feat(bd-1dq): Export Session Replay
Add export/import functionality for session replay:
**Export Features:**
- Export as shareable link (base64 encoded events in URL parameter)
- Export as .fabric-replay file (JSON format)
- Share button in replay controls with dropdown menu
**Import Features:**
- Import from .fabric-replay file via file picker
- Import from URL parameter (?replay=base64EncodedEvents)
- Automatic detection and loading of replay data from URL
**Implementation:**
- src/utils/replayExport.ts: Core export/import utilities for Node.js
- src/web/frontend/src/utils/replayExport.ts: Browser-specific utilities
- Updated web SessionReplay.tsx with export dropdown and status messages
- Updated TUI SessionReplay.ts with exportToFile/importFromFile methods
- Added session replay panel to App.tsx with URL parameter support
- Added CSS styles for export dropdown, status messages, and replay panel
**Export Format:**
```json
{
"version": "1.0",
"exportedAt": 1709337600,
"eventCount": 150,
"events": [...],
"metadata": {
"sessionStart": 1709337000,
"sessionEnd": 1709337600,
"workerCount": 3
}
}
```
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4d6aa15c13
commit
7ddbd7820b
6 changed files with 1245 additions and 0 deletions
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
388
src/utils/replayExport.ts
Normal file
388
src/utils/replayExport.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
|
||||
// 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<string, unknown>;
|
||||
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,
|
||||
};
|
||||
|
|
@ -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<number | null>(null);
|
||||
const [recoverySuggestions, setRecoverySuggestions] = useState<RecoverySuggestion[]>([]);
|
||||
|
||||
// Session Replay state
|
||||
const [showSessionReplay, setShowSessionReplay] = useState(false);
|
||||
const [replayEvents, setReplayEvents] = useState<LogEvent[]>([]);
|
||||
const [replayMetadata, setReplayMetadata] = useState<ReplayExport['metadata'] | null>(null);
|
||||
const [replayImportError, setReplayImportError] = useState<string | null>(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<Set<string>>(new Set());
|
||||
|
|
@ -436,6 +458,14 @@ const App: React.FC = () => {
|
|||
<span className="timeline-toggle-icon">📊</span>
|
||||
<span className="timeline-toggle-label">Timeline</span>
|
||||
</button>
|
||||
<button
|
||||
className={`session-replay-toggle ${showSessionReplay ? 'active' : ''}`}
|
||||
onClick={() => setShowSessionReplay(!showSessionReplay)}
|
||||
title={showSessionReplay ? 'Hide session replay' : 'Show session replay'}
|
||||
>
|
||||
<span className="session-replay-toggle-icon">📼</span>
|
||||
<span className="session-replay-toggle-label">Replay</span>
|
||||
</button>
|
||||
{unacknowledgedAlertCount > 0 && (
|
||||
<button
|
||||
className="collision-alert-toggle"
|
||||
|
|
@ -555,6 +585,42 @@ const App: React.FC = () => {
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSessionReplay && (
|
||||
<div className="session-replay-panel">
|
||||
<div className="session-replay-header">
|
||||
<h3>
|
||||
Session Replay
|
||||
{replayMetadata && (
|
||||
<span className="replay-info">
|
||||
{replayMetadata.eventCount || replayEvents.length} events | {replayMetadata.workerCount} workers
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<button
|
||||
className="close-button"
|
||||
onClick={() => {
|
||||
setShowSessionReplay(false);
|
||||
setReplayEvents([]);
|
||||
setReplayMetadata(null);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{replayImportError && (
|
||||
<div className="replay-import-error">{replayImportError}</div>
|
||||
)}
|
||||
<SessionReplay
|
||||
events={replayEvents.length > 0 ? replayEvents : filteredEvents}
|
||||
onImport={(importedEvents, metadata) => {
|
||||
setReplayEvents(importedEvents);
|
||||
setReplayMetadata(metadata);
|
||||
setReplayImportError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<SessionReplayProps> = ({
|
|||
onEvent,
|
||||
onStateChange,
|
||||
className = '',
|
||||
onImport,
|
||||
}) => {
|
||||
// Playback state
|
||||
const [state, setState] = useState<ReplayState>('idle');
|
||||
|
|
@ -69,9 +81,16 @@ const SessionReplay: React.FC<SessionReplayProps> = ({
|
|||
const [speed, setSpeed] = useState<ReplaySpeed>(1);
|
||||
const [displayedEvents, setDisplayedEvents] = useState<LogEvent[]>([]);
|
||||
|
||||
// Export/Import state
|
||||
const [showExportMenu, setShowExportMenu] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [exportSuccess, setExportSuccess] = useState<string | null>(null);
|
||||
|
||||
// Refs
|
||||
const playbackTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const eventListRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const exportMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Filter events
|
||||
const filteredEvents = React.useMemo(() => {
|
||||
|
|
@ -165,6 +184,20 @@ const SessionReplay: React.FC<SessionReplayProps> = ({
|
|||
}
|
||||
}, [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<SessionReplayProps> = ({
|
|||
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<HTMLInputElement>) => {
|
||||
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<SessionReplayProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Export/Import Status Messages */}
|
||||
{importError && (
|
||||
<div className="replay-status replay-status-error">
|
||||
{importError}
|
||||
</div>
|
||||
)}
|
||||
{exportSuccess && (
|
||||
<div className="replay-status replay-status-success">
|
||||
{exportSuccess}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls bar */}
|
||||
<div className="replay-controls">
|
||||
<div className="replay-controls-left">
|
||||
|
|
@ -439,6 +594,53 @@ const SessionReplay: React.FC<SessionReplayProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="replay-controls-right">
|
||||
{/* Export/Import Menu */}
|
||||
<div className="replay-export-menu" ref={exportMenuRef}>
|
||||
<button
|
||||
className="replay-btn replay-btn-share"
|
||||
onClick={() => setShowExportMenu(!showExportMenu)}
|
||||
title="Export/Import replay"
|
||||
>
|
||||
📤
|
||||
</button>
|
||||
{showExportMenu && (
|
||||
<div className="replay-export-dropdown">
|
||||
<button
|
||||
className="replay-dropdown-item"
|
||||
onClick={handleExportLink}
|
||||
disabled={filteredEvents.length === 0}
|
||||
title="Copy shareable link to clipboard"
|
||||
>
|
||||
🔗 Copy Share Link
|
||||
</button>
|
||||
<button
|
||||
className="replay-dropdown-item"
|
||||
onClick={handleExportFile}
|
||||
disabled={filteredEvents.length === 0}
|
||||
title="Download as .fabric-replay file"
|
||||
>
|
||||
💾 Export File
|
||||
</button>
|
||||
<button
|
||||
className="replay-dropdown-item"
|
||||
onClick={handleImportClick}
|
||||
title="Import from .fabric-replay file"
|
||||
>
|
||||
📂 Import File
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hidden file input for import */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".fabric-replay,.json"
|
||||
onChange={handleImportFile}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
<span className="replay-help">
|
||||
[Space] Play/Pause | [←/→] Step | [↑/↓] Speed | [r] Reset
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
============================================ */
|
||||
|
|
|
|||
241
src/web/frontend/src/utils/replayExport.ts
Normal file
241
src/web/frontend/src/utils/replayExport.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
|
||||
// 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<string, unknown>;
|
||||
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,
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue