Files
ArchiveBox/archivebox/templates/admin/progress_monitor.html
2025-12-25 03:59:51 -08:00

747 lines
25 KiB
HTML

<style>
/* Progress Monitor Container */
#progress-monitor {
background: linear-gradient(135deg, #0d1117 0%, #161b22 100%);
color: #c9d1d9;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
font-size: 12px;
border-bottom: 1px solid #30363d;
position: relative;
z-index: 100;
}
#progress-monitor.hidden {
display: none;
}
#progress-monitor .tree-container {
max-height: 350px;
overflow-y: auto;
}
/* Header Bar */
#progress-monitor .header-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: rgba(0,0,0,0.2);
border-bottom: 1px solid #30363d;
position: sticky;
top: 0;
z-index: 10;
}
#progress-monitor .header-left {
display: flex;
align-items: center;
gap: 16px;
}
#progress-monitor .header-right {
display: flex;
align-items: center;
gap: 12px;
}
/* Orchestrator Status */
#progress-monitor .orchestrator-status {
display: flex;
align-items: center;
gap: 6px;
}
#progress-monitor .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
#progress-monitor .status-dot.running {
background: #3fb950;
box-shadow: 0 0 8px #3fb950;
animation: pulse 2s infinite;
}
#progress-monitor .status-dot.idle {
background: #d29922;
box-shadow: 0 0 4px #d29922;
}
#progress-monitor .status-dot.stopped {
background: #6e7681;
}
#progress-monitor .status-dot.flash {
animation: flash 0.3s ease-out;
}
@keyframes pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 8px #3fb950; }
50% { opacity: 0.6; box-shadow: 0 0 4px #3fb950; }
}
@keyframes flash {
0% { transform: scale(1.5); }
100% { transform: scale(1); }
}
/* Stats */
#progress-monitor .stats {
display: flex;
gap: 16px;
}
#progress-monitor .stat {
display: flex;
align-items: center;
gap: 4px;
}
#progress-monitor .stat-label {
color: #8b949e;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
#progress-monitor .stat-value {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
#progress-monitor .stat-value.success { color: #3fb950; }
#progress-monitor .stat-value.error { color: #f85149; }
#progress-monitor .stat-value.warning { color: #d29922; }
#progress-monitor .stat-value.info { color: #58a6ff; }
#progress-monitor .stat.clickable {
cursor: pointer;
padding: 2px 6px;
margin: -2px -6px;
border-radius: 4px;
transition: background 0.2s;
}
#progress-monitor .stat.clickable:hover {
background: rgba(255,255,255,0.1);
}
#progress-monitor .stat.clickable:active {
background: rgba(255,255,255,0.2);
}
/* Toggle Button */
#progress-monitor .toggle-btn {
background: transparent;
border: 1px solid #30363d;
color: #8b949e;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
font-size: 11px;
transition: all 0.2s;
}
#progress-monitor .toggle-btn:hover {
background: #21262d;
color: #c9d1d9;
border-color: #8b949e;
}
/* Tree Container */
#progress-monitor .tree-container {
padding: 12px 16px;
}
#progress-monitor.collapsed .tree-container {
display: none;
}
/* Idle Message */
#progress-monitor .idle-message {
color: #8b949e;
font-style: italic;
padding: 8px 0;
text-align: center;
}
/* Crawl Item */
#progress-monitor .crawl-item {
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
}
#progress-monitor .crawl-header {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: rgba(0,0,0,0.2);
cursor: pointer;
text-decoration: none;
color: inherit;
}
#progress-monitor .crawl-header:hover {
background: rgba(88, 166, 255, 0.1);
}
#progress-monitor a.crawl-header:visited {
color: inherit;
}
#progress-monitor .crawl-icon {
font-size: 16px;
width: 20px;
text-align: center;
}
#progress-monitor .crawl-info {
flex: 1;
min-width: 0;
}
#progress-monitor .crawl-label {
font-weight: 600;
color: #58a6ff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#progress-monitor .crawl-meta {
font-size: 11px;
color: #8b949e;
margin-top: 2px;
}
#progress-monitor .crawl-stats {
display: flex;
gap: 12px;
font-size: 11px;
}
/* Progress Bar */
#progress-monitor .progress-bar-container {
height: 4px;
background: #21262d;
border-radius: 2px;
overflow: hidden;
position: relative;
}
#progress-monitor .progress-bar {
height: 100%;
border-radius: 2px;
transition: width 0.5s ease-out;
position: relative;
}
#progress-monitor .progress-bar.crawl {
background: linear-gradient(90deg, #238636 0%, #3fb950 100%);
}
#progress-monitor .progress-bar.snapshot {
background: linear-gradient(90deg, #1f6feb 0%, #58a6ff 100%);
}
#progress-monitor .progress-bar.extractor {
background: linear-gradient(90deg, #8957e5 0%, #a371f7 100%);
}
#progress-monitor .progress-bar.indeterminate {
background: linear-gradient(90deg, transparent 0%, #58a6ff 50%, transparent 100%);
animation: indeterminate 1.5s infinite linear;
width: 30% !important;
}
@keyframes indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
/* Crawl Body */
#progress-monitor .crawl-body {
padding: 0 14px 14px;
}
#progress-monitor .crawl-progress {
padding: 10px 14px;
border-bottom: 1px solid #21262d;
}
/* Snapshot List */
#progress-monitor .snapshot-list {
margin-top: 8px;
}
#progress-monitor .snapshot-item {
background: #0d1117;
border: 1px solid #21262d;
border-radius: 6px;
margin-bottom: 8px;
overflow: hidden;
}
#progress-monitor .snapshot-header {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
cursor: pointer;
text-decoration: none;
color: inherit;
}
#progress-monitor .snapshot-header:hover {
background: rgba(88, 166, 255, 0.05);
}
#progress-monitor a.snapshot-header:visited {
color: inherit;
}
#progress-monitor .snapshot-icon {
font-size: 14px;
width: 18px;
text-align: center;
color: #58a6ff;
}
#progress-monitor .snapshot-info {
flex: 1;
min-width: 0;
}
#progress-monitor .snapshot-url {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 11px;
color: #c9d1d9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#progress-monitor .snapshot-meta {
font-size: 10px;
color: #8b949e;
margin-top: 2px;
}
#progress-monitor .snapshot-progress {
padding: 0 12px 8px;
}
/* Extractor List - Compact Badge Layout */
#progress-monitor .extractor-list {
padding: 8px 12px;
background: rgba(0,0,0,0.2);
border-top: 1px solid #21262d;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
#progress-monitor .extractor-badge {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 10px;
background: #21262d;
overflow: hidden;
white-space: nowrap;
}
#progress-monitor .extractor-badge .progress-fill {
position: absolute;
top: 0;
left: 0;
bottom: 0;
z-index: 0;
transition: width 0.3s ease-out;
}
#progress-monitor .extractor-badge .badge-content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 4px;
}
#progress-monitor .extractor-badge.queued {
color: #8b949e;
}
#progress-monitor .extractor-badge.queued .progress-fill {
background: rgba(110, 118, 129, 0.2);
width: 0%;
}
#progress-monitor .extractor-badge.started {
color: #d29922;
}
#progress-monitor .extractor-badge.started .progress-fill {
background: rgba(210, 153, 34, 0.3);
width: 50%;
animation: progress-pulse 1.5s ease-in-out infinite;
}
@keyframes progress-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
#progress-monitor .extractor-badge.succeeded {
color: #3fb950;
}
#progress-monitor .extractor-badge.succeeded .progress-fill {
background: rgba(63, 185, 80, 0.25);
width: 100%;
}
#progress-monitor .extractor-badge.failed {
color: #f85149;
}
#progress-monitor .extractor-badge.failed .progress-fill {
background: rgba(248, 81, 73, 0.25);
width: 100%;
}
#progress-monitor .extractor-badge .badge-icon {
font-size: 10px;
}
#progress-monitor .extractor-badge.started .badge-icon {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Status Badge */
#progress-monitor .status-badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
}
#progress-monitor .status-badge.queued {
background: #21262d;
color: #8b949e;
}
#progress-monitor .status-badge.started {
background: rgba(210, 153, 34, 0.2);
color: #d29922;
}
#progress-monitor .status-badge.sealed,
#progress-monitor .status-badge.succeeded {
background: rgba(63, 185, 80, 0.2);
color: #3fb950;
}
#progress-monitor .status-badge.failed {
background: rgba(248, 81, 73, 0.2);
color: #f85149;
}
</style>
<div id="progress-monitor">
<div class="header-bar">
<div class="header-left">
<div class="orchestrator-status">
<span class="status-dot stopped" id="orchestrator-dot"></span>
<span id="orchestrator-text">Stopped</span>
</div>
<div class="stats">
<div class="stat">
<span class="stat-label">Workers</span>
<span class="stat-value info" id="worker-count">0</span>
</div>
<div class="stat">
<span class="stat-label">Queued</span>
<span class="stat-value warning" id="total-queued">0</span>
</div>
<div class="stat clickable" id="stat-succeeded" title="Click to reset counter">
<span class="stat-label">Done</span>
<span class="stat-value success" id="total-succeeded">0</span>
</div>
<div class="stat clickable" id="stat-failed" title="Click to reset counter">
<span class="stat-label">Failed</span>
<span class="stat-value error" id="total-failed">0</span>
</div>
</div>
</div>
<div class="header-right">
<button class="toggle-btn" id="progress-collapse" title="Toggle details">Details</button>
</div>
</div>
<div class="tree-container" id="tree-container">
<div class="idle-message" id="idle-message">No active crawls</div>
<div id="crawl-tree"></div>
</div>
</div>
<script>
(function() {
const monitor = document.getElementById('progress-monitor');
const collapseBtn = document.getElementById('progress-collapse');
const treeContainer = document.getElementById('tree-container');
const crawlTree = document.getElementById('crawl-tree');
const idleMessage = document.getElementById('idle-message');
let pollInterval = null;
let isCollapsed = localStorage.getItem('progress-monitor-collapsed') === 'true';
// Baselines for resettable counters
let succeededBaseline = parseInt(localStorage.getItem('progress-succeeded-baseline') || '0');
let failedBaseline = parseInt(localStorage.getItem('progress-failed-baseline') || '0');
let lastSucceeded = 0;
let lastFailed = 0;
// Click handlers for resetting counters
document.getElementById('stat-succeeded').addEventListener('click', function() {
succeededBaseline = lastSucceeded;
localStorage.setItem('progress-succeeded-baseline', succeededBaseline);
document.getElementById('total-succeeded').textContent = '0';
});
document.getElementById('stat-failed').addEventListener('click', function() {
failedBaseline = lastFailed;
localStorage.setItem('progress-failed-baseline', failedBaseline);
document.getElementById('total-failed').textContent = '0';
});
function formatUrl(url) {
try {
const u = new URL(url);
return u.hostname + u.pathname.substring(0, 30) + (u.pathname.length > 30 ? '...' : '');
} catch {
return url.substring(0, 50) + (url.length > 50 ? '...' : '');
}
}
function renderExtractor(extractor) {
const icon = extractor.status === 'started' ? '&#8635;' :
extractor.status === 'succeeded' ? '&#10003;' :
extractor.status === 'failed' ? '&#10007;' : '&#9675;';
return `
<span class="extractor-badge ${extractor.status}">
<span class="progress-fill"></span>
<span class="badge-content">
<span class="badge-icon">${icon}</span>
<span>${extractor.extractor}</span>
</span>
</span>
`;
}
function renderSnapshot(snapshot, crawlId) {
const statusIcon = snapshot.status === 'started' ? '&#8635;' : '&#128196;';
const adminUrl = `/admin/core/snapshot/${snapshot.id}/change/`;
let extractorHtml = '';
if (snapshot.all_extractors && snapshot.all_extractors.length > 0) {
// Sort extractors alphabetically by name to prevent reordering on updates
const sortedExtractors = [...snapshot.all_extractors].sort((a, b) =>
a.extractor.localeCompare(b.extractor)
);
extractorHtml = `
<div class="extractor-list">
${sortedExtractors.map(e => renderExtractor(e)).join('')}
</div>
`;
}
return `
<div class="snapshot-item">
<a class="snapshot-header" href="${adminUrl}">
<span class="snapshot-icon">${statusIcon}</span>
<div class="snapshot-info">
<div class="snapshot-url">${formatUrl(snapshot.url)}</div>
<div class="snapshot-meta">
${snapshot.completed_extractors}/${snapshot.total_extractors} extractors
${snapshot.failed_extractors > 0 ? `<span style="color:#f85149">(${snapshot.failed_extractors} failed)</span>` : ''}
</div>
</div>
<span class="status-badge ${snapshot.status}">${snapshot.status}</span>
</a>
<div class="snapshot-progress">
<div class="progress-bar-container">
<div class="progress-bar snapshot ${snapshot.status === 'started' && snapshot.progress === 0 ? 'indeterminate' : ''}"
style="width: ${snapshot.progress}%"></div>
</div>
</div>
${extractorHtml}
</div>
`;
}
function renderCrawl(crawl) {
const statusIcon = crawl.status === 'started' ? '&#8635;' : '&#128269;';
const adminUrl = `/admin/crawls/crawl/${crawl.id}/change/`;
let snapshotsHtml = '';
if (crawl.active_snapshots && crawl.active_snapshots.length > 0) {
snapshotsHtml = crawl.active_snapshots.map(s => renderSnapshot(s, crawl.id)).join('');
}
// Show warning if crawl is stuck (queued but can't start)
let warningHtml = '';
if (crawl.status === 'queued' && !crawl.can_start) {
warningHtml = `
<div style="padding: 8px 14px; background: rgba(248, 81, 73, 0.1); border-top: 1px solid #f85149; color: #f85149; font-size: 11px;">
⚠️ Crawl cannot start: ${crawl.urls_preview ? 'unknown error' : 'no URLs'}
</div>
`;
} else if (crawl.status === 'queued' && crawl.retry_at_future) {
// Queued but retry_at is in future (was claimed by worker, will retry)
warningHtml = `
<div style="padding: 8px 14px; background: rgba(88, 166, 255, 0.1); border-top: 1px solid #58a6ff; color: #58a6ff; font-size: 11px;">
🔄 Retrying in ${crawl.seconds_until_retry}s...${crawl.urls_preview ? ` (${crawl.urls_preview})` : ''}
</div>
`;
} else if (crawl.status === 'queued' && crawl.total_snapshots === 0) {
// Queued and waiting to be picked up by worker
warningHtml = `
<div style="padding: 8px 14px; background: rgba(210, 153, 34, 0.1); border-top: 1px solid #d29922; color: #d29922; font-size: 11px;">
⏳ Waiting for worker to pick up...${crawl.urls_preview ? ` (${crawl.urls_preview})` : ''}
</div>
`;
}
// Show snapshot info or URL count if no snapshots yet
let metaText = `depth: ${crawl.max_depth}`;
if (crawl.total_snapshots > 0) {
metaText += ` | ${crawl.total_snapshots} snapshots`;
} else if (crawl.urls_count > 0) {
metaText += ` | ${crawl.urls_count} URLs`;
} else if (crawl.urls_preview) {
metaText += ` | ${crawl.urls_preview.substring(0, 40)}${crawl.urls_preview.length > 40 ? '...' : ''}`;
}
return `
<div class="crawl-item" data-crawl-id="${crawl.id}">
<a class="crawl-header" href="${adminUrl}">
<span class="crawl-icon">${statusIcon}</span>
<div class="crawl-info">
<div class="crawl-label">${crawl.label}</div>
<div class="crawl-meta">${metaText}</div>
</div>
<div class="crawl-stats">
<span style="color:#3fb950">${crawl.completed_snapshots} done</span>
<span style="color:#d29922">${crawl.started_snapshots || 0} active</span>
<span style="color:#8b949e">${crawl.pending_snapshots} pending</span>
</div>
<span class="status-badge ${crawl.status}">${crawl.status}</span>
</a>
<div class="crawl-progress">
<div class="progress-bar-container">
<div class="progress-bar crawl ${crawl.status === 'started' && crawl.progress === 0 ? 'indeterminate' : ''}"
style="width: ${crawl.progress}%"></div>
</div>
</div>
${warningHtml}
<div class="crawl-body">
<div class="snapshot-list">
${snapshotsHtml}
</div>
</div>
</div>
`;
}
function updateProgress(data) {
// Calculate if there's activity
const hasActivity = data.active_crawls.length > 0 ||
data.crawls_pending > 0 || data.crawls_started > 0 ||
data.snapshots_pending > 0 || data.snapshots_started > 0 ||
data.archiveresults_pending > 0 || data.archiveresults_started > 0;
// Update orchestrator status - show "Running" only when there's actual activity
// Don't distinguish between "Stopped" and "Idle" since orchestrator starts/stops frequently
const dot = document.getElementById('orchestrator-dot');
const text = document.getElementById('orchestrator-text');
const hasWorkers = data.total_workers > 0;
if (hasWorkers || hasActivity) {
dot.classList.remove('stopped', 'idle');
dot.classList.add('running');
text.textContent = 'Running';
} else {
// No activity - show as idle (whether orchestrator process exists or not)
dot.classList.remove('stopped', 'running');
dot.classList.add('idle');
text.textContent = 'Idle';
}
// Pulse the dot to show we got fresh data
dot.classList.add('flash');
setTimeout(() => dot.classList.remove('flash'), 300);
// Update stats
document.getElementById('worker-count').textContent = data.total_workers;
document.getElementById('total-queued').textContent =
data.crawls_pending + data.snapshots_pending + data.archiveresults_pending;
// Store raw values and display relative to baseline
lastSucceeded = data.archiveresults_succeeded;
lastFailed = data.archiveresults_failed;
// If baseline is higher than current (e.g. after DB reset), reset baseline
if (succeededBaseline > lastSucceeded) {
succeededBaseline = 0;
localStorage.setItem('progress-succeeded-baseline', '0');
}
if (failedBaseline > lastFailed) {
failedBaseline = 0;
localStorage.setItem('progress-failed-baseline', '0');
}
document.getElementById('total-succeeded').textContent = lastSucceeded - succeededBaseline;
document.getElementById('total-failed').textContent = lastFailed - failedBaseline;
// Render crawl tree
if (data.active_crawls.length > 0) {
idleMessage.style.display = 'none';
crawlTree.innerHTML = data.active_crawls.map(c => renderCrawl(c)).join('');
} else if (hasActivity) {
idleMessage.style.display = 'none';
crawlTree.innerHTML = `
<div class="idle-message">
${data.snapshots_started} snapshots processing, ${data.archiveresults_started} extractors running
</div>
`;
} else {
idleMessage.style.display = '';
// Build the URL for recent crawls (last 24 hours)
var yesterday = new Date(Date.now() - 24*60*60*1000).toISOString().split('T')[0];
var recentUrl = '/admin/crawls/crawl/?created_at__gte=' + yesterday + '&o=-1';
idleMessage.innerHTML = `No active crawls (${data.crawls_pending} pending, ${data.crawls_started} started, <a href="${recentUrl}" style="color: #58a6ff;">${data.crawls_recent} recent</a>)`;
crawlTree.innerHTML = '';
}
}
function fetchProgress() {
fetch('/admin/live-progress/')
.then(response => response.json())
.then(data => {
if (data.error) {
console.error('Progress API error:', data.error, data.traceback);
idleMessage.textContent = 'API Error: ' + data.error;
idleMessage.style.color = '#f85149';
}
updateProgress(data);
})
.catch(error => {
console.error('Progress fetch error:', error);
idleMessage.textContent = 'Fetch Error: ' + error.message;
idleMessage.style.color = '#f85149';
});
}
function startPolling() {
if (pollInterval) return;
fetchProgress();
pollInterval = setInterval(fetchProgress, 1000); // Poll every 1 second
}
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
// Collapse toggle
collapseBtn.addEventListener('click', function() {
isCollapsed = !isCollapsed;
localStorage.setItem('progress-monitor-collapsed', isCollapsed);
if (isCollapsed) {
monitor.classList.add('collapsed');
collapseBtn.textContent = 'Expand';
} else {
monitor.classList.remove('collapsed');
collapseBtn.textContent = 'Details';
}
});
// Apply initial state
if (isCollapsed) {
monitor.classList.add('collapsed');
collapseBtn.textContent = 'Expand';
}
// Start polling when page loads
startPolling();
// Pause polling when tab is hidden
document.addEventListener('visibilitychange', function() {
if (document.hidden) {
stopPolling();
} else {
startPolling();
}
});
})();
</script>