rabbitize
Version:
Transform visual UI interactions into automated processes
1,055 lines (939 loc) • 71 kB
HTML
<!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()">×</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()">×</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()">×</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()">×</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()">×</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">