Files
2026-06-03 21:37:13 -07:00

1942 lines
75 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;
}
#progress-monitor .progress-content {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
}
/* 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.collapsed .header-bar {
cursor: pointer;
}
#progress-monitor .header-left {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
}
#progress-monitor .header-right {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
/* 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: #238636;
box-shadow: 0 0 8px #238636;
animation: pulse 2s infinite;
}
#progress-monitor .status-dot.idle {
background: #7ee787;
box-shadow: 0 0 4px #7ee787;
animation: idle-pulse 5s infinite;
}
#progress-monitor .status-dot.stopped {
background: #d29922;
box-shadow: 0 0 4px #d29922;
}
#progress-monitor .status-dot.error {
background: #f85149;
box-shadow: 0 0 8px #f85149;
}
#progress-monitor .status-dot.flash {
animation: flash 0.3s ease-out;
}
@keyframes pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 8px #238636; }
50% { opacity: 0.6; box-shadow: 0 0 4px #238636; }
}
@keyframes idle-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 4px #7ee787; }
50% { opacity: 0.65; box-shadow: 0 0 2px #7ee787; }
}
@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.compact {
font-size: 11px;
white-space: nowrap;
}
#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; }
/* 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;
}
#progress-monitor .crawl-action-btn,
#progress-monitor .cancel-item-btn {
background: transparent;
border: 1px solid #30363d;
cursor: pointer;
padding: 2px 6px;
border-radius: 6px;
font-size: 11px;
line-height: 1;
transition: all 0.2s;
flex-shrink: 0;
}
#progress-monitor .pause-item-btn {
color: #d29922;
}
#progress-monitor .pause-item-btn:hover {
background: rgba(210, 153, 34, 0.12);
border-color: #d29922;
color: #f0b72f;
}
#progress-monitor .cancel-item-btn {
color: #f85149;
}
#progress-monitor .cancel-item-btn:hover {
background: rgba(248, 81, 73, 0.12);
border-color: #f85149;
color: #ff7b72;
}
#progress-monitor .crawl-action-btn.is-busy,
#progress-monitor .cancel-item-btn.is-busy {
opacity: 0.6;
cursor: wait;
border-color: #6e7681;
color: #6e7681;
}
#progress-monitor .progress-overflow-note {
padding: 8px 12px;
color: #8b949e;
font-size: 11px;
text-align: center;
background: rgba(33, 38, 45, 0.55);
border-top: 1px solid #30363d;
}
/* Tree Container */
#progress-monitor .tree-container {
flex: 1 1 auto;
min-width: 0;
padding: 0;
}
#progress-monitor.collapsed .progress-content {
display: none;
}
/* Hide admin-only controls when viewer is not staff, or when the monitor is
embedded on a non-admin host (snap-* subdomain) where cross-origin POSTs
to /api would violate the subdomain isolation model. */
#progress-monitor.is-guest .crawl-action-btn,
#progress-monitor.is-guest .cancel-item-btn,
#progress-monitor.is-guest .pause-item-btn,
#progress-monitor[data-progress-scope="snapshot"] .crawl-action-btn,
#progress-monitor[data-progress-scope="snapshot"] .cancel-item-btn,
#progress-monitor[data-progress-scope="snapshot"] .pause-item-btn {
display: none !important;
}
/* Chrome Screencast */
#progress-monitor .screencast-panel {
display: none;
flex: 0 0 336px;
width: 336px;
position: sticky;
top: 49px;
border: 1px solid rgba(88, 166, 255, 0.5);
border-radius: 8px;
overflow: hidden;
background: #0d1117;
box-shadow: 0 12px 34px rgba(1, 4, 9, 0.4), 0 0 0 1px rgba(88, 166, 255, 0.12);
}
#progress-monitor .screencast-panel.visible {
display: block;
}
#progress-monitor .screencast-frame {
display: block;
width: 100%;
height: 310px;
background: #010409;
color: #6e7681;
text-decoration: none;
overflow: hidden;
}
#progress-monitor .screencast-frame img {
display: block;
width: 120%;
height: 100%;
margin-left: -10%;
object-fit: cover;
object-position: top center;
background: #ffffff;
opacity: 1 !important;
}
#progress-monitor .screencast-placeholder {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 6px;
text-align: center;
color: #8b949e;
background: #010409;
}
#progress-monitor .screencast-placeholder-icon {
color: #c9d1d9;
font-size: 32px;
font-weight: 700;
line-height: 1;
}
#progress-monitor .screencast-placeholder.launching .screencast-placeholder-icon {
color: #d29922;
}
#progress-monitor .screencast-placeholder-title {
color: #f0f6fc;
font-size: 13px;
font-weight: 700;
line-height: 1.25;
}
#progress-monitor .screencast-placeholder-subtitle {
max-width: 260px;
color: #8b949e;
font-size: 11px;
line-height: 1.35;
}
#progress-monitor .screencast-caption {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-top: 1px solid #21262d;
background: rgba(13, 17, 23, 0.96);
color: inherit;
text-decoration: none;
}
#progress-monitor .screencast-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex: 0 0 7px;
background: #3fb950;
box-shadow: 0 0 8px rgba(63, 185, 80, 0.9);
}
#progress-monitor .screencast-dot.not-launched {
background: #6e7681;
box-shadow: none;
}
#progress-monitor .screencast-dot.launching {
background: #d29922;
box-shadow: 0 0 8px rgba(210, 153, 34, 0.8);
}
#progress-monitor .screencast-text {
min-width: 0;
}
#progress-monitor .screencast-title {
display: block;
color: #f0f6fc;
font-size: 12px;
font-weight: 600;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#progress-monitor .screencast-url {
display: block;
color: #8b949e;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 10px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 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: 9px;
overflow: hidden;
}
#progress-monitor .crawl-header {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: rgba(0,0,0,0.2);
}
#progress-monitor .crawl-header:hover {
background: rgba(88, 166, 255, 0.1);
}
#progress-monitor .crawl-header-link {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
cursor: pointer;
text-decoration: none;
color: inherit;
}
#progress-monitor .crawl-header-link,
#progress-monitor .crawl-header-link * {
text-decoration: none !important;
}
#progress-monitor a.crawl-header-link:visited {
color: inherit;
}
#progress-monitor a.crawl-header-link:link,
#progress-monitor a.crawl-header-link:visited,
#progress-monitor a.crawl-header-link:hover,
#progress-monitor a.crawl-header-link:active {
text-decoration: none !important;
}
#progress-monitor .crawl-info {
flex: 1;
min-width: 0;
}
#progress-monitor .crawl-label {
font-weight: 600;
color: #f0f6fc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#progress-monitor .crawl-badges {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 5px;
}
#progress-monitor .crawl-badge {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 240px;
padding: 2px 7px;
border-radius: 999px;
font-size: 11px;
line-height: 1.35;
color: #c9d1d9;
background: rgba(110, 118, 129, 0.16);
border: 1px solid rgba(110, 118, 129, 0.22);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#progress-monitor a.crawl-badge {
cursor: pointer;
text-decoration: none !important;
}
#progress-monitor a.crawl-badge:hover {
border-color: currentColor;
filter: brightness(1.14);
}
#progress-monitor .crawl-badge strong {
color: #8b949e;
font-weight: 600;
}
#progress-monitor .crawl-title-link {
display: inline-block;
max-width: 100%;
color: inherit;
text-decoration: none !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#progress-monitor .crawl-title-link:hover {
color: #79c0ff;
}
#progress-monitor .crawl-badge.persona {
color: #d2a8ff;
background: rgba(163, 113, 247, 0.16);
border-color: rgba(163, 113, 247, 0.28);
}
#progress-monitor .crawl-badge.limit {
color: #79c0ff;
background: rgba(88, 166, 255, 0.13);
border-color: rgba(88, 166, 255, 0.24);
}
#progress-monitor .crawl-badge.size {
color: #ffa657;
background: rgba(240, 136, 62, 0.13);
border-color: rgba(240, 136, 62, 0.24);
}
#progress-monitor .crawl-badge.count {
color: #7ee787;
background: rgba(63, 185, 80, 0.13);
border-color: rgba(63, 185, 80, 0.24);
}
#progress-monitor .crawl-badge.tag {
color: #a5d6ff;
background: rgba(31, 111, 235, 0.16);
border-color: rgba(31, 111, 235, 0.28);
}
#progress-monitor .crawl-stats {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
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;
}
#progress-monitor .snapshot-header:hover {
background: rgba(88, 166, 255, 0.05);
}
#progress-monitor .snapshot-header-link {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
cursor: pointer;
text-decoration: none;
color: inherit;
}
#progress-monitor a.snapshot-header-link:visited {
color: inherit;
}
#progress-monitor .snapshot-icon {
font-size: 14px;
width: 18px;
text-align: center;
color: #58a6ff;
}
#progress-monitor .snapshot-preview {
display: flex;
align-items: center;
justify-content: center;
width: 84px;
height: 52px;
flex: 0 0 84px;
border-radius: 6px;
border: 1px solid #30363d;
background: #161b22;
color: #6e7681;
overflow: hidden;
text-decoration: none;
}
#progress-monitor .snapshot-preview:hover {
border-color: #58a6ff;
box-shadow: 0 0 0 1px rgba(88, 166, 255, 0.2);
}
#progress-monitor .snapshot-preview img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top center;
display: block;
}
#progress-monitor .snapshot-preview.placeholder {
font-size: 20px;
}
#progress-monitor .snapshot-info {
flex: 1;
min-width: 0;
}
#progress-monitor .snapshot-title-line {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
}
#progress-monitor .snapshot-favicon {
width: 16px;
height: 16px;
border-radius: 3px;
object-fit: contain;
background: #fff;
flex: 0 0 16px;
}
#progress-monitor .snapshot-title {
color: #f0f6fc;
font-size: 12px;
font-weight: 600;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#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;
color: inherit;
text-decoration: none;
}
#progress-monitor a.extractor-badge:hover {
background: #30363d;
color: #f0f6fc;
}
#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);
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.backoff {
color: #b8860b;
}
#progress-monitor .extractor-badge.backoff .progress-fill {
background: rgba(210, 153, 34, 0.2);
width: 30%;
}
#progress-monitor .extractor-badge.skipped {
color: #6e7681;
}
#progress-monitor .extractor-badge.skipped .progress-fill {
background: rgba(110, 118, 129, 0.15);
width: 100%;
}
#progress-monitor .extractor-badge .badge-icon {
font-size: 10px;
}
/* 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.paused {
background: rgba(88, 166, 255, 0.18);
color: #58a6ff;
}
#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;
}
#progress-monitor .status-badge.backoff {
background: rgba(210, 153, 34, 0.15);
color: #b8860b;
}
#progress-monitor .status-badge.unknown {
background: #21262d;
color: #6e7681;
}
#progress-monitor .duration-badge {
flex-shrink: 0;
min-width: 28px;
padding: 2px 6px;
border-radius: 10px;
background: rgba(88, 166, 255, 0.12);
color: #a5d6ff;
border: 1px solid rgba(88, 166, 255, 0.2);
font-size: 10px;
font-weight: 600;
line-height: 1.2;
text-align: center;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
#progress-monitor .pid-label {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: 999px;
font-size: 10px;
font-weight: 600;
color: #8b949e;
background: rgba(148, 163, 184, 0.12);
border: 1px solid rgba(148, 163, 184, 0.2);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
letter-spacing: 0.2px;
white-space: nowrap;
}
#progress-monitor .pid-label.compact {
padding: 1px 5px;
font-size: 9px;
}
@media (max-width: 900px) {
#progress-monitor .header-bar {
align-items: flex-start;
gap: 8px;
}
#progress-monitor .header-left {
flex: 1;
min-width: 0;
flex-wrap: wrap;
gap: 8px 14px;
}
#progress-monitor .stats {
flex-wrap: wrap;
gap: 8px 12px;
}
#progress-monitor .tree-container {
max-height: 310px;
}
#progress-monitor .progress-content {
flex-direction: column;
padding: 10px 8px;
}
#progress-monitor .screencast-panel {
position: static;
order: -1;
width: min(100%, 336px);
flex-basis: auto;
align-self: center;
}
#progress-monitor .crawl-header {
align-items: flex-start;
gap: 8px;
padding: 8px;
}
#progress-monitor .crawl-header-link {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
align-items: start;
gap: 8px;
width: 100%;
}
#progress-monitor .crawl-info {
grid-column: 1 / -1;
width: 100%;
}
#progress-monitor .crawl-label {
max-width: 100%;
}
#progress-monitor .crawl-badges,
#progress-monitor .crawl-stats {
justify-content: flex-start;
min-width: 0;
}
#progress-monitor .crawl-badge {
max-width: min(100%, 260px);
}
#progress-monitor .crawl-stats {
grid-column: 1 / 2;
}
#progress-monitor .crawl-body {
padding: 0 8px 10px;
}
#progress-monitor .crawl-progress {
padding: 8px;
}
#progress-monitor .snapshot-header {
align-items: flex-start;
padding: 8px;
}
#progress-monitor .snapshot-header-link {
min-width: 0;
}
}
@media (max-width: 520px) {
#progress-monitor {
font-size: 11px;
}
#progress-monitor .header-bar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
padding: 7px 8px;
}
#progress-monitor .header-left {
gap: 6px 10px;
}
#progress-monitor .header-right {
justify-self: end;
}
#progress-monitor .toggle-btn {
white-space: nowrap;
}
#progress-monitor .orchestrator-status {
max-width: 100%;
}
#progress-monitor .stats {
flex: 1 0 100%;
gap: 6px 10px;
}
#progress-monitor .stat-label {
font-size: 9px;
}
#progress-monitor .tree-container {
max-height: 300px;
}
#progress-monitor .progress-content {
padding: 8px;
}
#progress-monitor .screencast-panel {
width: 100%;
align-self: stretch;
}
#progress-monitor .screencast-frame {
height: 300px;
}
#progress-monitor .crawl-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
padding: 8px;
}
#progress-monitor .crawl-header-link {
grid-template-columns: minmax(0, 1fr);
}
#progress-monitor .crawl-stats {
grid-column: auto;
}
#progress-monitor .crawl-title-link {
white-space: normal;
line-height: 1.35;
}
#progress-monitor .crawl-badges {
gap: 4px;
}
#progress-monitor .crawl-badge {
max-width: 100%;
min-width: 0;
font-size: 10px;
white-space: nowrap;
}
#progress-monitor .crawl-stats {
gap: 4px;
}
#progress-monitor .status-badge,
#progress-monitor .duration-badge,
#progress-monitor .pid-label {
justify-self: start;
}
#progress-monitor .snapshot-header-link {
display: grid;
grid-template-columns: 56px minmax(0, 1fr);
gap: 8px;
}
#progress-monitor .snapshot-preview {
width: 56px;
height: 42px;
flex-basis: 56px;
}
#progress-monitor .snapshot-title-line {
align-items: flex-start;
}
#progress-monitor .snapshot-title,
#progress-monitor .snapshot-url {
white-space: normal;
overflow-wrap: anywhere;
}
#progress-monitor .extractor-list {
padding: 7px 8px;
gap: 4px;
}
#progress-monitor .extractor-badge {
max-width: 100%;
white-space: nowrap;
}
}
</style>
<div id="progress-monitor" class="{% if not progress_auto_expand %}collapsed{% endif %}"
data-progress-endpoint="{{ progress_endpoint|default:'/progress.json' }}"
data-progress-scope="{{ progress_scope|default:'global' }}"
data-auto-expand="{% if progress_auto_expand %}1{% else %}0{% endif %}">
<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">Runner stopped</span>
<span class="pid-label compact" id="orchestrator-pid" style="display:none;"></span>
</div>
<div class="stats">
<div class="stat">
<span class="stat-label">Crawls</span>
<span class="stat-value compact info"><span id="crawls-active-segment"><span id="crawls-active">0</span> active · </span><span id="crawls-queued">0</span> queued</span>
</div>
<div class="stat">
<span class="stat-label">Snapshots</span>
<span class="stat-value compact info"><span id="snapshots-active-segment"><span id="snapshots-active">0</span> active · </span><span id="snapshots-queued">0</span> queued</span>
</div>
<div class="stat">
<span class="stat-label">Downloads</span>
<span class="stat-value compact warning"><span id="downloads-active-segment"><span id="downloads-active">0</span> active · </span><span id="downloads-queued">0</span> queued</span>
</div>
<div class="stat">
<span class="stat-label">Indexing</span>
<span class="stat-value compact success"><span id="indexing-active-segment"><span id="indexing-active">0</span> active · </span><span id="indexing-queued">0</span> queued</span>
</div>
</div>
</div>
<div class="header-right">
<button class="toggle-btn" id="progress-collapse" title="Toggle details" aria-expanded="{% if progress_auto_expand %}true{% else %}false{% endif %}">
{% if progress_auto_expand %}Hide{% else %}Details{% endif %}
</button>
</div>
</div>
<div class="progress-content">
<div class="tree-container" id="tree-container">
<div class="idle-message" id="idle-message">No active crawls</div>
<div id="crawl-tree"></div>
</div>
<aside class="screencast-panel" id="screencast-panel" aria-label="Live browser screencast"></aside>
</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');
const screencastPanel = document.getElementById('screencast-panel');
let pollInterval = null;
let pollDelayMs = 1000;
let idleTicks = 0;
let isCollapsed = monitor.dataset.autoExpand !== '1';
const snapshotMedia = new Map();
function getApiKey() {
return (window.ARCHIVEBOX_API_KEY || '').trim();
}
function buildApiUrl(path) {
const apiKey = getApiKey();
if (!apiKey) return path;
const sep = path.includes('?') ? '&' : '?';
return `${path}${sep}api_key=${encodeURIComponent(apiKey)}`;
}
function buildApiHeaders() {
const headers = { 'Content-Type': 'application/json' };
const apiKey = getApiKey();
if (apiKey) headers['X-ArchiveBox-API-Key'] = apiKey;
const csrfToken = getCSRFToken();
if (csrfToken) headers['X-CSRFToken'] = csrfToken;
return headers;
}
function getCSRFToken() {
const input = document.querySelector('input[name="csrfmiddlewaretoken"]');
if (input && input.value) return input.value;
const cookie = document.cookie.split('; ').find(part => part.startsWith('csrftoken='));
return cookie ? decodeURIComponent(cookie.slice('csrftoken='.length)) : '';
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, char => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[char]));
}
function escapeAttr(value) {
return escapeHtml(value).replace(/`/g, '&#96;');
}
function stableSnapshotMedia(snapshot) {
// The progress endpoint can briefly omit media URLs while ArchiveResult
// rows settle. Once this page has seen a snapshot image URL, keep using
// it so the preview/favicon branch does not flicker back to placeholder.
const snapshotId = String(snapshot.id || '');
const media = snapshotMedia.get(snapshotId) || {};
if (snapshot.favicon_url) media.favicon_url ||= snapshot.favicon_url;
if (snapshot.preview_url) {
media.preview_url ||= snapshot.preview_url;
media.preview_link ||= snapshot.preview_link;
media.preview_fallbacks ||= snapshot.preview_fallbacks || [];
}
if (snapshotId) snapshotMedia.set(snapshotId, media);
return media;
}
function preserveStableMediaNodes(nextRoot) {
// Snapshot preview/favicon files are immutable once visible. Keep the
// loaded DOM nodes across poll renders so the browser never reloads
// them while progress text/badges continue updating around them.
const currentCards = new Map(
Array.from(crawlTree.querySelectorAll('.snapshot-item[data-snapshot-id]')).map(card => [card.dataset.snapshotId, card])
);
nextRoot.querySelectorAll('.snapshot-item[data-snapshot-id]').forEach(nextCard => {
const currentCard = currentCards.get(nextCard.dataset.snapshotId);
if (!currentCard) return;
currentCard.querySelectorAll('[data-stable-media]').forEach(currentNode => {
const nextNode = nextCard.querySelector(`[data-stable-media="${currentNode.dataset.stableMedia}"]`);
if (nextNode) nextNode.replaceWith(currentNode);
});
});
}
function replaceCrawlTree(html) {
const template = document.createElement('template');
template.innerHTML = html;
preserveStableMediaNodes(template.content);
crawlTree.replaceChildren(...template.content.childNodes);
}
window.nextPreviewFallback = function(img) {
const fallbacks = (img.dataset.fallbacks || '').split(',').filter(Boolean);
if (fallbacks.length > 0) {
img.src = fallbacks.shift();
img.dataset.fallbacks = fallbacks.join(',');
} else {
const preview = img.closest('.snapshot-preview');
if (preview) {
preview.removeAttribute('data-stable-media');
preview.classList.add('placeholder');
preview.innerHTML = '<span>▤</span>';
}
}
};
function formatUrl(url) {
if (!url) return '(no URL)';
try {
const u = new URL(url);
return u.hostname + u.pathname.substring(0, 30) + (u.pathname.length > 30 ? '...' : '');
} catch {
return String(url).substring(0, 50) + (String(url).length > 50 ? '...' : '');
}
}
function getPluginIcon(plugin) {
const icons = {
'screenshot': '▧',
'chrome_mhtml': '▧',
'favicon': '◆',
'dom': '&lt;/&gt;',
'pdf': 'PDF',
'title': 'T',
'headers': '{}',
'singlefile': '▣',
'readability': 'R',
'mercury': 'M',
'wget': '↓',
'media': '▶',
};
return icons[plugin] || '•';
}
function formatDuration(seconds) {
seconds = Math.max(0, Math.floor(seconds || 0));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) return `${hours}hr ${minutes}m ${secs}s`;
if (minutes > 0) return `${minutes}m ${secs}s`;
return `${secs}s`;
}
function durationText(startedAt) {
const startedMs = Date.parse(startedAt || '');
if (!Number.isFinite(startedMs)) return '';
return formatDuration((Date.now() - startedMs) / 1000);
}
function renderDurationBadge(startedAt) {
const text = durationText(startedAt);
if (!text) return '';
return `<span class="duration-badge" data-started-at="${escapeAttr(startedAt)}" title="Duration">${escapeHtml(text)}</span>`;
}
function updateDurationBadges() {
document.querySelectorAll('#progress-monitor .duration-badge[data-started-at]').forEach((badge) => {
const text = durationText(badge.dataset.startedAt);
if (text) badge.textContent = text;
});
}
function activeScreencastTarget(data) {
const hookText = (item) => `${item.plugin || ''} ${item.hook_name || ''} ${item.label || ''}`.toLowerCase();
let fallback = null;
for (const crawl of data.active_crawls || []) {
for (const snapshot of crawl.active_snapshots || []) {
if (!Array.isArray(snapshot) && snapshot.screencast_url) {
return {type: 'frame', crawl, snapshot};
}
}
}
for (const crawl of data.active_crawls || []) {
if (fallback) continue;
const setupPlugins = crawl.setup_plugins || [];
if (setupPlugins.some((item) => item.status === 'started' && hookText(item).includes('chrome launch'))) {
fallback = {
type: 'placeholder',
crawl,
state: 'launching',
icon: '▣',
title: 'Launching browser',
subtitle: 'Chrome is starting and publishing its CDP session.',
};
continue;
}
const openingSnapshot = (crawl.active_snapshots || []).find((snapshot) => {
return !Array.isArray(snapshot) && (snapshot.all_plugins || []).some((item) => (
item.status === 'started' && hookText(item).includes('chrome tab')
));
});
if (openingSnapshot) {
fallback = {
type: 'placeholder',
crawl,
snapshot: openingSnapshot,
state: 'launching',
icon: '▣',
title: 'Opening browser tab',
subtitle: 'The snapshot tab is being attached for capture.',
};
continue;
}
if ((crawl.status === 'queued' || crawl.status === 'started') && !setupPlugins.some((item) => hookText(item).includes('chrome launch'))) {
fallback = {
type: 'placeholder',
crawl,
state: 'not-launched',
icon: '▯',
title: 'Browser not launched yet',
subtitle: 'Waiting for the crawl setup hooks to start Chrome.',
};
}
}
return fallback;
}
function updateScreencastPanel(data) {
const hasWork = (data.active_crawls || []).length > 0 ||
(data.crawls_queued || 0) > 0 ||
(data.crawls_active || 0) > 0 ||
(data.snapshots_queued || 0) > 0 ||
(data.snapshots_active || 0) > 0;
let target = activeScreencastTarget(data);
if (!target) {
const currentFrame = screencastPanel.querySelector('.screencast-frame img');
if (
hasWork &&
screencastPanel.dataset.mode === 'frame' &&
currentFrame?.getAttribute('src') &&
currentFrame.complete &&
currentFrame.naturalWidth > 0
) {
screencastPanel.classList.add('visible');
currentFrame.style.opacity = '1';
return;
}
if (!hasWork) {
screencastPanel.classList.remove('visible');
screencastPanel.dataset.mode = '';
screencastPanel.innerHTML = '';
return;
}
target = {
type: 'placeholder',
state: 'not-launched',
icon: '▯',
title: 'Waiting for browser',
subtitle: 'Live preview will appear whenever Chrome is active.',
};
}
if (target.type === 'placeholder') {
const currentFrame = screencastPanel.querySelector('.screencast-frame img');
if (
screencastPanel.dataset.mode === 'frame' &&
currentFrame?.getAttribute('src') &&
currentFrame.complete &&
currentFrame.naturalWidth > 0
) {
screencastPanel.classList.add('visible');
currentFrame.style.opacity = '1';
return;
}
const crawlUrl = target.crawl?.id ? `/admin/crawls/crawl/${target.crawl.id}/change/` : '/admin/';
const placeholderKey = [target.state, target.title, target.subtitle, crawlUrl].join('|');
if (screencastPanel.dataset.mode === 'placeholder' && screencastPanel.dataset.key === placeholderKey) {
return;
}
screencastPanel.dataset.mode = 'placeholder';
screencastPanel.dataset.key = placeholderKey;
screencastPanel.classList.add('visible');
screencastPanel.innerHTML = `
<a class="screencast-frame" href="${escapeAttr(crawlUrl)}" title="Open crawl admin">
<span class="screencast-placeholder ${escapeAttr(target.state)}">
<span class="screencast-placeholder-icon">${escapeHtml(target.icon)}</span>
<span class="screencast-placeholder-title">${escapeHtml(target.title)}</span>
<span class="screencast-placeholder-subtitle">${escapeHtml(target.subtitle)}</span>
</span>
</a>
<a class="screencast-caption" href="${escapeAttr(crawlUrl)}" title="Open crawl admin">
<span class="screencast-dot ${escapeAttr(target.state)}"></span>
<span class="screencast-text">
<span class="screencast-title">${escapeHtml(target.title)}</span>
<span class="screencast-url">Crawl setup</span>
</span>
</a>
`;
return;
}
const snapshot = target.snapshot;
const adminUrl = snapshot.admin_url || `/admin/core/snapshot/${snapshot.id || 'unknown'}/change/`;
const linkUrl = snapshot.screencast_link || snapshot.view_url || adminUrl;
const titleText = snapshot.title || formatUrl(snapshot.full_url || snapshot.url);
const urlText = snapshot.full_url || snapshot.url || '';
const newUrl = snapshot.screencast_url;
const snapshotKey = `${target.crawl?.id || ''}|${snapshot.id || ''}`;
screencastPanel.dataset.desiredFrameKey = snapshotKey;
if (screencastPanel.dataset.mode !== 'frame' && !screencastPanel.querySelector('.screencast-frame img')?.getAttribute('src')) {
if (screencastPanel.dataset.pendingFrameSrc) return;
screencastPanel.dataset.pendingFrameSrc = newUrl;
screencastPanel.dataset.pendingFrameKey = snapshotKey;
const firstFrame = new Image();
firstFrame.decoding = 'async';
firstFrame.onload = () => {
(firstFrame.decode ? firstFrame.decode() : Promise.resolve()).catch(() => {}).finally(() => {
if (screencastPanel.dataset.pendingFrameSrc !== newUrl) return;
delete screencastPanel.dataset.pendingFrameSrc;
delete screencastPanel.dataset.pendingFrameKey;
if (screencastPanel.dataset.desiredFrameKey !== snapshotKey) return;
screencastPanel.dataset.mode = 'frame';
screencastPanel.dataset.key = '';
screencastPanel.dataset.currentFrameSrc = newUrl;
screencastPanel.innerHTML = `
<a class="screencast-frame" href="${escapeAttr(linkUrl)}" title="Open active snapshot">
<img src="${escapeAttr(newUrl)}" alt="" decoding="async" loading="eager">
</a>
<a class="screencast-caption" href="${escapeAttr(adminUrl)}" title="Open snapshot admin">
<span class="screencast-dot"></span>
<span class="screencast-text">
<span class="screencast-title">${escapeHtml(titleText)}</span>
<span class="screencast-url">${escapeHtml(urlText)}</span>
</span>
</a>
`;
screencastPanel.classList.add('visible');
});
};
firstFrame.onerror = () => {
if (screencastPanel.dataset.pendingFrameSrc === newUrl) {
delete screencastPanel.dataset.pendingFrameSrc;
delete screencastPanel.dataset.pendingFrameKey;
}
};
firstFrame.src = newUrl;
return;
}
if (screencastPanel.dataset.mode !== 'frame') {
screencastPanel.dataset.mode = 'frame';
screencastPanel.dataset.key = '';
screencastPanel.innerHTML = `
<a class="screencast-frame" href="" title="Open active snapshot">
<img src="" alt="" decoding="async" loading="eager">
</a>
<a class="screencast-caption" href="" title="Open snapshot admin">
<span class="screencast-dot"></span>
<span class="screencast-text">
<span class="screencast-title"></span>
<span class="screencast-url"></span>
</span>
</a>
`;
}
const frameLink = screencastPanel.querySelector('.screencast-frame');
const captionLink = screencastPanel.querySelector('.screencast-caption');
const img = screencastPanel.querySelector('.screencast-frame img');
const titleEl = screencastPanel.querySelector('.screencast-title');
const urlEl = screencastPanel.querySelector('.screencast-url');
frameLink.href = linkUrl;
captionLink.href = adminUrl;
titleEl.textContent = titleText;
urlEl.textContent = urlText;
if (img.getAttribute('src')) {
img.style.opacity = '1';
screencastPanel.classList.add('visible');
}
if (screencastPanel.dataset.currentFrameSrc !== newUrl && screencastPanel.dataset.pendingFrameSrc !== newUrl && !screencastPanel.dataset.pendingFrameSrc) {
screencastPanel.dataset.pendingFrameSrc = newUrl;
screencastPanel.dataset.pendingFrameKey = snapshotKey;
const nextFrame = new Image();
nextFrame.decoding = 'async';
nextFrame.onload = () => {
(nextFrame.decode ? nextFrame.decode() : Promise.resolve()).catch(() => {}).finally(() => {
if (screencastPanel.dataset.pendingFrameSrc !== newUrl) return;
delete screencastPanel.dataset.pendingFrameSrc;
delete screencastPanel.dataset.pendingFrameKey;
if (screencastPanel.dataset.desiredFrameKey !== snapshotKey) return;
const currentImg = screencastPanel.querySelector('.screencast-frame img');
if (!currentImg) return;
nextFrame.alt = '';
nextFrame.loading = 'eager';
nextFrame.style.opacity = '1';
currentImg.replaceWith(nextFrame);
screencastPanel.dataset.currentFrameSrc = newUrl;
screencastPanel.classList.add('visible');
});
};
nextFrame.onerror = () => {
if (screencastPanel.dataset.pendingFrameSrc === newUrl) {
delete screencastPanel.dataset.pendingFrameSrc;
delete screencastPanel.dataset.pendingFrameKey;
if (!screencastPanel.querySelector('.screencast-frame img')?.getAttribute('src')) {
screencastPanel.classList.remove('visible');
}
}
};
nextFrame.src = newUrl;
}
}
function renderExtractor(extractor) {
const icon = extractor.status === 'started' ? '▶' :
extractor.status === 'succeeded' ? '✓' :
extractor.status === 'failed' ? '!' :
extractor.status === 'backoff' ? 'wait' :
extractor.status === 'skipped' ? 'skip' :
extractor.status === 'noresults' ? '∅' : getPluginIcon(extractor.plugin);
const progress = typeof extractor.progress === 'number'
? Math.max(0, Math.min(100, extractor.progress))
: null;
const progressStyle = progress !== null ? ` style="width: ${progress}%;"` : '';
const pidHtml = extractor.status === 'started' && extractor.pid ? `<span class="pid-label compact">pid ${extractor.pid}</span>` : '';
const href = extractor.output_url || extractor.admin_url || '';
const tag = href ? 'a' : 'span';
const hrefAttr = href ? ` href="${escapeAttr(href)}"` : '';
const title = extractor.output_path
? `${extractor.plugin || 'output'}: ${extractor.output_path}`
: `${extractor.plugin || 'hook'}${extractor.hook_name ? `: ${extractor.hook_name}` : ''}`;
return `
<${tag} class="extractor-badge ${extractor.status || 'queued'}"${hrefAttr} title="${escapeAttr(title)}">
<span class="progress-fill"${progressStyle}></span>
<span class="badge-content">
<span class="badge-icon">${icon}</span>
<span>${escapeHtml(extractor.label || extractor.plugin || 'unknown')}</span>
${pidHtml}
</span>
</${tag}>
`;
}
function renderSnapshot(snapshot, crawlId) {
if (Array.isArray(snapshot)) {
snapshot = {
id: snapshot[0],
url: snapshot[1],
title: snapshot[2],
status: snapshot[3] || 'queued',
};
}
const statusIcon = snapshot.status === 'started' ? '▤' : '▢';
const adminUrl = snapshot.admin_url || `/admin/core/snapshot/${snapshot.id || 'unknown'}/change/`;
const canCancel = snapshot.status === 'queued';
const cancelBtn = canCancel
? `<button class="cancel-item-btn" data-cancel-type="snapshot" data-snapshot-id="${snapshot.id}" data-label="✕" title="Cancel snapshot">✕</button>`
: '';
const snapshotPidHtml = snapshot.worker_pid ? `<span class="pid-label compact">pid ${snapshot.worker_pid}</span>` : '';
const snapshotDurationHtml = renderDurationBadge(snapshot.started);
const titleText = snapshot.title || formatUrl(snapshot.full_url || snapshot.url);
const urlText = snapshot.full_url || snapshot.url || '';
const media = stableSnapshotMedia(snapshot);
const faviconHtml = media.favicon_url
? `<img class="snapshot-favicon" data-stable-media="favicon" src="${escapeAttr(media.favicon_url)}" alt="" decoding="async" loading="lazy" onerror="this.remove()">`
: '';
const previewHtml = media.preview_url
? `<a class="snapshot-preview" data-stable-media="preview" href="${escapeAttr(media.preview_link || snapshot.view_url || adminUrl)}" title="Open snapshot output"><img src="${escapeAttr(media.preview_url)}" alt="" decoding="async" loading="lazy" data-fallbacks="${escapeAttr((media.preview_fallbacks || []).join(','))}" onerror="nextPreviewFallback(this)"></a>`
: `<a class="snapshot-preview placeholder" href="${escapeAttr(snapshot.view_url || adminUrl)}" title="Open snapshot"><span>${statusIcon}</span></a>`;
let extractorHtml = '';
if (snapshot.all_plugins && snapshot.all_plugins.length > 0) {
// Sort plugins alphabetically by name to prevent reordering on updates
const sortedExtractors = [...snapshot.all_plugins].sort((a, b) =>
(a.plugin || '').localeCompare(b.plugin || '')
);
extractorHtml = `
<div class="extractor-list">
${sortedExtractors.map(e => renderExtractor(e)).join('')}
</div>
`;
}
const hasProcessEntries = (snapshot.all_plugins || []).some(extractor => extractor.source === 'process');
const hasArchiveResults = (snapshot.all_plugins || []).some(extractor => extractor.source === 'archiveresult');
const processOnly = hasProcessEntries && !hasArchiveResults;
const runningProcessCount = (snapshot.all_plugins || []).filter(extractor => extractor.source === 'process' && extractor.status === 'started').length;
const failedProcessCount = (snapshot.all_plugins || []).filter(extractor => extractor.source === 'process' && extractor.status === 'failed').length;
const snapshotMeta = (snapshot.total_plugins || 0) > 0
? processOnly
? runningProcessCount > 0
? `Running ${runningProcessCount}/${snapshot.total_plugins || 0} setup hooks`
: failedProcessCount > 0
? `${failedProcessCount} setup hook${failedProcessCount === 1 ? '' : 's'} failed`
: `${snapshot.completed_plugins || 0}/${snapshot.total_plugins || 0} setup hooks`
: hasProcessEntries
? `${snapshot.completed_plugins || 0}/${snapshot.total_plugins || 0} tasks${(snapshot.failed_plugins || 0) > 0 ? ` <span style="color:#f85149">(${snapshot.failed_plugins} failed)</span>` : ''}${runningProcessCount > 0 ? ` <span style="color:#d29922">(${runningProcessCount} hooks running)</span>` : ''}`
: `${snapshot.completed_plugins || 0}/${snapshot.total_plugins || 0} extractors${(snapshot.failed_plugins || 0) > 0 ? ` <span style="color:#f85149">(${snapshot.failed_plugins} failed)</span>` : ''}`
: snapshot.worker_state === 'crashed'
? '<span style="color:#f85149">Worker stopped before extractors started</span>'
: snapshot.worker_state === 'stalled'
? '<span style="color:#d29922">Waiting for runner to resume</span>'
: snapshot.worker_state === 'cancelled'
? '<span style="color:#8b949e">Cancelled before completion</span>'
: 'Waiting for extractors...';
return `
<div class="snapshot-item" data-snapshot-id="${escapeAttr(snapshot.id || '')}">
<div class="snapshot-header">
${previewHtml}
<a class="snapshot-header-link" href="${adminUrl}">
<div class="snapshot-info">
<div class="snapshot-title-line">
${faviconHtml}
<span class="snapshot-title">${escapeHtml(titleText)}</span>
</div>
<div class="snapshot-url">${escapeHtml(urlText)}</div>
<div class="snapshot-meta">
${snapshotMeta}
</div>
</div>
${snapshotPidHtml}
${snapshotDurationHtml}
<span class="status-badge ${snapshot.status || 'unknown'}">${snapshot.status || 'unknown'}</span>
</a>
${cancelBtn}
</div>
<div class="snapshot-progress">
<div class="progress-bar-container">
<div class="progress-bar snapshot ${((processOnly && runningProcessCount > 0) || (snapshot.status === 'started' && (snapshot.progress || 0) === 0)) ? 'indeterminate' : ''}"
style="width: ${snapshot.progress || 0}%"></div>
</div>
</div>
${extractorHtml}
</div>
`;
}
function renderCrawl(crawl) {
const adminUrl = `/admin/crawls/crawl/${crawl.id || 'unknown'}/change/`;
const adminFieldUrl = (fieldName) => `${adminUrl}#id_${fieldName}`;
const crawlBadge = (className, label, value, href, title) => {
const tag = href ? 'a' : 'span';
const hrefAttr = href ? ` href="${escapeAttr(href)}"` : '';
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
return `<${tag} class="crawl-badge ${className}"${hrefAttr}${titleAttr}><strong>${escapeHtml(label)}</strong>${escapeHtml(value)}</${tag}>`;
};
const crawlId = (crawl.id || 'unknown').toString();
const crawlShortId = crawlId === 'unknown' ? 'unknown' : crawlId.slice(-8);
const startedDate = crawl.started ? crawl.started.slice(0, 10) : 'unknown date';
const canCancel = crawl.status === 'queued' || crawl.status === 'started' || crawl.status === 'paused';
const canPause = (crawl.status === 'queued' || crawl.status === 'started') && !crawl.is_paused;
const canResume = crawl.status === 'paused' || crawl.is_paused;
const pauseBtn = canPause
? `<button class="crawl-action-btn pause-item-btn" data-crawl-action="pause" data-crawl-id="${crawl.id}" data-label="Ⅱ" title="Pause crawl">Ⅱ</button>`
: canResume
? `<button class="crawl-action-btn pause-item-btn" data-crawl-action="resume" data-crawl-id="${crawl.id}" data-label="▶" title="Resume crawl">▶</button>`
: '';
const cancelBtn = canCancel
? `<button class="cancel-item-btn" data-cancel-type="crawl" data-crawl-id="${crawl.id}" data-label="✕" title="Cancel crawl">✕</button>`
: '';
const crawlPidHtml = crawl.worker_pid ? `<span class="pid-label compact">pid ${crawl.worker_pid}</span>` : '';
const crawlDurationHtml = renderDurationBadge(crawl.started);
let snapshotsHtml = '';
if (crawl.active_snapshots && crawl.active_snapshots.length > 0) {
snapshotsHtml = crawl.active_snapshots.map(s => renderSnapshot(s, crawl.id)).join('');
}
const queuedSnapshotsHidden = Number(crawl.queued_snapshots_hidden || 0);
const queuedSnapshotsNote = queuedSnapshotsHidden > 0
? `<div class="progress-overflow-note">${queuedSnapshotsHidden} more queued snapshot${queuedSnapshotsHidden === 1 ? '' : 's'} not shown</div>`
: '';
let setupHtml = '';
if (crawl.setup_plugins && crawl.setup_plugins.length > 0) {
const sortedSetup = [...crawl.setup_plugins].sort((a, b) =>
(a.plugin || '').localeCompare(b.plugin || '')
);
setupHtml = `
<div class="snapshot-item">
<div class="snapshot-header">
<div class="snapshot-header-link">
<span class="snapshot-icon">&#9881;</span>
<div class="snapshot-info">
<div class="snapshot-url">Crawl Setup</div>
</div>
</div>
</div>
<div class="extractor-list">
${sortedSetup.map(e => renderExtractor(e)).join('')}
</div>
</div>
`;
}
// 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.is_paused) {
warningHtml = `
<div style="padding: 8px 14px; background: rgba(210, 153, 34, 0.1); border-top: 1px solid #d29922; color: #d29922; font-size: 11px;">
Crawl is paused. Resume it to continue processing queued snapshots.
</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;">
Trying in ${crawl.seconds_until_retry || 0}s...${crawl.urls_preview ? ` (${escapeHtml(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 the runner to pick up...${crawl.urls_preview ? ` (${escapeHtml(crawl.urls_preview)})` : ''}
</div>
`;
} else if (crawl.status === 'started' && crawl.worker_state === 'crashed') {
warningHtml = `
<div style="padding: 8px 14px; background: rgba(248, 81, 73, 0.1); border-top: 1px solid #f85149; color: #f85149; font-size: 11px;">
Runner stopped with ${crawl.started_snapshots || 0} active and ${crawl.pending_snapshots || 0} pending snapshots. It will resume when the runner starts again.
</div>
`;
} else if (crawl.worker_state === 'cancelled') {
warningHtml = `
<div style="padding: 8px 14px; background: rgba(139, 148, 158, 0.1); border-top: 1px solid #6e7681; color: #8b949e; font-size: 11px;">
Crawl was cancelled. ${crawl.cancelled_snapshots || 0} snapshot${(crawl.cancelled_snapshots || 0) === 1 ? '' : 's'} stopped before completion.
</div>
`;
}
// Show crawl-scale limits and sealed output sizes from DB metadata.
const currentUrlCount = Math.max(crawl.total_snapshots || 0, crawl.urls_count || 0);
const maxUrlsText = (crawl.max_urls || 0) > 0 ? crawl.max_urls : 'unlimited';
const urlLimitText = `${currentUrlCount} / ${maxUrlsText}`;
const crawlSizeLimitText = `${crawl.crawl_output_size_display || '0 B'} / ${crawl.max_crawl_size_display || 'unlimited'}`;
const crawlTimeoutText = (crawl.crawl_timeout || 0) > 0 ? `${crawl.crawl_timeout}s` : 'unlimited';
const snapshotSizeLimitText = `${crawl.avg_snapshot_size_display || '0 B'} / ${crawl.max_snapshot_size_display || 'unlimited'}`;
const crawlBadges = [
crawlBadge('persona', 'Persona Config', crawl.persona || 'Default', crawl.persona_admin_url, 'Edit persona config'),
crawlBadge('limit', 'depth', crawl.max_depth || 0, adminFieldUrl('max_depth'), 'Edit crawl depth'),
crawlBadge('limit', 'urls', urlLimitText, adminFieldUrl('max_urls'), 'Edit max URLs'),
crawlBadge('size', 'crawl size', crawlSizeLimitText, adminFieldUrl('crawl_max_size'), 'Edit max crawl size'),
crawlBadge('limit', 'time', crawlTimeoutText, adminFieldUrl('crawl_timeout'), 'Edit max crawl time'),
crawlBadge('size', 'avg snap', snapshotSizeLimitText, adminFieldUrl('snapshot_max_size'), 'Edit max snapshot size'),
...(crawl.tags || []).map(tag => `<a class="crawl-badge tag" href="${escapeAttr(adminFieldUrl('tags_editor'))}" title="Edit crawl tags">#${escapeHtml(tag)}</a>`),
].join('');
const statsHtml = [
`<span class="crawl-badge count"><strong>done</strong>${crawl.completed_snapshots || 0}</span>`,
`<span class="crawl-badge size"><strong>active</strong>${crawl.started_snapshots || 0}</span>`,
`<span class="crawl-badge"><strong>pending</strong>${crawl.pending_snapshots || 0}</span>`,
(crawl.cancelled_snapshots || 0) > 0 ? `<span class="crawl-badge"><strong>cancelled</strong>${crawl.cancelled_snapshots}</span>` : '',
].join('');
return `
<div class="crawl-item" data-crawl-id="${crawl.id || 'unknown'}">
<div class="crawl-header">
<div class="crawl-header-link">
<div class="crawl-info">
<a class="crawl-label crawl-title-link" href="${escapeAttr(adminUrl)}">Crawl #${escapeHtml(crawlShortId)} ${escapeHtml(startedDate)} started by ${escapeHtml(crawl.created_by || 'unknown')}</a>
<div class="crawl-badges">${crawlBadges}</div>
</div>
<div class="crawl-stats">
${statsHtml}
</div>
${crawlPidHtml}
${crawlDurationHtml}
<span class="status-badge ${crawl.status || 'unknown'}">${crawl.is_paused ? 'paused' : (crawl.status || 'unknown')}</span>
</div>
${pauseBtn}
${cancelBtn}
</div>
<div class="crawl-progress">
<div class="progress-bar-container">
<div class="progress-bar crawl ${crawl.status === 'started' && (crawl.progress || 0) === 0 ? 'indeterminate' : ''}"
style="width: ${crawl.progress || 0}%"></div>
</div>
</div>
${warningHtml}
<div class="crawl-body">
<div class="snapshot-list">
${setupHtml}
${snapshotsHtml}
${queuedSnapshotsNote}
</div>
</div>
</div>
`;
}
function setOrchestratorState(state, label) {
const dot = document.getElementById('orchestrator-dot');
dot.classList.remove('stopped', 'idle', 'running', 'error');
dot.classList.add(state);
document.getElementById('orchestrator-text').textContent = label;
return dot;
}
function updateProgress(data) {
idleMessage.style.color = '';
function setCount(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = Number(value || 0).toLocaleString();
}
function setActiveCount(id, value) {
setCount(id, value);
const segment = document.getElementById(`${id}-segment`);
if (segment) segment.style.display = Number(value || 0) > 0 ? '' : 'none';
}
// Calculate if there's activity
const hasActivity = data.active_crawls.length > 0 ||
data.crawls_queued > 0 || data.crawls_active > 0 ||
data.snapshots_queued > 0 || data.snapshots_active > 0 ||
data.archiveresults_queued > 0 || data.archiveresults_active > 0;
if (hasActivity) {
idleTicks = 0;
if (pollDelayMs !== 1000) {
setPollingDelay(1000);
}
} else {
idleTicks += 1;
if (idleTicks > 5 && pollDelayMs !== 10000) {
setPollingDelay(10000);
}
}
// Update orchestrator status - show "Running" only when there are active workers.
const pidEl = document.getElementById('orchestrator-pid');
const hasWorkers = data.total_workers > 0;
let dot = null;
if (hasWorkers) {
dot = setOrchestratorState('running', 'Running');
} else if (!data.orchestrator_running) {
dot = setOrchestratorState('stopped', 'Runner stopped');
} else if (hasActivity) {
dot = setOrchestratorState('idle', 'Idle');
} else {
dot = setOrchestratorState('idle', 'Idle');
}
if (data.orchestrator_pid) {
pidEl.textContent = `pid ${data.orchestrator_pid}`;
pidEl.style.display = 'inline-flex';
} else {
pidEl.textContent = '';
pidEl.style.display = 'none';
}
// Pulse the dot to show we got fresh data
dot.classList.add('flash');
setTimeout(() => dot.classList.remove('flash'), 300);
[
['crawls-queued', data.crawls_queued],
['snapshots-queued', data.snapshots_queued],
['downloads-queued', data.downloads_queued],
['indexing-queued', data.indexing_queued],
].forEach(([id, value]) => setCount(id, value));
[
['crawls-active', data.crawls_active],
['snapshots-active', data.snapshots_active],
['downloads-active', data.downloads_active],
['indexing-active', data.indexing_active],
].forEach(([id, value]) => setActiveCount(id, value));
updateScreencastPanel(data);
// Render crawl tree
if (data.active_crawls.length > 0) {
idleMessage.style.display = 'none';
const queuedCrawlsHidden = Number(data.queued_crawls_hidden || 0);
const queuedCrawlsNote = queuedCrawlsHidden > 0
? `<div class="progress-overflow-note">${queuedCrawlsHidden} more queued crawl${queuedCrawlsHidden === 1 ? '' : 's'} not shown</div>`
: '';
replaceCrawlTree(data.active_crawls.map(c => renderCrawl(c)).join('') + queuedCrawlsNote);
} else if (hasActivity) {
idleMessage.style.display = 'none';
replaceCrawlTree(`
<div class="idle-message">
${data.snapshots_active || 0} snapshots processing, ${data.archiveresults_active || 0} 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_queued || 0} pending, ${data.crawls_active || 0} started, <a href="${recentUrl}" style="color: #58a6ff;">${data.crawls_recent || 0} recent</a>)`;
replaceCrawlTree('');
}
updateDurationBadges();
}
const progressEndpoint = monitor.dataset.progressEndpoint || '/progress.json';
function fetchProgress() {
fetch(progressEndpoint, { credentials: 'same-origin' })
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then(data => {
if (data.error) {
console.error('Progress API error:', data.error, data.traceback);
setOrchestratorState('error', 'Backend not responding');
idleMessage.textContent = 'API Error: ' + data.error;
idleMessage.style.color = '#f85149';
return;
}
monitor.classList.toggle('is-guest', data.is_admin === false);
updateProgress(data);
})
.catch(error => {
console.error('Progress fetch error:', error);
setOrchestratorState('error', 'Backend not responding');
idleMessage.textContent = 'Fetch Error: ' + error.message;
idleMessage.style.color = '#f85149';
});
}
function startPolling() {
if (pollInterval) return;
fetchProgress();
pollInterval = setInterval(fetchProgress, pollDelayMs);
}
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
function setPollingDelay(ms) {
pollDelayMs = ms;
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = setInterval(fetchProgress, pollDelayMs);
}
}
function setCollapsedState(collapsed) {
isCollapsed = collapsed;
if (isCollapsed) {
monitor.classList.add('collapsed');
collapseBtn.textContent = 'Details';
collapseBtn.setAttribute('aria-expanded', 'false');
} else {
monitor.classList.remove('collapsed');
collapseBtn.textContent = 'Hide';
collapseBtn.setAttribute('aria-expanded', 'true');
}
}
function setActionButtonState(btn, busy) {
if (!btn) return;
const label = btn.dataset.label || '✕';
btn.disabled = !!busy;
btn.classList.toggle('is-busy', !!busy);
btn.textContent = busy ? '…' : label;
}
function patchProgressItem(url, action, btn, errorLabel) {
if (!url || !action) return;
setActionButtonState(btn, true);
fetch(buildApiUrl(url), {
method: 'PATCH',
headers: buildApiHeaders(),
credentials: 'same-origin',
body: JSON.stringify({ action: action }),
})
.then(response => response.json().then(data => ({response, data})))
.then(({response, data}) => {
if (!response.ok) throw new Error(data.detail || data.error || `HTTP ${response.status}`);
if (data.error) console.error(`${errorLabel} error:`, data.error);
fetchProgress();
})
.catch(error => {
console.error(`${errorLabel} failed:`, error);
setActionButtonState(btn, false);
});
}
// Collapse toggle
collapseBtn.addEventListener('click', function(event) {
event.stopPropagation();
setCollapsedState(!isCollapsed);
});
monitor.querySelector('.header-bar').addEventListener('click', function(event) {
if (event.target.closest('button, a, input, select, textarea, [role="button"]')) return;
if (isCollapsed) setCollapsedState(false);
});
crawlTree.addEventListener('click', function(event) {
const actionBtn = event.target.closest('.crawl-action-btn');
if (actionBtn) {
event.preventDefault();
event.stopPropagation();
patchProgressItem(
actionBtn.dataset.crawlId ? `/api/v1/crawls/crawl/${actionBtn.dataset.crawlId}` : '',
actionBtn.dataset.crawlAction,
actionBtn,
'Crawl action',
);
return;
}
const btn = event.target.closest('.cancel-item-btn');
if (!btn) return;
event.preventDefault();
event.stopPropagation();
const cancelType = btn.dataset.cancelType;
if (cancelType === 'crawl') {
patchProgressItem(btn.dataset.crawlId ? `/api/v1/crawls/crawl/${btn.dataset.crawlId}` : '', 'cancel', btn, 'Cancel crawl');
} else if (cancelType === 'snapshot') {
patchProgressItem(btn.dataset.snapshotId ? `/api/v1/core/snapshot/${btn.dataset.snapshotId}` : '', 'cancel', btn, 'Cancel snapshot');
}
});
// Apply initial state
setCollapsedState(isCollapsed);
// Start polling when page loads
startPolling();
setInterval(updateDurationBadges, 1000);
// Pause polling when tab is hidden
document.addEventListener('visibilitychange', function() {
if (document.hidden) {
stopPolling();
} else {
startPolling();
}
});
})();
</script>