UNPKG

rabbitize

Version:

Transform visual UI interactions into automated processes

1,055 lines (939 loc) 71 kB
<!DOCTYPE html> <html> <head> <title>RABBITIZE /// STREAMING DASHBOARD</title> <link rel="stylesheet" href="/resources/streaming/themes/theme-variables.css"> <link rel="stylesheet" href="/resources/streaming/themes/theme-styles.css"> <link rel="stylesheet" href="/resources/streaming/cyberpunk.css"> <link rel="stylesheet" href="/resources/streaming/themes/theme-switcher.css"> <link rel="icon" type="image/x-icon" href="/resources/streaming/favicon.png"> <!-- Monaco Editor for syntax highlighting in modals --> <link rel="stylesheet" data-name="vs/editor/editor.main" href="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.44.0/min/vs/editor/editor.main.min.css"> </head> <body> <div class="container"> <div class="header"> <div style="display: flex; align-items: center; justify-content: flex-start; gap: 20px;"> <img src="/resources/streaming/images/rabbitize.png" width="100px" height="100px" style="display: block;"/> <div> <h1 class="glitch" data-text="RABBITIZE /// STREAMING" style="font-size: 40px; margin: 0;">RABBITIZE /// STREAMING</h1> <div class="subtitle" style="margin-top: 5px;">SEE WHAT YOUR BROWSER AUTOMATION IS DOING</div> </div> </div> </div> <div class="sessions"> {{SESSIONS_CONTENT}} </div> </div> <!-- Process debugging panel --> <div id="process-panel" class="process-panel"> <div class="process-panel-header"> <h3>PROCESS DEBUGGING</h3> <button class="close-btn" onclick="toggleProcessPanel()">&times;</button> </div> <div class="process-panel-content"> <div class="process-section"> <h4>TRACKED PROCESSES</h4> <div id="tracked-processes">Loading...</div> </div> <div class="process-section"> <h4>ORPHANED INSTANCES</h4> <button class="action-btn" onclick="scanForOrphans()">[ SCAN FOR ORPHANS ]</button> <div id="orphaned-instances"></div> </div> </div> </div> <!-- Process logs modal --> <div id="logs-modal" class="modal"> <div class="modal-content" style="max-width: 80%; max-height: 80%;"> <div class="modal-header"> <h2 class="modal-title">PROCESS LOGS</h2> <span class="close-btn" onclick="closeLogsModal()">&times;</span> </div> <div class="modal-body" style="overflow: auto;"> <h3>STDOUT</h3> <pre id="stdout-content" class="log-content"></pre> <h3>STDERR</h3> <pre id="stderr-content" class="log-content"></pre> </div> </div> </div> <!-- Step detail overlay --> <div id="step-overlay" class="step-overlay"> <span class="step-overlay-close" onclick="closeStepOverlay()">&times;</span> <div class="step-overlay-content" id="step-overlay-content"> <!-- Content will be dynamically loaded here --> </div> </div> <!-- Re-run confirmation modal --> <div id="rerun-modal" class="modal"> <div class="modal-content" style="max-width: 600px;"> <div class="modal-header"> <h2 class="modal-title">CONFIRM RE-RUN</h2> <span class="close-btn" onclick="closeRerunModal()">&times;</span> </div> <div class="modal-body" id="rerun-modal-body"> <!-- Content will be dynamically loaded here --> </div> </div> </div> <script> // Helper function to format date in human-readable format function formatHumanDate(timestamp) { const date = new Date(timestamp); const options = { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }; return date.toLocaleDateString('en-US', options).replace(' at ', ' '); } // Helper function to format duration in human-readable format function formatDuration(seconds) { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = seconds % 60; const parts = []; if (hours > 0) { parts.push(`${hours} hour${hours !== 1 ? 's' : ''}`); } if (minutes > 0) { parts.push(`${minutes} minute${minutes !== 1 ? 's' : ''}`); } if (secs > 0 || parts.length === 0) { parts.push(`${secs} second${secs !== 1 ? 's' : ''}`); } return parts.join(', '); } // Helper function to render a single session card function renderSessionCard(session, statusClass, statusText, uptime, isHistorical) { const sessionKey = `${session.clientId}/${session.testId}/${session.sessionId}`; return ` <div class="session-card ${isHistorical ? 'historical' : ''}" data-session-id="${session.sessionId}" data-session-key="${sessionKey}"> <div class="timestamp">${formatHumanDate(session.startTime)}</div> <div class="session-info"> <div class="session-cover"> <a href="/single-session/${session.clientId}/${session.testId}/${session.sessionId}"> <img src="${session.status === 'finished' || session.phase === 'completed' ? `/rabbitize-runs/${session.clientId}/${session.testId}/${session.sessionId}/video/cover.gif` : '/resources/streaming/images/cyberpunk-running.svg'}" alt="Session preview" onerror="this.style.display='none'" loading="lazy"> </a> </div> ${session.initialUrl ? ` <div class="info-item url-item"> <div class="info-label">URL</div> <div class="info-value url-value" title="${session.initialUrl}">${session.initialUrl}</div> </div> ` : ''} <div class="info-item"> <div class="info-label">Status</div> <div class="info-value"><span class="${statusClass}"></span>${statusText}</div> </div> <div class="info-item"> <div class="info-label">Client ID</div> <div class="info-value"> <a href="/single-client/${session.clientId}" class="session-id-link">${session.clientId}</a> </div> </div> <div class="info-item"> <div class="info-label">Test ID</div> <div class="info-value"> <a href="/single-test/${session.clientId}/${session.testId}" class="session-id-link">${session.testId}</a> </div> </div> <div class="info-item"> <div class="info-label">Session ID</div> <div class="info-value"> <a href="/single-session/${session.clientId}/${session.testId}/${session.sessionId}" class="session-id-link">${session.sessionId}</a> </div> </div> <div class="info-item"> <div class="info-label">Steps</div> <div class="info-value command-count">${session.commandCount}</div> </div> <div class="info-item"> <div class="info-label">Phase</div> <div class="info-value phase">${session.phase || 'unknown'}</div> </div> <div class="info-item"> <div class="info-label uptime-label">${session.status === 'active' ? 'Uptime' : 'Duration'}</div> <div class="info-value uptime">${uptime}</div> </div> ${session.isExternal ? ` <div class="info-item"> <div class="info-label">External</div> <div class="info-value"><span style="color: #f0f;">Port ${session.port || 'Unknown'}</span></div> </div> ` : ''} </div> <div class="zoom-preview-container" data-session-key="${sessionKey}"> <div class="zoom-preview-loading">Loading previews...</div> </div> <div class="timing-chart-container" data-session-key="${sessionKey}"> <!-- Timing chart will be loaded here --> </div> <div class="actions"> ${session.status === 'active' ? ` <a href="${session.isExternal && session.port ? `http://${window.location.hostname}:${session.port}` : ''}/stream/${session.clientId}/${session.testId}/${session.sessionId}" class="action-link" target="_blank"> [ DIRECT STREAM ] </a> <a href="/stream-viewer/${session.clientId}/${session.testId}/${session.sessionId}" class="action-link" target="_blank"> [ WEB VIEWER ] </a> ` : ` <a href="/stream-viewer/${session.clientId}/${session.testId}/${session.sessionId}" class="action-link" target="_blank"> [ WATCH VIDEO ] </a> `} <button class="action-link details-btn" onclick="showSessionDetails('${session.clientId}', '${session.testId}', '${session.sessionId}')"> [ COMMANDS ] </button> <button class="action-link rerun-btn" onclick="confirmRerun('${session.clientId}', '${session.testId}', '${session.sessionId}')"> [ RE-RUN ] </button> </div> </div> `; } // Track collapsed state const collapsedGroups = JSON.parse(localStorage.getItem('collapsedGroups') || '{}'); // Function to toggle accordion function toggleAccordion(groupId) { const element = document.getElementById(groupId); if (element) { element.classList.toggle('collapsed'); collapsedGroups[groupId] = element.classList.contains('collapsed'); localStorage.setItem('collapsedGroups', JSON.stringify(collapsedGroups)); } } // Make it globally accessible window.toggleAccordion = toggleAccordion; // Function to render session data with hierarchy function renderSessions(sessions) { const container = document.getElementById('sessions-container'); // Save existing zoom preview and timing content before re-rendering const existingPreviews = {}; const existingTimings = {}; container.querySelectorAll('.session-card.details-loaded').forEach(card => { const sessionKey = card.dataset.sessionKey; const zoomContainer = card.querySelector('.zoom-preview-container'); const timingContainer = card.querySelector('.timing-chart-container'); if (zoomContainer) { existingPreviews[sessionKey] = zoomContainer.innerHTML; } if (timingContainer) { existingTimings[sessionKey] = timingContainer.innerHTML; } }); if (!sessions || sessions.length === 0) { container.innerHTML = ` <div class="no-sessions"> <p>NO SESSIONS FOUND</p> <p style="margin-top: 10px; font-size: 12px;">Start a session to begin streaming</p> </div> `; return; } // Group sessions by clientId and testId const grouped = {}; sessions.forEach(session => { if (!grouped[session.clientId]) { grouped[session.clientId] = {}; } if (!grouped[session.clientId][session.testId]) { grouped[session.clientId][session.testId] = []; } grouped[session.clientId][session.testId].push(session); }); // Start building the HTML let html = ''; // Render grouped sessions Object.entries(grouped).forEach(([clientId, tests]) => { const clientGroupId = `client-${clientId}`; const isCollapsed = collapsedGroups[clientGroupId] || false; const totalSessions = Object.values(tests).reduce((sum, sessions) => sum + sessions.length, 0); const activeSessions = Object.values(tests).flat().filter(s => s.status === 'active').length; html += ` <div class="client-group ${isCollapsed ? 'collapsed' : ''}" id="${clientGroupId}"> <div class="client-header"> <a href="/single-client/${clientId}" class="client-header-link" onclick="event.stopPropagation()"> <h2>CLIENT: ${clientId}</h2> </a> <div class="client-stats" onclick="toggleAccordion('${clientGroupId}')"> <span>${totalSessions} session${totalSessions !== 1 ? 's' : ''}</span> ${activeSessions > 0 ? `<span style="color: #0f0;">${activeSessions} active</span>` : ''} <span class="toggle-icon">▼</span> </div> </div> <div class="client-content"> `; Object.entries(tests).forEach(([testId, testSessions]) => { const testGroupId = `test-${clientId}-${testId}`; const isTestCollapsed = collapsedGroups[testGroupId] || false; const activeTestSessions = testSessions.filter(s => s.status === 'active').length; html += ` <div class="test-group ${isTestCollapsed ? 'collapsed' : ''}" id="${testGroupId}"> <div class="test-header"> <a href="/single-test/${clientId}/${testId}" class="test-header-link" onclick="event.stopPropagation()"> <h3>TEST: ${testId}</h3> </a> <div class="client-stats" onclick="toggleAccordion('${testGroupId}')"> <span>${testSessions.length} run${testSessions.length !== 1 ? 's' : ''}</span> ${activeTestSessions > 0 ? `<span style="color: #0f0;">${activeTestSessions} active</span>` : ''} <span class="toggle-icon">▼</span> </div> </div> <div class="test-content"> `; // Render sessions for this test testSessions.forEach(session => { const statusClass = session.status === 'active' ? 'status-indicator' : 'status-indicator-finished'; const statusText = session.status.toUpperCase(); const uptimeSeconds = session.status === 'active' ? Math.floor((Date.now() - session.startTime) / 1000) : Math.floor((session.duration || 0) / 1000); const uptime = formatDuration(uptimeSeconds); const isHistorical = session.phase === 'legacy' || session.phase === 'unknown'; html += renderSessionCard(session, statusClass, statusText, uptime, isHistorical); }); html += ` </div> </div> `; }); html += ` </div> </div> `; }); container.innerHTML = html; // Restore zoom preview and timing content after re-rendering Object.entries(existingPreviews).forEach(([sessionKey, content]) => { const card = document.querySelector(`.session-card[data-session-key="${sessionKey}"]`); if (card) { card.classList.add('details-loaded'); const zoomContainer = card.querySelector('.zoom-preview-container'); if (zoomContainer) { zoomContainer.innerHTML = content; } } }); Object.entries(existingTimings).forEach(([sessionKey, content]) => { const timingContainer = document.querySelector(`.timing-chart-container[data-session-key="${sessionKey}"]`); if (timingContainer) { timingContainer.innerHTML = content; } }); } // Store sessions globally for header updates let currentSessions = []; // Function to fetch and update sessions async function updateSessions() { try { const response = await fetch('/api/sessions'); const sessions = await response.json(); currentSessions = sessions; // Store for other uses renderSessions(sessions); } catch (error) { console.error('Failed to fetch sessions:', error); } } // Track loaded session details const loadedSessions = new Set(); // Track active sessions that need continuous updates const activeSessions = new Map(); // Function to render timing chart function renderTimingChart(timingData, totalDuration) { if (!timingData || timingData.length === 0) { return '<div class="timing-chart-empty">No timing data available</div>'; } let html = '<div class="timing-chart">'; timingData.forEach((item, index) => { // Calculate positions as percentages const startPercent = (item.relativeStart / totalDuration) * 100; const widthPercent = (item.duration / totalDuration) * 100; // Add gap if there is one if (item.gapBefore > 0) { const gapStartPercent = ((item.relativeStart - item.gapBefore) / totalDuration) * 100; const gapWidthPercent = (item.gapBefore / totalDuration) * 100; html += `<div class="timing-gap" style="left: ${gapStartPercent}%; width: ${gapWidthPercent}%;"></div>`; } // Add timing bar const barLabel = widthPercent > 5 ? `${index}` : ''; html += ` <div class="timing-bar" data-command="${item.command}" data-index="${index}" style="left: ${startPercent}%; width: ${widthPercent}%;" title="${item.command} - ${item.duration}ms"> ${barLabel} <div class="timing-tooltip"> Step ${index}: ${item.command}<br> Duration: ${(item.duration / 1000).toFixed(2)}s </div> </div> `; }); html += '</div>'; // Add stats const totalSeconds = (totalDuration / 1000).toFixed(2); const avgDuration = (timingData.reduce((sum, item) => sum + item.duration, 0) / timingData.length / 1000).toFixed(2); html += ` <div class="timing-stats"> <div class="timing-stat"> <span class="timing-stat-label">Total:</span> <span>${totalSeconds}s</span> </div> <div class="timing-stat"> <span class="timing-stat-label">Steps:</span> <span>${timingData.length}</span> </div> <div class="timing-stat"> <span class="timing-stat-label">Avg:</span> <span>${avgDuration}s</span> </div> </div> `; return html; } // Function to lazy load session details (zoom previews and timing) async function loadSessionDetails() { const containers = document.querySelectorAll('.session-card:not(.details-loaded)'); // Clean up loadedSessions set - remove sessions that no longer exist const currentSessionKeys = new Set(); document.querySelectorAll('.session-card').forEach(el => { currentSessionKeys.add(el.dataset.sessionKey); }); for (const key of loadedSessions) { if (!currentSessionKeys.has(key)) { loadedSessions.delete(key); } } for (const container of containers) { const rect = container.getBoundingClientRect(); // Check if container is in viewport (with 100px buffer) if (rect.top < window.innerHeight + 100 && rect.bottom > -100) { const sessionKey = container.dataset.sessionKey; if (!loadedSessions.has(sessionKey)) { loadedSessions.add(sessionKey); container.classList.add('details-loaded'); try { const [clientId, testId, sessionId] = sessionKey.split('/'); const response = await fetch(`/api/session/${clientId}/${testId}/${sessionId}`); const details = await response.json(); // Track if this is an active session if (details.isActive) { activeSessions.set(sessionKey, { commandsExecuted: details.commandsExecuted, totalCommands: details.totalCommands }); } else { activeSessions.delete(sessionKey); } // Update zoom previews const zoomContainer = container.querySelector('.zoom-preview-container'); if (zoomContainer) { if (details.zoomImages && details.zoomImages.length > 0) { zoomContainer.innerHTML = ` <div class="zoom-preview-grid"> ${details.zoomImages.map(img => ` <img class="zoom-thumb" src="${img.url}" alt="Step ${img.index}" title="Step ${img.index}" data-index="${img.index}" loading="lazy"> `).join('')} </div> `; // Show progress indicator for active sessions if (details.isActive && details.totalCommands > 0) { const progress = Math.round((details.commandsExecuted / details.totalCommands) * 100); zoomContainer.innerHTML += ` <div style="margin-top: 5px; font-size: 11px; opacity: 0.7;"> Loading: ${details.commandsExecuted}/${details.totalCommands} (${progress}%) </div> `; } } else { zoomContainer.innerHTML = '<div class="zoom-preview-empty">No preview images</div>'; } } // Update timing chart const timingContainer = container.querySelector('.timing-chart-container'); if (timingContainer && details.timingData) { timingContainer.innerHTML = renderTimingChart(details.timingData, details.totalDuration); } } catch (error) { console.error('Failed to load session details:', error); const zoomContainer = container.querySelector('.zoom-preview-container'); if (zoomContainer) { zoomContainer.innerHTML = '<div class="zoom-preview-error">Failed to load details</div>'; } } } } } } // Update immediately on load updateSessions().then(() => { // Load visible session details after sessions are rendered setTimeout(loadSessionDetails, 100); }); // Update status and metrics for existing sessions every 1 second setInterval(async () => { try { const response = await fetch('/api/sessions'); const sessions = await response.json(); // Update only the dynamic parts of existing session cards sessions.forEach(session => { const sessionKey = `${session.clientId}/${session.testId}/${session.sessionId}`; const card = document.querySelector(`.session-card[data-session-key="${sessionKey}"]`); if (card) { // Update status const statusElement = card.querySelector('.info-value .status-indicator, .info-value .status-indicator-finished'); if (statusElement) { const newClass = session.status === 'active' ? 'status-indicator' : 'status-indicator-finished'; statusElement.className = newClass; statusElement.nextSibling.textContent = session.status.toUpperCase(); } // Update command count const commandCount = card.querySelector('.command-count'); if (commandCount) { commandCount.textContent = session.commandCount; } // Update phase const phase = card.querySelector('.phase'); if (phase) { phase.textContent = (session.phase || 'unknown').replace(/_/g, ' '); } // Update uptime const uptime = card.querySelector('.uptime'); if (uptime) { const uptimeSeconds = session.status === 'active' ? Math.floor((Date.now() - session.startTime) / 1000) : Math.floor((session.duration || 0) / 1000); uptime.textContent = formatDuration(uptimeSeconds); } // Update session cover image when status changes const coverImg = card.querySelector('.session-cover img'); if (coverImg) { const newSrc = session.status === 'finished' || session.phase === 'completed' ? `/rabbitize-runs/${session.clientId}/${session.testId}/${session.sessionId}/video/cover.gif` : '/resources/streaming/images/cyberpunk-running.svg'; // Only update if the src has changed if (coverImg.src !== new URL(newSrc, window.location.origin).href) { coverImg.src = newSrc; } } // Update uptime label const uptimeLabel = card.querySelector('.uptime-label'); if (uptimeLabel) { uptimeLabel.textContent = session.status === 'active' ? 'Uptime' : 'Duration'; } } }); // Also reload active sessions to get new zoom images reloadActiveSessions(); } catch (error) { console.error('Failed to update session status:', error); } }, 1000); // Check for new sessions without re-rendering existing ones setInterval(async () => { try { const response = await fetch('/api/sessions'); const newSessions = await response.json(); // Get current session keys const existingKeys = new Set(); document.querySelectorAll('.session-card').forEach(card => { existingKeys.add(card.dataset.sessionKey); }); // Get new session keys const newKeys = new Set(); newSessions.forEach(session => { const key = `${session.clientId}/${session.testId}/${session.sessionId}`; newKeys.add(key); }); // Check if sessions were added or removed const hasNewSessions = newSessions.some(session => { const key = `${session.clientId}/${session.testId}/${session.sessionId}`; return !existingKeys.has(key); }); const hasRemovedSessions = Array.from(existingKeys).some(key => !newKeys.has(key)); // Only do a full refresh if structure changed if (hasNewSessions || hasRemovedSessions) { updateSessions().then(() => { loadSessionDetails(); }); } } catch (error) { console.error('Failed to check for new sessions:', error); } }, 10000); // Check every 10 seconds // Function to reload active sessions for new zoom images async function reloadActiveSessions() { for (const [sessionKey, sessionInfo] of activeSessions) { const container = document.querySelector(`.session-card[data-session-key="${sessionKey}"]`); if (container) { try { const [clientId, testId, sessionId] = sessionKey.split('/'); const response = await fetch(`/api/session/${clientId}/${testId}/${sessionId}`); const details = await response.json(); // Only update if there are new commands executed if (details.commandsExecuted > sessionInfo.commandsExecuted) { // Update our tracking sessionInfo.commandsExecuted = details.commandsExecuted; // Update zoom previews const zoomContainer = container.querySelector('.zoom-preview-container'); if (zoomContainer && details.zoomImages && details.zoomImages.length > 0) { zoomContainer.innerHTML = ` <div class="zoom-preview-grid"> ${details.zoomImages.map(img => ` <img class="zoom-thumb" src="${img.url}" alt="Step ${img.index}" title="Step ${img.index}" data-index="${img.index}" loading="lazy"> `).join('')} </div> `; // Show progress indicator if (details.totalCommands > 0) { const progress = Math.round((details.commandsExecuted / details.totalCommands) * 100); zoomContainer.innerHTML += ` <div style="margin-top: 5px; font-size: 11px; opacity: 0.7;"> Loading: ${details.commandsExecuted}/${details.totalCommands} (${progress}%) </div> `; } } // Update timing chart const timingContainer = container.querySelector('.timing-chart-container'); if (timingContainer && details.timingData) { timingContainer.innerHTML = renderTimingChart(details.timingData, details.totalDuration); } } // Remove from active sessions if no longer active if (!details.isActive) { activeSessions.delete(sessionKey); } } catch (error) { console.error('Failed to reload active session:', error); } } } } // Load session details on scroll let scrollTimeout; window.addEventListener('scroll', () => { clearTimeout(scrollTimeout); scrollTimeout = setTimeout(loadSessionDetails, 100); }); // Function to show session details in a modal async function showSessionDetails(clientId, testId, sessionId) { try { const response = await fetch(`/api/session/${clientId}/${testId}/${sessionId}`); const details = await response.json(); // Create modal if it doesn't exist let modal = document.getElementById('details-modal'); if (!modal) { modal = document.createElement('div'); modal.id = 'details-modal'; modal.className = 'modal'; modal.innerHTML = ` <div class="modal-content"> <div class="modal-header"> <h2 class="modal-title">REPLAY COMMANDS</h2> <span class="close-btn" onclick="closeModal()">&times;</span> </div> <div class="modal-body" id="modal-body"> <!-- Content will be inserted here --> </div> </div> `; document.body.appendChild(modal); } // Build modal content let content = `<div class="details-section">`; // Command reconstruction if (details.hasCommands) { const cliCommand = generateCLICommand(details); const curlCommands = generateCURLCommands(details); content += ` <h3>REPLAY COMMANDS</h3> <div class="command-section"> <h4>CLI BATCH MODE</h4> <div id="cli-editor" style="height: 200px; border: 1px solid #0ff;"></div> <button class="copy-btn" onclick="copyCommandContent('cli')">[ COPY ]</button> </div> <div class="command-section" style="margin-top: 20px;"> <h4>CURL SEQUENCE</h4> <div id="curl-editor" style="height: 300px; border: 1px solid #0ff;"></div> <button class="copy-btn" onclick="copyCommandContent('curl')">[ COPY ]</button> </div> `; // Store commands for Monaco initialization window.pendingCommands = { cli: cliCommand, curl: curlCommands }; } else { content += `<p style="text-align: center; opacity: 0.6;">No commands found for this session</p>`; } content += `</div>`; document.getElementById('modal-body').innerHTML = content; modal.style.display = 'block'; // Initialize Monaco editors if we have commands if (window.pendingCommands) { initializeMonaco(() => { // Create CLI editor if (document.getElementById('cli-editor')) { cliEditor = monaco.editor.create(document.getElementById('cli-editor'), { value: window.pendingCommands.cli, language: 'shell', theme: 'cyberpunk-dark', readOnly: true, minimap: { enabled: false }, scrollBeyondLastLine: false, renderLineHighlight: 'none', lineNumbers: 'off', glyphMargin: false, folding: false, automaticLayout: true, wordWrap: 'on' }); } // Create cURL editor if (document.getElementById('curl-editor')) { curlEditor = monaco.editor.create(document.getElementById('curl-editor'), { value: window.pendingCommands.curl, language: 'shell', theme: 'cyberpunk-dark', readOnly: true, minimap: { enabled: false }, scrollBeyondLastLine: false, renderLineHighlight: 'none', lineNumbers: 'off', glyphMargin: false, folding: false, automaticLayout: true, wordWrap: 'on' }); } }); } } catch (error) { console.error('Failed to load session details:', error); alert('Failed to load session details'); } } // Generate CLI command function generateCLICommand(details) { const commands = details.commands.map(cmd => ' ' + JSON.stringify(cmd) ).join(',\n'); return `node src/index.js \\ --client-id "${details.clientId}" \\ --test-id "${details.testId}" \\ --exit-on-end true \\ --process-video true \\ --batch-url "${details.initialUrl || 'https://ryrob.es'}" \\ --batch-commands='[ ${commands} ]'`; } // Generate CURL commands function generateCURLCommands(details) { let commands = []; // Start command commands.push(`curl -s -X POST http://${window.location.hostname}:${window.location.port}/start \\ -H "Content-Type: application/json" \\ -d '{"url": "${details.initialUrl || 'https://ryrob.es'}"}' | jq`); // Execute commands details.commands.forEach(cmd => { commands.push(`curl -s -X POST http://${window.location.hostname}:${window.location.port}/execute \\ -H "Content-Type: application/json" \\ -d '{"command": ${JSON.stringify(cmd)}}' | jq`); }); // End command commands.push(`curl -s -X POST http://${window.location.hostname}:${window.location.port}/end | jq`); return commands.join('\n'); } // Close modal function closeModal() { const modal = document.getElementById('details-modal'); if (modal) { modal.style.display = 'none'; } // Dispose Monaco editors if (cliEditor) { cliEditor.dispose(); cliEditor = null; } if (curlEditor) { curlEditor.dispose(); curlEditor = null; } window.pendingCommands = null; } // Copy command content from Monaco editor function copyCommandContent(type) { let text = ''; if (type === 'cli' && cliEditor) { text = cliEditor.getValue(); } else if (type === 'curl' && curlEditor) { text = curlEditor.getValue(); } if (text) { navigator.clipboard.writeText(text).then(() => { // Visual feedback event.target.textContent = '[ COPIED! ]'; setTimeout(() => { event.target.textContent = '[ COPY ]'; }, 2000); }).catch(err => { console.error('Failed to copy:', err); }); } } // Make copy function globally accessible window.copyCommandContent = copyCommandContent; // Copy to clipboard function copyToClipboard(text) { navigator.clipboard.writeText(text).then(() => { // Visual feedback event.target.textContent = '[ COPIED! ]'; setTimeout(() => { event.target.textContent = '[ COPY ]'; }, 2000); }).catch(err => { console.error('Failed to copy:', err); }); } // Close modal when clicking outside window.onclick = function(event) { const detailsModal = document.getElementById('details-modal'); const rerunModal = document.getElementById('rerun-modal'); if (event.target === detailsModal) { closeModal(); } if (event.target === rerunModal) { closeRerunModal(); } } // Setup hover interactions between zoom thumbs and timing bars function setupHoverInteractions() { // Handle zoom thumb hover document.addEventListener('mouseover', (e) => { if (e.target.classList.contains('zoom-thumb')) { const index = e.target.dataset.index; if (index !== undefined) { // Find the parent session card const sessionCard = e.target.closest('.session-card'); if (sessionCard) { // Highlight corresponding timing bar const timingBar = sessionCard.querySelector(`.timing-bar[data-index="${index}"]`); if (timingBar) { timingBar.classList.add('highlight'); } } } } // Handle timing bar hover if (e.target.classList.contains('timing-bar')) { const index = e.target.dataset.index; if (index !== undefined) { // Find the parent session card const sessionCard = e.target.closest('.session-card'); if (sessionCard) { // Highlight corresponding zoom thumb const zoomThumb = sessionCard.querySelector(`.zoom-thumb[data-index="${index}"]`); if (zoomThumb) { zoomThumb.classList.add('highlight'); } } } } }); // Handle mouseout to remove highlights document.addEventListener('mouseout', (e) => { if (e.target.classList.contains('zoom-thumb')) { const index = e.target.dataset.index; if (index !== undefined) { const sessionCard = e.target.closest('.session-card'); if (sessionCard) { const timingBar = sessionCard.querySelector(`.timing-bar[data-index="${index}"]`); if (timingBar) { timingBar.classList.remove('highlight'); } } } } if (e.target.classList.contains('timing-bar')) { const index = e.target.dataset.index; if (index !== undefined) { const sessionCard = e.target.closest('.session-card'); if (sessionCard) { const zoomThumb = sessionCard.querySelector(`.zoom-thumb[data-index="${index}"]`); if (zoomThumb) { zoomThumb.classList.remove('highlight'); } } } } }); } // Initialize hover interactions setupHoverInteractions(); // Step overlay functions async function showStepDetails(clientId, testId, sessionId, stepIndex) { try { const response = await fetch(`/api/session/${clientId}/${testId}/${sessionId}/step/${stepIndex}`); const stepData = await response.json(); // Build overlay content let content = ` <div class="step-screenshot-section"> `; // Screenshots if (stepData.screenshots.pre) { content += ` <div class="step-screenshot"> <img src="${stepData.screenshots.pre}" alt="Pre-command state"> <div class="step-screenshot-label">Before</div> </div> `; } if (stepData.screenshots.post) { content += ` <div class="step-screenshot"> <img src="${stepData.screenshots.post}" alt="Post-command state"> <div class="step-screenshot-label">After</div> </div> `; } content += `</div><div class="step-info-section">`; // Header content += ` <div class="step-header"> <div class="step-title">Step ${stepIndex}</div> <div class="step-command-display">${JSON.stringify(stepData.command)}</div> </div> `; // Metrics if (stepData.timing || stepData.metrics) { content += '<div class="step-metrics">'; if (stepData.timing) { content += ` <div class="step-metric">