mirror of
https://github.com/ArchiveBox/ArchiveBox.git
synced 2026-06-21 19:10:45 -04:00
1942 lines
75 KiB
HTML
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 => ({
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": ''',
|
|
}[char]));
|
|
}
|
|
function escapeAttr(value) {
|
|
return escapeHtml(value).replace(/`/g, '`');
|
|
}
|
|
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': '</>',
|
|
'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">⚙</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>
|