diff --git a/src/tui/components/SessionReplay.ts b/src/tui/components/SessionReplay.ts index 611ee00..f927247 100644 --- a/src/tui/components/SessionReplay.ts +++ b/src/tui/components/SessionReplay.ts @@ -565,6 +565,176 @@ export class SessionReplay extends EventEmitter { this.parent.render(); } + /** + * Get all events (for export) + */ + getEvents(): LogEvent[] { + return [...this.events]; + } + + /** + * Get filtered events (for export) + */ + getFilteredEvents(): LogEvent[] { + return [...this.filteredEvents]; + } + + /** + * Export session to file + */ + exportToFile(filePath?: string): string { + const eventsToExport = this.filteredEvents.length > 0 ? this.filteredEvents : this.events; + + if (eventsToExport.length === 0) { + throw new Error('No events to export'); + } + + // Calculate metadata + const timestamps = eventsToExport.map(e => e.ts); + const sessionStart = Math.min(...timestamps); + const sessionEnd = Math.max(...timestamps); + const workers = new Set(eventsToExport.map(e => e.worker)); + + const exportData = { + version: '1.0', + exportedAt: Date.now(), + eventCount: eventsToExport.length, + events: eventsToExport, + metadata: { + sessionStart, + sessionEnd, + workerCount: workers.size, + sourcePath: this.sourcePath || undefined, + }, + }; + + // Generate filename if not provided + const exportPath = filePath || this.generateExportFilename(exportData.metadata); + + fs.writeFileSync(exportPath, JSON.stringify(exportData, null, 2), 'utf-8'); + + return exportPath; + } + + /** + * Import session from file + */ + importFromFile(filePath: string): { eventCount: number; metadata: object } { + if (!fs.existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + + const content = fs.readFileSync(filePath, 'utf-8'); + const importData = JSON.parse(content); + + // Validate structure + if (!importData.version || !Array.isArray(importData.events)) { + throw new Error('Invalid replay export format'); + } + + // Validate events have required fields + for (const event of importData.events) { + if (typeof event.ts !== 'number' || !event.worker || !event.msg) { + throw new Error('Invalid event format in export'); + } + } + + // Load events + this.sourcePath = filePath; + this.events = importData.events; + this.applyFilter(); + this.currentIndex = 0; + this.state = 'idle'; + this.logBox.setContent(''); + this.updateDisplay(); + this.emit('loaded', this.events.length); + this.emit('imported', { eventCount: importData.eventCount, metadata: importData.metadata }); + + return { + eventCount: importData.eventCount, + metadata: importData.metadata, + }; + } + + /** + * Export to base64 string (for sharing) + */ + exportToBase64(): string { + const eventsToExport = this.filteredEvents.length > 0 ? this.filteredEvents : this.events; + + if (eventsToExport.length === 0) { + throw new Error('No events to export'); + } + + // Calculate metadata + const timestamps = eventsToExport.map(e => e.ts); + const sessionStart = Math.min(...timestamps); + const sessionEnd = Math.max(...timestamps); + const workers = new Set(eventsToExport.map(e => e.worker)); + + const exportData = { + version: '1.0', + exportedAt: Date.now(), + eventCount: eventsToExport.length, + events: eventsToExport, + metadata: { + sessionStart, + sessionEnd, + workerCount: workers.size, + sourcePath: this.sourcePath || undefined, + }, + }; + + const jsonString = JSON.stringify(exportData); + return Buffer.from(jsonString, 'utf-8').toString('base64'); + } + + /** + * Import from base64 string + */ + importFromBase64(base64String: string): { eventCount: number; metadata: object } { + const jsonString = Buffer.from(base64String, 'base64').toString('utf-8'); + const importData = JSON.parse(jsonString); + + // Validate structure + if (!importData.version || !Array.isArray(importData.events)) { + throw new Error('Invalid replay export format'); + } + + // Validate events have required fields + for (const event of importData.events) { + if (typeof event.ts !== 'number' || !event.worker || !event.msg) { + throw new Error('Invalid event format in export'); + } + } + + // Load events + this.sourcePath = '[imported]'; + this.events = importData.events; + this.applyFilter(); + this.currentIndex = 0; + this.state = 'idle'; + this.logBox.setContent(''); + this.updateDisplay(); + this.emit('loaded', this.events.length); + this.emit('imported', { eventCount: importData.eventCount, metadata: importData.metadata }); + + return { + eventCount: importData.eventCount, + metadata: importData.metadata, + }; + } + + /** + * Generate export filename + */ + private generateExportFilename(metadata: { sessionStart: number }): string { + const date = new Date(metadata.sessionStart); + const dateStr = date.toISOString().split('T')[0]; // YYYY-MM-DD + const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, '-'); // HH-MM-SS + return `session-${dateStr}-${timeStr}.fabric-replay`; + } + /** * Clean up resources */ diff --git a/src/utils/replayExport.ts b/src/utils/replayExport.ts new file mode 100644 index 0000000..f9ea390 --- /dev/null +++ b/src/utils/replayExport.ts @@ -0,0 +1,388 @@ +/** + * Session Replay Export/Import Utilities + * + * Provides functionality for exporting and importing session replay data + * as shareable links or .fabric-replay files. + */ + +import { LogEvent } from '../types.js'; + +/** + * Version of the export format + */ +export const REPLAY_EXPORT_VERSION = '1.0'; + +/** + * Metadata for the exported session + */ +export interface ReplayExportMetadata { + /** Unix timestamp of session start */ + sessionStart: number; + /** Unix timestamp of session end */ + sessionEnd: number; + /** Number of unique workers */ + workerCount: number; + /** Optional source file path */ + sourcePath?: string; + /** Optional description */ + description?: string; +} + +/** + * Export format for session replay + */ +export interface ReplayExport { + /** Format version */ + version: string; + /** Unix timestamp when exported */ + exportedAt: number; + /** Number of events in the export */ + eventCount: number; + /** The events to replay */ + events: LogEvent[]; + /** Metadata about the session */ + metadata: ReplayExportMetadata; +} + +/** + * Convert web LogEvent format to core LogEvent format + */ +export interface WebLogEvent { + timestamp: string; + level: 'debug' | 'info' | 'warn' | 'error'; + worker: string; + tool?: string; + message: string; + raw: string; + bead?: string; +} + +/** + * Create a replay export from events + */ +export function createReplayExport( + events: LogEvent[], + options: { + sourcePath?: string; + description?: string; + } = {} +): ReplayExport { + if (events.length === 0) { + return { + version: REPLAY_EXPORT_VERSION, + exportedAt: Date.now(), + eventCount: 0, + events: [], + metadata: { + sessionStart: Date.now(), + sessionEnd: Date.now(), + workerCount: 0, + sourcePath: options.sourcePath, + description: options.description, + }, + }; + } + + // Sort events by timestamp + const sortedEvents = [...events].sort((a, b) => a.ts - b.ts); + + // Calculate metadata + const timestamps = sortedEvents.map(e => e.ts); + const sessionStart = Math.min(...timestamps); + const sessionEnd = Math.max(...timestamps); + const workers = new Set(sortedEvents.map(e => e.worker)); + + return { + version: REPLAY_EXPORT_VERSION, + exportedAt: Date.now(), + eventCount: sortedEvents.length, + events: sortedEvents, + metadata: { + sessionStart, + sessionEnd, + workerCount: workers.size, + sourcePath: options.sourcePath, + description: options.description, + }, + }; +} + +/** + * Export events to JSON string + */ +export function exportToJson(events: LogEvent[], options?: { + sourcePath?: string; + description?: string; +}): string { + const exportData = createReplayExport(events, options); + return JSON.stringify(exportData, null, 2); +} + +/** + * Export events to base64 encoded string (for URL sharing) + */ +export function exportToBase64(events: LogEvent[], options?: { + sourcePath?: string; + description?: string; +}): string { + const exportData = createReplayExport(events, options); + const jsonString = JSON.stringify(exportData); + // Use Node.js Buffer for base64 encoding + return Buffer.from(jsonString, 'utf-8').toString('base64'); +} + +/** + * Import events from JSON string + */ +export function importFromJson(jsonString: string): ReplayExport { + try { + const data = JSON.parse(jsonString) as ReplayExport; + + // Validate structure + if (!data.version || !Array.isArray(data.events)) { + throw new Error('Invalid replay export format'); + } + + // Validate events have required fields + for (const event of data.events) { + if (typeof event.ts !== 'number' || !event.worker || !event.msg) { + throw new Error('Invalid event format in export'); + } + } + + return data; + } catch (error) { + throw new Error(`Failed to parse replay export: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Import events from base64 encoded string (from URL) + */ +export function importFromBase64(base64String: string): ReplayExport { + try { + const jsonString = Buffer.from(base64String, 'base64').toString('utf-8'); + return importFromJson(jsonString); + } catch (error) { + throw new Error(`Failed to decode replay data: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Generate a shareable URL with replay data + */ +export function generateShareableUrl( + events: LogEvent[], + baseUrl: string, + options?: { + sourcePath?: string; + description?: string; + } +): string { + const base64Data = exportToBase64(events, options); + const url = new URL(baseUrl); + url.searchParams.set('replay', base64Data); + return url.toString(); +} + +/** + * Extract replay data from URL parameters + */ +export function extractReplayFromUrl(url: string): ReplayExport | null { + try { + const parsedUrl = new URL(url); + const replayParam = parsedUrl.searchParams.get('replay'); + + if (!replayParam) { + return null; + } + + return importFromBase64(replayParam); + } catch { + return null; + } +} + +/** + * Generate a filename for the export + */ +export function generateExportFilename(metadata: ReplayExportMetadata): string { + const date = new Date(metadata.sessionStart); + const dateStr = date.toISOString().split('T')[0]; // YYYY-MM-DD + const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, '-'); // HH-MM-SS + return `session-${dateStr}-${timeStr}.fabric-replay`; +} + +/** + * Validate a replay export + */ +export function validateReplayExport(data: unknown): data is ReplayExport { + if (typeof data !== 'object' || data === null) { + return false; + } + + const obj = data as Record; + + // Check required fields + if (typeof obj.version !== 'string') return false; + if (typeof obj.exportedAt !== 'number') return false; + if (typeof obj.eventCount !== 'number') return false; + if (!Array.isArray(obj.events)) return false; + if (typeof obj.metadata !== 'object' || obj.metadata === null) return false; + + // Validate metadata + const metadata = obj.metadata as Record; + if (typeof metadata.sessionStart !== 'number') return false; + if (typeof metadata.sessionEnd !== 'number') return false; + if (typeof metadata.workerCount !== 'number') return false; + + return true; +} + +// ============================================ +// Web-specific utilities (for browser environment) +// ============================================ + +/** + * Export events to base64 (browser version) + */ +export function exportToBase64Browser(events: WebLogEvent[]): string { + const exportData = createReplayExportWeb(events); + const jsonString = JSON.stringify(exportData); + return btoa(encodeURIComponent(jsonString)); +} + +/** + * Import events from base64 (browser version) + */ +export function importFromBase64Browser(base64String: string): ReplayExportWeb { + try { + const jsonString = decodeURIComponent(atob(base64String)); + return JSON.parse(jsonString); + } catch (error) { + throw new Error(`Failed to decode replay data: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Web version of ReplayExport + */ +export interface ReplayExportWeb { + version: string; + exportedAt: number; + eventCount: number; + events: WebLogEvent[]; + metadata: { + sessionStart: number; + sessionEnd: number; + workerCount: number; + sourcePath?: string; + description?: string; + }; +} + +/** + * Create a replay export from web events + */ +export function createReplayExportWeb( + events: WebLogEvent[], + options: { + sourcePath?: string; + description?: string; + } = {} +): ReplayExportWeb { + if (events.length === 0) { + return { + version: REPLAY_EXPORT_VERSION, + exportedAt: Date.now(), + eventCount: 0, + events: [], + metadata: { + sessionStart: Date.now(), + sessionEnd: Date.now(), + workerCount: 0, + sourcePath: options.sourcePath, + description: options.description, + }, + }; + } + + // Sort events by timestamp + const sortedEvents = [...events].sort((a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + ); + + // Calculate metadata + const timestamps = sortedEvents.map(e => new Date(e.timestamp).getTime()); + const sessionStart = Math.min(...timestamps); + const sessionEnd = Math.max(...timestamps); + const workers = new Set(sortedEvents.map(e => e.worker)); + + return { + version: REPLAY_EXPORT_VERSION, + exportedAt: Date.now(), + eventCount: sortedEvents.length, + events: sortedEvents, + metadata: { + sessionStart, + sessionEnd, + workerCount: workers.size, + sourcePath: options.sourcePath, + description: options.description, + }, + }; +} + +/** + * Export web events to JSON string + */ +export function exportToJsonWeb(events: WebLogEvent[], options?: { + sourcePath?: string; + description?: string; +}): string { + const exportData = createReplayExportWeb(events, options); + return JSON.stringify(exportData, null, 2); +} + +/** + * Import web events from JSON string + */ +export function importFromJsonWeb(jsonString: string): ReplayExportWeb { + try { + const data = JSON.parse(jsonString); + + // Validate structure + if (!data.version || !Array.isArray(data.events)) { + throw new Error('Invalid replay export format'); + } + + // Validate events have required fields + for (const event of data.events) { + if (!event.timestamp || !event.worker || !event.message) { + throw new Error('Invalid event format in export'); + } + } + + return data; + } catch (error) { + throw new Error(`Failed to parse replay export: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +export default { + REPLAY_EXPORT_VERSION, + createReplayExport, + exportToJson, + exportToBase64, + importFromJson, + importFromBase64, + generateShareableUrl, + extractReplayFromUrl, + generateExportFilename, + validateReplayExport, + exportToBase64Browser, + importFromBase64Browser, + createReplayExportWeb, + exportToJsonWeb, + importFromJsonWeb, +}; diff --git a/src/web/frontend/src/App.tsx b/src/web/frontend/src/App.tsx index 167045d..6c1f4ad 100644 --- a/src/web/frontend/src/App.tsx +++ b/src/web/frontend/src/App.tsx @@ -10,6 +10,8 @@ import DependencyDag from './components/DependencyDag'; import RecoveryPanel from './components/RecoveryPanel'; import FileContextPanel from './components/FileContextPanel'; import TimelineView from './components/TimelineView'; +import SessionReplay from './components/SessionReplay'; +import { extractReplayFromUrl, ReplayExport } from './utils/replayExport'; const FOCUS_MODE_STORAGE_KEY = 'fabric-focus-mode'; @@ -228,6 +230,26 @@ const App: React.FC = () => { const [selectedTimelineTime, setSelectedTimelineTime] = useState(null); const [recoverySuggestions, setRecoverySuggestions] = useState([]); + // Session Replay state + const [showSessionReplay, setShowSessionReplay] = useState(false); + const [replayEvents, setReplayEvents] = useState([]); + const [replayMetadata, setReplayMetadata] = useState(null); + const [replayImportError, setReplayImportError] = useState(null); + + // Check URL for replay parameter on mount + useEffect(() => { + const replayData = extractReplayFromUrl(); + if (replayData) { + setReplayEvents(replayData.events); + setReplayMetadata(replayData.metadata); + setShowSessionReplay(true); + // Clear the URL parameter after loading + const url = new URL(window.location.href); + url.searchParams.delete('replay'); + window.history.replaceState({}, '', url.toString()); + } + }, []); + // Focus Mode state const [focusModeEnabled, setFocusModeEnabled] = useState(false); const [pinnedWorkers, setPinnedWorkers] = useState>(new Set()); @@ -436,6 +458,14 @@ const App: React.FC = () => { 📊 Timeline + {unacknowledgedAlertCount > 0 && ( + + {replayImportError && ( +
{replayImportError}
+ )} + 0 ? replayEvents : filteredEvents} + onImport={(importedEvents, metadata) => { + setReplayEvents(importedEvents); + setReplayMetadata(metadata); + setReplayImportError(null); + }} + /> + + )} ); diff --git a/src/web/frontend/src/components/SessionReplay.tsx b/src/web/frontend/src/components/SessionReplay.tsx index 9145651..10f59ed 100644 --- a/src/web/frontend/src/components/SessionReplay.tsx +++ b/src/web/frontend/src/components/SessionReplay.tsx @@ -3,10 +3,18 @@ * * Provides session replay functionality - ability to replay worker activity * history chronologically with playback controls. + * Includes export/import functionality for sharing sessions. */ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { LogEvent, ReplaySpeed, ReplayState } from '../types'; +import { + exportToBase64Browser, + importFromBase64Browser, + exportToJsonWeb, + importFromJsonWeb, + ReplayExportWeb, +} from '../utils/replayExport'; // Re-export types for external use export type { ReplaySpeed, ReplayState }; @@ -26,6 +34,9 @@ interface SessionReplayProps { /** Optional CSS class */ className?: string; + + /** Callback when events are imported */ + onImport?: (events: LogEvent[], metadata: ReplayExportWeb['metadata']) => void; } /** @@ -62,6 +73,7 @@ const SessionReplay: React.FC = ({ onEvent, onStateChange, className = '', + onImport, }) => { // Playback state const [state, setState] = useState('idle'); @@ -69,9 +81,16 @@ const SessionReplay: React.FC = ({ const [speed, setSpeed] = useState(1); const [displayedEvents, setDisplayedEvents] = useState([]); + // Export/Import state + const [showExportMenu, setShowExportMenu] = useState(false); + const [importError, setImportError] = useState(null); + const [exportSuccess, setExportSuccess] = useState(null); + // Refs const playbackTimerRef = useRef(null); const eventListRef = useRef(null); + const fileInputRef = useRef(null); + const exportMenuRef = useRef(null); // Filter events const filteredEvents = React.useMemo(() => { @@ -165,6 +184,20 @@ const SessionReplay: React.FC = ({ } }, [displayedEvents, state]); + // Click outside to close export menu + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (exportMenuRef.current && !exportMenuRef.current.contains(e.target as Node)) { + setShowExportMenu(false); + } + }; + + if (showExportMenu) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [showExportMenu]); + // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -316,6 +349,116 @@ const SessionReplay: React.FC = ({ seekToPercent(percent); }; + // ============================================ + // Export/Import Functions + // ============================================ + + /** + * Export as shareable link (copies to clipboard) + */ + const handleExportLink = useCallback(async () => { + if (filteredEvents.length === 0) { + setImportError('No events to export'); + return; + } + + try { + const base64Data = exportToBase64Browser(filteredEvents); + const url = `${window.location.origin}${window.location.pathname}?replay=${base64Data}`; + + await navigator.clipboard.writeText(url); + setExportSuccess('Link copied to clipboard!'); + setTimeout(() => setExportSuccess(null), 3000); + setShowExportMenu(false); + } catch (err) { + setImportError(`Failed to create shareable link: ${err instanceof Error ? err.message : 'Unknown error'}`); + setTimeout(() => setImportError(null), 5000); + } + }, [filteredEvents]); + + /** + * Export as .fabric-replay file + */ + const handleExportFile = useCallback(() => { + if (filteredEvents.length === 0) { + setImportError('No events to export'); + return; + } + + try { + const jsonData = exportToJsonWeb(filteredEvents); + const blob = new Blob([jsonData], { type: 'application/json' }); + 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}.fabric-replay`; + + 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 file: ${err instanceof Error ? err.message : 'Unknown error'}`); + setTimeout(() => setImportError(null), 5000); + } + }, [filteredEvents]); + + /** + * Import from file + */ + const handleImportFile = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const content = event.target?.result as string; + const importData = importFromJsonWeb(content); + + if (importData.events.length === 0) { + setImportError('No events found in import file'); + return; + } + + onImport?.(importData.events, importData.metadata); + setExportSuccess(`Imported ${importData.eventCount} events`); + setTimeout(() => setExportSuccess(null), 3000); + setShowExportMenu(false); + } catch (err) { + setImportError(`Failed to import file: ${err instanceof Error ? err.message : 'Unknown error'}`); + setTimeout(() => setImportError(null), 5000); + } + }; + + reader.onerror = () => { + setImportError('Failed to read file'); + setTimeout(() => setImportError(null), 5000); + }; + + reader.readAsText(file); + + // Reset input for re-import of same file + e.target.value = ''; + }, [onImport]); + + /** + * Trigger file input click + */ + const handleImportClick = useCallback(() => { + fileInputRef.current?.click(); + }, []); + // Format event for display const formatEvent = (event: LogEvent): React.ReactNode => { const time = new Date(event.timestamp).toLocaleTimeString(); @@ -372,6 +515,18 @@ const SessionReplay: React.FC = ({ )} + {/* Export/Import Status Messages */} + {importError && ( +
+ {importError} +
+ )} + {exportSuccess && ( +
+ {exportSuccess} +
+ )} + {/* Controls bar */}
@@ -439,6 +594,53 @@ const SessionReplay: React.FC = ({
+ {/* Export/Import Menu */} +
+ + {showExportMenu && ( +
+ + + +
+ )} +
+ + {/* Hidden file input for import */} + + [Space] Play/Pause | [←/→] Step | [↑/↓] Speed | [r] Reset diff --git a/src/web/frontend/src/index.css b/src/web/frontend/src/index.css index 96c048c..abf652c 100644 --- a/src/web/frontend/src/index.css +++ b/src/web/frontend/src/index.css @@ -981,6 +981,184 @@ body { color: var(--text-secondary); } +/* Export/Import Styles */ +.replay-export-menu { + position: relative; + margin-right: 0.5rem; +} + +.replay-btn-share { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); +} + +.replay-btn-share:hover { + background: var(--accent); + border-color: var(--accent); +} + +.replay-export-dropdown { + position: absolute; + bottom: 100%; + right: 0; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + box-shadow: 0 4px 12px var(--shadow-color); + min-width: 160px; + z-index: 1000; + margin-bottom: 4px; + overflow: hidden; +} + +.replay-dropdown-item { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.625rem 0.875rem; + background: none; + border: none; + color: var(--text-primary); + font-size: 0.8rem; + cursor: pointer; + text-align: left; + transition: background 0.15s; +} + +.replay-dropdown-item:hover { + background: var(--hover-bg); +} + +.replay-dropdown-item:disabled { + color: var(--text-secondary); + cursor: not-allowed; + opacity: 0.6; +} + +/* Status Messages */ +.replay-status { + padding: 0.5rem 1rem; + font-size: 0.8rem; + text-align: center; + animation: fadeIn 0.2s ease-out; +} + +.replay-status-success { + background: rgba(0, 200, 83, 0.15); + color: var(--success); + border-top: 1px solid rgba(0, 200, 83, 0.3); +} + +.replay-status-error { + background: rgba(244, 67, 54, 0.15); + color: var(--error); + border-top: 1px solid rgba(244, 67, 54, 0.3); +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Session Replay Panel (App-level) */ +.session-replay-toggle { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.375rem 0.625rem; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + cursor: pointer; + font-size: 0.75rem; + transition: all 0.2s; +} + +.session-replay-toggle:hover { + background: var(--hover-bg); +} + +.session-replay-toggle.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.session-replay-toggle-icon { + font-size: 1rem; +} + +.session-replay-panel { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 300px; + background: var(--bg-secondary); + border-top: 1px solid var(--border-color); + z-index: 100; + display: flex; + flex-direction: column; + box-shadow: 0 -4px 12px var(--shadow-color); +} + +.session-replay-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1rem; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-color); +} + +.session-replay-header h3 { + font-size: 0.875rem; + font-weight: 600; + margin: 0; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.session-replay-header .replay-info { + font-size: 0.75rem; + font-weight: 400; + color: var(--text-secondary); +} + +.session-replay-header .close-button { + background: none; + border: none; + color: var(--text-secondary); + font-size: 1.25rem; + cursor: pointer; + padding: 0.25rem 0.5rem; + line-height: 1; + border-radius: 4px; + transition: all 0.2s; +} + +.session-replay-header .close-button:hover { + background: var(--bg-primary); + color: var(--text-primary); +} + +.replay-import-error { + padding: 0.5rem 1rem; + background: rgba(244, 67, 54, 0.15); + color: var(--error); + font-size: 0.8rem; +} + +.session-replay-panel .session-replay { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + /* ============================================ Collision Alert Panel Styles ============================================ */ diff --git a/src/web/frontend/src/utils/replayExport.ts b/src/web/frontend/src/utils/replayExport.ts new file mode 100644 index 0000000..4dec1d1 --- /dev/null +++ b/src/web/frontend/src/utils/replayExport.ts @@ -0,0 +1,241 @@ +/** + * Session Replay Export/Import Utilities for Web Frontend + * + * Provides functionality for exporting and importing session replay data + * as shareable links or .fabric-replay files. + */ + +import { LogEvent } from '../types'; + +/** + * Version of the export format + */ +export const REPLAY_EXPORT_VERSION = '1.0'; + +/** + * Metadata for the exported session + */ +export interface ReplayExportMetadata { + /** Unix timestamp of session start */ + sessionStart: number; + /** Unix timestamp of session end */ + sessionEnd: number; + /** Number of unique workers */ + workerCount: number; + /** Optional source file path */ + sourcePath?: string; + /** Optional description */ + description?: string; +} + +/** + * Export format for session replay + */ +export interface ReplayExport { + version: string; + exportedAt: number; + eventCount: number; + events: LogEvent[]; + metadata: ReplayExportMetadata; +} + +/** + * Create a replay export from events + */ +export function createReplayExport( + events: LogEvent[], + options: { + sourcePath?: string; + description?: string; + } = {} +): ReplayExport { + if (events.length === 0) { + return { + version: REPLAY_EXPORT_VERSION, + exportedAt: Date.now(), + eventCount: 0, + events: [], + metadata: { + sessionStart: Date.now(), + sessionEnd: Date.now(), + workerCount: 0, + sourcePath: options.sourcePath, + description: options.description, + }, + }; + } + + // Sort events by timestamp + const sortedEvents = [...events].sort((a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + ); + + // Calculate metadata + const timestamps = sortedEvents.map(e => new Date(e.timestamp).getTime()); + const sessionStart = Math.min(...timestamps); + const sessionEnd = Math.max(...timestamps); + const workers = new Set(sortedEvents.map(e => e.worker)); + + return { + version: REPLAY_EXPORT_VERSION, + exportedAt: Date.now(), + eventCount: sortedEvents.length, + events: sortedEvents, + metadata: { + sessionStart, + sessionEnd, + workerCount: workers.size, + sourcePath: options.sourcePath, + description: options.description, + }, + }; +} + +/** + * Export events to JSON string + */ +export function exportToJson(events: LogEvent[], options?: { + sourcePath?: string; + description?: string; +}): string { + const exportData = createReplayExport(events, options); + return JSON.stringify(exportData, null, 2); +} + +/** + * Export events to base64 encoded string (for URL sharing) + * Uses browser's btoa with UTF-8 encoding support + */ +export function exportToBase64Browser(events: LogEvent[]): string { + const exportData = createReplayExport(events); + const jsonString = JSON.stringify(exportData); + // Encode UTF-8 to handle special characters + return btoa(encodeURIComponent(jsonString).replace(/%([0-9A-F]{2})/g, (_, p1) => { + return String.fromCharCode(parseInt(p1, 16)); + })); +} + +/** + * Import events from JSON string + */ +export function importFromJson(jsonString: string): ReplayExport { + try { + const data = JSON.parse(jsonString) as ReplayExport; + + // Validate structure + if (!data.version || !Array.isArray(data.events)) { + throw new Error('Invalid replay export format'); + } + + // Validate events have required fields + for (const event of data.events) { + if (!event.timestamp || !event.worker || !event.message) { + throw new Error('Invalid event format in export'); + } + } + + return data; + } catch (error) { + throw new Error(`Failed to parse replay export: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Import events from base64 encoded string (from URL) + * Uses browser's atob with UTF-8 decoding support + */ +export function importFromBase64Browser(base64String: string): ReplayExport { + try { + const jsonString = decodeURIComponent(atob(base64String).split('').map(c => { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); + }).join('')); + return importFromJson(jsonString); + } catch (error) { + throw new Error(`Failed to decode replay data: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Generate a shareable URL with replay data + */ +export function generateShareableUrl( + events: LogEvent[], + baseUrl: string +): string { + const base64Data = exportToBase64Browser(events); + const url = new URL(baseUrl); + url.searchParams.set('replay', base64Data); + return url.toString(); +} + +/** + * Extract replay data from URL parameters + */ +export function extractReplayFromUrl(): ReplayExport | null { + try { + const replayParam = new URLSearchParams(window.location.search).get('replay'); + + if (!replayParam) { + return null; + } + + return importFromBase64Browser(replayParam); + } catch { + return null; + } +} + +/** + * Generate a filename for the export + */ +export function generateExportFilename(metadata: ReplayExportMetadata): string { + const date = new Date(metadata.sessionStart); + const dateStr = date.toISOString().split('T')[0]; // YYYY-MM-DD + const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, '-'); // HH-MM-SS + return `session-${dateStr}-${timeStr}.fabric-replay`; +} + +/** + * Validate a replay export + */ +export function validateReplayExport(data: unknown): data is ReplayExport { + if (typeof data !== 'object' || data === null) { + return false; + } + + const obj = data as Record; + + // Check required fields + if (typeof obj.version !== 'string') return false; + if (typeof obj.exportedAt !== 'number') return false; + if (typeof obj.eventCount !== 'number') return false; + if (!Array.isArray(obj.events)) return false; + if (typeof obj.metadata !== 'object' || obj.metadata === null) return false; + + // Validate metadata + const metadata = obj.metadata as Record; + if (typeof metadata.sessionStart !== 'number') return false; + if (typeof metadata.sessionEnd !== 'number') return false; + if (typeof metadata.workerCount !== 'number') return false; + + return true; +} + +// Type aliases for compatibility +export type ReplayExportWeb = ReplayExport; +export const createReplayExportWeb = createReplayExport; +export const exportToJsonWeb = exportToJson; +export const importFromJsonWeb = importFromJson; + +export default { + REPLAY_EXPORT_VERSION, + createReplayExport, + exportToJson, + exportToBase64Browser, + importFromJson, + importFromBase64Browser, + generateShareableUrl, + extractReplayFromUrl, + generateExportFilename, + validateReplayExport, +};