rabbitize
Version:
Transform visual UI interactions into automated processes
476 lines (417 loc) • 22.7 kB
HTML
<html>
<head>
<title>{{CLIENT_ID}} - RABBITIZE CLIENT</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">
</head>
<body>
<a href="/streaming" class="back-link">[ ← BACK TO ALL CLIENTS ]</a>
<div class="single-client-container">
<div class="header">
<h1 class="glitch" data-text="CLIENT /// {{CLIENT_ID}}">CLIENT /// {{CLIENT_ID}}</h1>
<div class="subtitle">ALL TESTS AND SESSIONS</div>
</div>
<div id="tests-container">
<!-- Tests will be loaded here -->
</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>
<script>
const clientId = '{{CLIENT_ID}}';
// Copy necessary functions from dashboard
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">${new Date(session.startTime).toISOString()}</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/running.gif'}"
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">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</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>
`}
</div>
</div>
`;
}
async function loadClientTests() {
try {
const response = await fetch('/api/sessions');
const sessions = await response.json();
// Filter sessions for this client
const clientSessions = sessions.filter(s => s.clientId === clientId);
if (clientSessions.length === 0) {
document.getElementById('tests-container').innerHTML =
'<div class="no-sessions">No tests found for this client</div>';
return;
}
// Group by test ID
const testGroups = {};
clientSessions.forEach(session => {
if (!testGroups[session.testId]) {
testGroups[session.testId] = [];
}
testGroups[session.testId].push(session);
});
// Render tests
let html = '';
Object.entries(testGroups).forEach(([testId, sessions]) => {
const testGroupId = `test-${testId}`;
const activeTestSessions = sessions.filter(s => s.status === 'active').length;
html += `
<div class="test-group" id="${testGroupId}">
<div class="test-header">
<a href="/single-test/${clientId}/${testId}" class="test-header-link">
<h3>TEST: ${testId}</h3>
</a>
<div class="client-stats">
<span>${sessions.length} run${sessions.length !== 1 ? 's' : ''}</span>
${activeTestSessions > 0 ? `<span style="color: #0f0;">${activeTestSessions} active</span>` : ''}
</div>
</div>
<div class="test-content">
`;
// Render sessions for this test
sessions.forEach(session => {
const statusClass = session.status === 'active' ? 'status-indicator' : 'status-indicator-finished';
const statusText = session.status.toUpperCase();
const uptime = session.status === 'active' ?
Math.floor((Date.now() - session.startTime) / 1000) + 's' :
Math.floor((session.duration || 0) / 1000) + 's';
const isHistorical = session.phase === 'legacy' || session.phase === 'unknown';
html += renderSessionCard(session, statusClass, statusText, uptime, isHistorical);
});
html += `
</div>
</div>
`;
});
document.getElementById('tests-container').innerHTML = html;
// Load zoom previews and timing data
loadSessionDetails();
} catch (error) {
console.error('Failed to load client tests:', error);
document.getElementById('tests-container').innerHTML =
'<div class="no-sessions">Failed to load tests</div>';
}
}
// Load tests initially
loadClientTests();
// Update only dynamic content every second
setInterval(async () => {
try {
const response = await fetch('/api/sessions');
const sessions = await response.json();
// Filter sessions for this client
const clientSessions = sessions.filter(s => s.clientId === clientId);
// Update only the dynamic parts of existing session cards
clientSessions.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';
}
// Update uptime
const uptime = card.querySelector('.uptime');
if (uptime) {
const uptimeValue = session.status === 'active' ?
Math.floor((Date.now() - session.startTime) / 1000) + 's' :
Math.floor((session.duration || 0) / 1000) + 's';
uptime.textContent = uptimeValue;
}
}
});
// Also update test group stats
const testGroups = {};
clientSessions.forEach(session => {
if (!testGroups[session.testId]) {
testGroups[session.testId] = { total: 0, active: 0 };
}
testGroups[session.testId].total++;
if (session.status === 'active') {
testGroups[session.testId].active++;
}
});
Object.entries(testGroups).forEach(([testId, stats]) => {
const testHeader = document.querySelector(`#test-${testId} .client-stats`);
if (testHeader) {
testHeader.innerHTML = `
<span>${stats.total} run${stats.total !== 1 ? 's' : ''}</span>
${stats.active > 0 ? `<span style="color: #0f0;">${stats.active} active</span>` : ''}
`;
}
});
} catch (error) {
console.error('Failed to update session status:', error);
}
}, 1000);
// Check for new tests/sessions less frequently
setInterval(loadClientTests, 10000);
// Function to render timing chart
function renderTimingChart(timingData, totalDuration, clientId, testId, sessionId) {
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) => {
const startPercent = (item.relativeStart / totalDuration) * 100;
const widthPercent = (item.duration / totalDuration) * 100;
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>`;
}
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"
onclick="showStepDetails('${clientId}', '${testId}', '${sessionId}', ${index})">
${barLabel}
<div class="timing-tooltip">
Step ${index}: ${item.command}<br>
Duration: ${(item.duration / 1000).toFixed(2)}s
</div>
</div>
`;
});
html += '</div>';
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
async function loadSessionDetails() {
const containers = document.querySelectorAll('.session-card');
for (const container of containers) {
const sessionKey = container.dataset.sessionKey;
const [clientId, testId, sessionId] = sessionKey.split('/');
try {
const response = await fetch(`/api/session/${clientId}/${testId}/${sessionId}`);
const details = await response.json();
// 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"
onclick="showStepDetails('${clientId}', '${testId}', '${sessionId}', ${img.index})">
`).join('')}
</div>
`;
} else if (zoomContainer) {
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, clientId, testId, sessionId);
}
} catch (error) {
console.error('Failed to load session details:', error);
}
}
}
// 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();
let content = `
<div class="step-screenshot-section">
`;
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">`;
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">
<div class="step-metric-label">Duration</div>
<div class="step-metric-value">${(stepData.timing.duration / 1000).toFixed(2)}s</div>
</div>
`;
}
if (stepData.metrics?.pre?.cpu) {
content += `
<div class="step-metric">
<div class="step-metric-label">CPU (Pre → Post)</div>
<div class="step-metric-value">${stepData.metrics.pre.cpu}% → ${stepData.metrics.post.cpu}%</div>
</div>
`;
}
if (stepData.metrics?.pre?.memory) {
content += `
<div class="step-metric">
<div class="step-metric-label">Memory (Pre → Post)</div>
<div class="step-metric-value">${stepData.metrics.pre.memory}MB → ${stepData.metrics.post.memory}MB</div>
</div>
`;
}
content += '</div>';
}
// DOM preview
if (stepData.dom) {
content += `
<div class="step-dom-preview">${stepData.dom.substring(0, 500)}${stepData.dom.length > 500 ? '...' : ''}</div>
`;
}
content += '</div>';
if (stepData.videoClip) {
content += `
<div class="step-video-section">
<video class="step-video" controls autoplay loop>
<source src="${stepData.videoClip}" type="video/mp4">
</video>
</div>
`;
}
document.getElementById('step-overlay-content').innerHTML = content;
document.getElementById('step-overlay').classList.add('active');
} catch (error) {
console.error('Failed to load step details:', error);
alert('Failed to load step details');
}
}
function closeStepOverlay() {
document.getElementById('step-overlay').classList.remove('active');
}
</script>
<script src="/resources/streaming/dock.js"></script>
<script src="/resources/streaming/themes/theme-switcher.js"></script>
</body>
</html>