feat(replay): add session replay export functionality
Add comprehensive export/import functionality for session replay in both TUI and web UI: - TUI: Add keyboard shortcuts [e] export file, [E] export base64, [m] export markdown, [i] import - Web UI: Add export dropdown with JSON, Markdown, and shareable link options - Web UI: Add "Import from URL" option for loading replay data - Auto-import from URL parameters on page load for shared links Export formats: - JSON (.fabric-replay): Full event data with metadata - Markdown (.md): Human-readable session summary with tables - Base64 URL: Shareable link for collaboration Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
a6418ac539
commit
a05281c796
4 changed files with 450 additions and 2 deletions
|
|
@ -156,6 +156,12 @@ export class SessionReplay extends EventEmitter {
|
|||
this.container.key(['3'], () => this.setSpeed(2));
|
||||
this.container.key(['4'], () => this.setSpeed(5));
|
||||
this.container.key(['5'], () => this.setSpeed(10));
|
||||
|
||||
// Export shortcuts
|
||||
this.container.key(['e'], () => this.handleExportFile());
|
||||
this.container.key(['E'], () => this.handleExportBase64());
|
||||
this.container.key(['m'], () => this.handleExportMarkdown());
|
||||
this.container.key(['i'], () => this.handleImportPrompt());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -529,7 +535,91 @@ export class SessionReplay extends EventEmitter {
|
|||
*/
|
||||
private formatControls(): string {
|
||||
const speedDisplay = `${this.speed}x`;
|
||||
return ` Speed: ${speedDisplay} | [Space] Play/Pause | [←/→] Step | [↑/↓] Speed | [1-5] 0.5x-10x | [Home/End] Jump | [r] Reset`;
|
||||
return ` Speed: ${speedDisplay} | [Space] Play/Pause | [←/→] Step | [↑/↓] Speed | [1-5] 0.5x-10x | [Home/End] Jump | [r] Reset | [e/m/E] Export | [i] Import`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle export to file
|
||||
*/
|
||||
private handleExportFile(): void {
|
||||
try {
|
||||
const filePath = this.exportToFile();
|
||||
this.logBox.log(`{green-fg}Exported to: ${filePath}{/}`);
|
||||
} catch (err) {
|
||||
this.logBox.log(`{red-fg}Export failed: ${err instanceof Error ? err.message : 'Unknown error'}{/}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle export to base64 (for sharing)
|
||||
*/
|
||||
private handleExportBase64(): void {
|
||||
try {
|
||||
const base64 = this.exportToBase64();
|
||||
const url = `https://example.com/replay?data=${base64.slice(0, 50)}...`;
|
||||
this.logBox.log(`{green-fg}Shareable URL generated (${base64.length} bytes){/}`);
|
||||
this.logBox.log(`{yellow-fg}Use this base64 data to share the replay{/}`);
|
||||
this.logBox.log(`{cyan-fg}${base64.slice(0, 100)}...{/}`);
|
||||
} catch (err) {
|
||||
this.logBox.log(`{red-fg}Export failed: ${err instanceof Error ? err.message : 'Unknown error'}{/}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle export to markdown
|
||||
*/
|
||||
private handleExportMarkdown(): void {
|
||||
try {
|
||||
const { exportToMarkdown } = require('../../utils/replayExport.js');
|
||||
const eventsToExport = this.filteredEvents.length > 0 ? this.filteredEvents : this.events;
|
||||
|
||||
if (eventsToExport.length === 0) {
|
||||
this.logBox.log(`{red-fg}No events to export{/}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const markdown = exportToMarkdown(eventsToExport, {
|
||||
sourcePath: this.sourcePath || undefined,
|
||||
});
|
||||
|
||||
// Generate filename
|
||||
const timestamps = eventsToExport.map(e => e.ts);
|
||||
const sessionStart = Math.min(...timestamps);
|
||||
const date = new Date(sessionStart);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, '-');
|
||||
const filename = `session-${dateStr}-${timeStr}.md`;
|
||||
|
||||
// Write file
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const exportPath = path.join(process.cwd(), filename);
|
||||
fs.writeFileSync(exportPath, markdown, 'utf-8');
|
||||
|
||||
this.logBox.log(`{green-fg}Exported Markdown to: ${exportPath}{/}`);
|
||||
} catch (err) {
|
||||
this.logBox.log(`{red-fg}Markdown export failed: ${err instanceof Error ? err.message : 'Unknown error'}{/}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle import from file prompt
|
||||
*/
|
||||
private handleImportPrompt(): void {
|
||||
this.logBox.log(`{yellow-fg}Import: Use importFromFile(path) or importFromBase64(string) methods{/}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import from URL (requires base64 data from URL)
|
||||
* Call this method programmatically with the base64 data
|
||||
*/
|
||||
importFromUrlData(base64Data: string): { eventCount: number; metadata: object } {
|
||||
try {
|
||||
return this.importFromBase64(base64Data);
|
||||
} catch (err) {
|
||||
this.logBox.log(`{red-fg}Import from URL failed: ${err instanceof Error ? err.message : 'Unknown error'}{/}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -239,6 +239,122 @@ export function validateReplayExport(data: unknown): data is ReplayExport {
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export events to Markdown string
|
||||
*/
|
||||
export function exportToMarkdown(events: LogEvent[], options?: {
|
||||
sourcePath?: string;
|
||||
description?: string;
|
||||
}): string {
|
||||
const exportData = createReplayExport(events, options);
|
||||
return formatAsMarkdown(exportData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a replay export as Markdown
|
||||
*/
|
||||
export function formatAsMarkdown(exportData: ReplayExport): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('# Session Replay');
|
||||
lines.push('');
|
||||
|
||||
// Session info
|
||||
lines.push('## Session Information');
|
||||
lines.push('');
|
||||
lines.push(`- **Exported At:** ${new Date(exportData.exportedAt).toLocaleString()}`);
|
||||
lines.push(`- **Version:** ${exportData.version}`);
|
||||
lines.push(`- **Events:** ${exportData.eventCount}`);
|
||||
lines.push('');
|
||||
|
||||
const metadata = exportData.metadata;
|
||||
lines.push(`- **Session Start:** ${new Date(metadata.sessionStart).toLocaleString()}`);
|
||||
lines.push(`- **Session End:** ${new Date(metadata.sessionEnd).toLocaleString()}`);
|
||||
lines.push(`- **Duration:** ${formatDuration(metadata.sessionEnd - metadata.sessionStart)}`);
|
||||
lines.push(`- **Workers:** ${metadata.workerCount}`);
|
||||
if (metadata.sourcePath) {
|
||||
lines.push(`- **Source:** ${metadata.sourcePath}`);
|
||||
}
|
||||
if (metadata.description) {
|
||||
lines.push(`- **Description:** ${metadata.description}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Events table
|
||||
lines.push('## Events');
|
||||
lines.push('');
|
||||
|
||||
if (exportData.events.length === 0) {
|
||||
lines.push('_No events in this session_');
|
||||
} else {
|
||||
lines.push('| Time | Worker | Level | Tool | Message |');
|
||||
lines.push('|------|--------|-------|------|---------|');
|
||||
|
||||
for (const event of exportData.events) {
|
||||
const time = new Date(event.ts).toLocaleTimeString();
|
||||
const worker = event.worker.slice(0, 8);
|
||||
const level = event.level.toUpperCase();
|
||||
const tool = event.tool || '';
|
||||
const message = (event.msg || '').replace(/\|/g, '\\|').replace(/\n/g, ' ').slice(0, 100);
|
||||
lines.push(`| ${time} | ${worker} | ${level} | ${tool} | ${message} |`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Worker breakdown
|
||||
lines.push('## Worker Breakdown');
|
||||
lines.push('');
|
||||
|
||||
const workerCounts = new Map<string, number>();
|
||||
for (const event of exportData.events) {
|
||||
workerCounts.set(event.worker, (workerCounts.get(event.worker) || 0) + 1);
|
||||
}
|
||||
|
||||
lines.push('| Worker | Events |');
|
||||
lines.push('|--------|--------|');
|
||||
for (const [worker, count] of workerCounts.entries()) {
|
||||
lines.push(`| ${worker.slice(0, 8)} | ${count} |`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Level breakdown
|
||||
lines.push('## Log Level Distribution');
|
||||
lines.push('');
|
||||
|
||||
const levelCounts = new Map<string, number>();
|
||||
for (const event of exportData.events) {
|
||||
levelCounts.set(event.level, (levelCounts.get(event.level) || 0) + 1);
|
||||
}
|
||||
|
||||
lines.push('| Level | Count |');
|
||||
lines.push('|-------|-------|');
|
||||
for (const [level, count] of levelCounts.entries()) {
|
||||
lines.push(`| ${level.toUpperCase()} | ${count} |`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
lines.push('---');
|
||||
lines.push(`*Generated by FABRIC at ${new Date().toLocaleString()}*`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in milliseconds to human readable format
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
if (ms < 3600000) {
|
||||
const mins = Math.floor(ms / 60000);
|
||||
const secs = Math.floor((ms % 60000) / 1000);
|
||||
return `${mins}m ${secs}s`;
|
||||
}
|
||||
const hours = Math.floor(ms / 3600000);
|
||||
const mins = Math.floor((ms % 3600000) / 60000);
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Web-specific utilities (for browser environment)
|
||||
// ============================================
|
||||
|
|
@ -374,6 +490,8 @@ export default {
|
|||
createReplayExport,
|
||||
exportToJson,
|
||||
exportToBase64,
|
||||
exportToMarkdown,
|
||||
formatAsMarkdown,
|
||||
importFromJson,
|
||||
importFromBase64,
|
||||
generateShareableUrl,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
importFromBase64Browser,
|
||||
exportToJsonWeb,
|
||||
importFromJsonWeb,
|
||||
exportToMarkdown,
|
||||
ReplayExportWeb,
|
||||
} from '../utils/replayExport';
|
||||
|
||||
|
|
@ -196,6 +197,28 @@ const SessionReplay: React.FC<SessionReplayProps> = ({
|
|||
}
|
||||
}, [showExportMenu]);
|
||||
|
||||
// Check for replay data in URL on mount
|
||||
useEffect(() => {
|
||||
const checkUrlForReplay = () => {
|
||||
try {
|
||||
const replayParam = new URLSearchParams(window.location.search).get('replay');
|
||||
if (replayParam) {
|
||||
const importData = importFromBase64Browser(replayParam);
|
||||
if (importData.events.length > 0) {
|
||||
onImport?.(importData.events, importData.metadata);
|
||||
setExportSuccess(`Imported ${importData.eventCount} events from URL`);
|
||||
setTimeout(() => setExportSuccess(null), 3000);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setImportError(`Failed to import from URL: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
setTimeout(() => setImportError(null), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
checkUrlForReplay();
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
|
|
@ -457,6 +480,90 @@ const SessionReplay: React.FC<SessionReplayProps> = ({
|
|||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Export as Markdown file
|
||||
*/
|
||||
const handleExportMarkdown = useCallback(() => {
|
||||
if (filteredEvents.length === 0) {
|
||||
setImportError('No events to export');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert web LogEvent format to core LogEvent format for exportToMarkdown
|
||||
const coreEvents = filteredEvents.map(e => ({
|
||||
ts: new Date(e.timestamp).getTime(),
|
||||
worker: e.worker,
|
||||
level: e.level,
|
||||
msg: e.message,
|
||||
tool: e.tool,
|
||||
path: e.path,
|
||||
bead: e.bead,
|
||||
sequence: e.sequence,
|
||||
}));
|
||||
|
||||
const markdown = exportToMarkdown(coreEvents);
|
||||
const blob = new Blob([markdown], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// Generate filename with timestamp
|
||||
const date = new Date();
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, '-');
|
||||
const filename = `session-${dateStr}-${timeStr}.md`;
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
setExportSuccess(`Exported as ${filename}`);
|
||||
setTimeout(() => setExportSuccess(null), 3000);
|
||||
setShowExportMenu(false);
|
||||
} catch (err) {
|
||||
setImportError(`Failed to export markdown: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
setTimeout(() => setImportError(null), 5000);
|
||||
}
|
||||
}, [filteredEvents]);
|
||||
|
||||
/**
|
||||
* Import from URL
|
||||
*/
|
||||
const handleImportUrl = useCallback(() => {
|
||||
const url = prompt('Enter replay URL or paste the base64 data:');
|
||||
if (!url) return;
|
||||
|
||||
try {
|
||||
// Check if it's a full URL or just base64 data
|
||||
let base64Data = url;
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
const parsedUrl = new URL(url);
|
||||
const replayParam = parsedUrl.searchParams.get('replay');
|
||||
if (!replayParam) {
|
||||
throw new Error('No replay data found in URL');
|
||||
}
|
||||
base64Data = replayParam;
|
||||
}
|
||||
|
||||
const importData = importFromBase64Browser(base64Data);
|
||||
if (importData.events.length === 0) {
|
||||
setImportError('No events found in import data');
|
||||
return;
|
||||
}
|
||||
|
||||
onImport?.(importData.events, importData.metadata);
|
||||
setExportSuccess(`Imported ${importData.eventCount} events`);
|
||||
setTimeout(() => setExportSuccess(null), 3000);
|
||||
setShowExportMenu(false);
|
||||
} catch (err) {
|
||||
setImportError(`Failed to import from URL: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
setTimeout(() => setImportError(null), 5000);
|
||||
}
|
||||
}, [onImport]);
|
||||
|
||||
// Format event for display
|
||||
const formatEvent = (event: LogEvent): React.ReactNode => {
|
||||
const time = new Date(event.timestamp).toLocaleTimeString();
|
||||
|
|
@ -617,7 +724,15 @@ const SessionReplay: React.FC<SessionReplayProps> = ({
|
|||
disabled={filteredEvents.length === 0}
|
||||
title="Download as .fabric-replay file"
|
||||
>
|
||||
💾 Export File
|
||||
💾 Export JSON
|
||||
</button>
|
||||
<button
|
||||
className="replay-dropdown-item"
|
||||
onClick={handleExportMarkdown}
|
||||
disabled={filteredEvents.length === 0}
|
||||
title="Export as Markdown file"
|
||||
>
|
||||
📝 Export Markdown
|
||||
</button>
|
||||
<button
|
||||
className="replay-dropdown-item"
|
||||
|
|
@ -626,6 +741,13 @@ const SessionReplay: React.FC<SessionReplayProps> = ({
|
|||
>
|
||||
📂 Import File
|
||||
</button>
|
||||
<button
|
||||
className="replay-dropdown-item"
|
||||
onClick={handleImportUrl}
|
||||
title="Import from URL or base64 data"
|
||||
>
|
||||
🌐 Import from URL
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -221,6 +221,122 @@ export function validateReplayExport(data: unknown): data is ReplayExport {
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export events to Markdown string
|
||||
*/
|
||||
export function exportToMarkdown(events: LogEvent[], options?: {
|
||||
sourcePath?: string;
|
||||
description?: string;
|
||||
}): string {
|
||||
const exportData = createReplayExport(events, options);
|
||||
return formatAsMarkdown(exportData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a replay export as Markdown
|
||||
*/
|
||||
export function formatAsMarkdown(exportData: ReplayExport): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('# Session Replay');
|
||||
lines.push('');
|
||||
|
||||
// Session info
|
||||
lines.push('## Session Information');
|
||||
lines.push('');
|
||||
lines.push(`- **Exported At:** ${new Date(exportData.exportedAt).toLocaleString()}`);
|
||||
lines.push(`- **Version:** ${exportData.version}`);
|
||||
lines.push(`- **Events:** ${exportData.eventCount}`);
|
||||
lines.push('');
|
||||
|
||||
const metadata = exportData.metadata;
|
||||
lines.push(`- **Session Start:** ${new Date(metadata.sessionStart).toLocaleString()}`);
|
||||
lines.push(`- **Session End:** ${new Date(metadata.sessionEnd).toLocaleString()}`);
|
||||
lines.push(`- **Duration:** ${formatDuration(metadata.sessionEnd - metadata.sessionStart)}`);
|
||||
lines.push(`- **Workers:** ${metadata.workerCount}`);
|
||||
if (metadata.sourcePath) {
|
||||
lines.push(`- **Source:** ${metadata.sourcePath}`);
|
||||
}
|
||||
if (metadata.description) {
|
||||
lines.push(`- **Description:** ${metadata.description}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Events table
|
||||
lines.push('## Events');
|
||||
lines.push('');
|
||||
|
||||
if (exportData.events.length === 0) {
|
||||
lines.push('_No events in this session_');
|
||||
} else {
|
||||
lines.push('| Time | Worker | Level | Tool | Message |');
|
||||
lines.push('|------|--------|-------|------|---------|');
|
||||
|
||||
for (const event of exportData.events) {
|
||||
const time = new Date(event.timestamp).toLocaleTimeString();
|
||||
const worker = event.worker.slice(0, 8);
|
||||
const level = event.level.toUpperCase();
|
||||
const tool = event.tool || '';
|
||||
const message = (event.message || '').replace(/\|/g, '\\|').replace(/\n/g, ' ').slice(0, 100);
|
||||
lines.push(`| ${time} | ${worker} | ${level} | ${tool} | ${message} |`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Worker breakdown
|
||||
lines.push('## Worker Breakdown');
|
||||
lines.push('');
|
||||
|
||||
const workerCounts = new Map<string, number>();
|
||||
for (const event of exportData.events) {
|
||||
workerCounts.set(event.worker, (workerCounts.get(event.worker) || 0) + 1);
|
||||
}
|
||||
|
||||
lines.push('| Worker | Events |');
|
||||
lines.push('|--------|--------|');
|
||||
for (const [worker, count] of workerCounts.entries()) {
|
||||
lines.push(`| ${worker.slice(0, 8)} | ${count} |`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Level breakdown
|
||||
lines.push('## Log Level Distribution');
|
||||
lines.push('');
|
||||
|
||||
const levelCounts = new Map<string, number>();
|
||||
for (const event of exportData.events) {
|
||||
levelCounts.set(event.level, (levelCounts.get(event.level) || 0) + 1);
|
||||
}
|
||||
|
||||
lines.push('| Level | Count |');
|
||||
lines.push('|-------|-------|');
|
||||
for (const [level, count] of levelCounts.entries()) {
|
||||
lines.push(`| ${level.toUpperCase()} | ${count} |`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
lines.push('---');
|
||||
lines.push(`*Generated by FABRIC at ${new Date().toLocaleString()}*`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in milliseconds to human readable format
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
if (ms < 3600000) {
|
||||
const mins = Math.floor(ms / 60000);
|
||||
const secs = Math.floor((ms % 60000) / 1000);
|
||||
return `${mins}m ${secs}s`;
|
||||
}
|
||||
const hours = Math.floor(ms / 3600000);
|
||||
const mins = Math.floor((ms % 3600000) / 60000);
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
|
||||
// Type aliases for compatibility
|
||||
export type ReplayExportWeb = ReplayExport;
|
||||
export const createReplayExportWeb = createReplayExport;
|
||||
|
|
@ -232,6 +348,8 @@ export default {
|
|||
createReplayExport,
|
||||
exportToJson,
|
||||
exportToBase64Browser,
|
||||
exportToMarkdown,
|
||||
formatAsMarkdown,
|
||||
importFromJson,
|
||||
importFromBase64Browser,
|
||||
generateShareableUrl,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue