feat(admin): observability — job history, live now-analyzing, durations & metrics (0.84.0) (#6)
Some checks failed
deploy / test-backend (push) Failing after 19m59s
deploy / test-frontend (push) Successful in 9m54s
deploy / build-and-push (push) Has been skipped
deploy / deploy (push) Has been skipped

This commit was merged in pull request #6.
This commit is contained in:
2026-06-16 12:21:13 +00:00
parent 790549636f
commit d51ab2a049
41 changed files with 3655 additions and 21 deletions

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { onMount, onDestroy, tick } from 'svelte';
import { ApiError } from '$lib/api/client';
import {
reenqueueAnalysis,
@@ -19,9 +19,16 @@
import { chapterLabel } from '$lib/api/chapters';
import CoverageBadge from '$lib/components/CoverageBadge.svelte';
import Modal from '$lib/components/Modal.svelte';
import AnalysisHistoryTable from '$lib/components/analysis/AnalysisHistoryTable.svelte';
import AnalysisMetricsPanel from '$lib/components/analysis/AnalysisMetricsPanel.svelte';
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
const LIMIT = 25;
// Segmented Live/History/Metrics view. Live = the SSE coverage
// dashboard; History = a searchable log; Metrics = durations/averages.
let view = $state<'live' | 'history' | 'metrics'>('live');
// Global enqueue toggle.
let includeAnalyzed = $state(false);
@@ -65,6 +72,44 @@
let source: EventSource | null = null;
let sseErrors = 0;
// Global "now analyzing" pointer, driven by the `started` SSE event so
// the operator sees what the worker is on even when nothing is
// expanded. `phase` flips to done/failed for a moment on the matching
// completed/failed event, then the banner clears.
type NowAnalyzing = {
manga_id: string;
manga_title: string;
chapter_id: string;
chapter_number: number;
page_id: string;
page_number: number;
phase: 'analyzing' | 'done' | 'failed';
};
let nowAnalyzing = $state<NowAnalyzing | null>(null);
let clearTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleClear(pageId: string) {
if (clearTimer) clearTimeout(clearTimer);
clearTimer = setTimeout(() => {
// Don't clear if a newer page took over the banner.
if (nowAnalyzing?.page_id === pageId) nowAnalyzing = null;
}, 4000);
}
/** Switch to Live, expand the now-analyzing chapter, scroll its chip. */
async function jumpToNowAnalyzing() {
const t = nowAnalyzing;
if (!t) return;
view = 'live';
await tick();
if (expandedManga !== t.manga_id) await toggleManga(t.manga_id);
if (expandedChapter !== t.chapter_id) await toggleChapter(t.chapter_id);
await tick();
document
.querySelector(`[data-testid="admin-analysis-page-${t.page_id}"]`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
onMount(() => {
void loadCoverage(true);
openStream();
@@ -73,6 +118,7 @@
onDestroy(() => {
source?.close();
source = null;
if (clearTimer) clearTimeout(clearTimer);
});
function pushTicker(text: string) {
@@ -155,16 +201,34 @@
}
if (ev.kind === 'started') {
liveStatus = { ...liveStatus, [ev.page_id]: 'analyzing' };
pushTicker(`Analyzing page ${ev.page_number}`);
if (clearTimer) clearTimeout(clearTimer);
nowAnalyzing = {
manga_id: ev.manga_id,
manga_title: ev.manga_title,
chapter_id: ev.chapter_id,
chapter_number: ev.chapter_number,
page_id: ev.page_id,
page_number: ev.page_number,
phase: 'analyzing'
};
pushTicker(`Analyzing ${ev.manga_title} · p${ev.page_number}`);
} else if (ev.kind === 'completed') {
liveStatus = { ...liveStatus, [ev.page_id]: 'done' };
if (!counted.has(ev.page_id)) {
counted.add(ev.page_id);
bumpCoverage(ev.manga_id, ev.chapter_id);
}
if (nowAnalyzing?.page_id === ev.page_id) {
nowAnalyzing = { ...nowAnalyzing, phase: 'done' };
scheduleClear(ev.page_id);
}
pushTicker(`✓ Analyzed page ${ev.page_number}`);
} else if (ev.kind === 'failed') {
liveStatus = { ...liveStatus, [ev.page_id]: 'failed' };
if (nowAnalyzing?.page_id === ev.page_id) {
nowAnalyzing = { ...nowAnalyzing, phase: 'failed' };
scheduleClear(ev.page_id);
}
pushTicker(`✗ Failed page ${ev.page_number}`);
}
}
@@ -393,6 +457,60 @@
page's result. Updates stream live as pages are queued and processed.
</p>
<div class="viewtabs">
<SegmentedControl
options={[
{ label: 'Live', value: 'live' },
{ label: 'History', value: 'history' },
{ label: 'Metrics', value: 'metrics' }
]}
value={view}
onchange={(v) => (view = v)}
ariaLabel="Analysis view"
testid="admin-analysis-tab"
/>
</div>
{#if nowAnalyzing}
<div
class="now-analyzing {nowAnalyzing.phase}"
data-testid="admin-analysis-now"
aria-live="polite"
>
<span class="now-icon">
{nowAnalyzing.phase === 'done' ? '✓' : nowAnalyzing.phase === 'failed' ? '✗' : '⟳'}
</span>
<span class="now-label">
<strong
>{nowAnalyzing.phase === 'done'
? 'Analyzed'
: nowAnalyzing.phase === 'failed'
? 'Failed'
: 'Now analyzing'}</strong
>
{nowAnalyzing.manga_title} · Ch {nowAnalyzing.chapter_number} · Page
{nowAnalyzing.page_number}
</span>
<button
type="button"
class="now-jump"
onclick={jumpToNowAnalyzing}
data-testid="admin-analysis-now-jump"
>
Jump →
</button>
</div>
{/if}
{#if view === 'history'}
<AnalysisHistoryTable onOpenDetail={openDetail} />
{/if}
{#if view === 'metrics'}
<AnalysisMetricsPanel />
{/if}
{#if view === 'live'}
{#if ticker.length > 0}
<ul class="ticker" data-testid="admin-analysis-ticker" aria-live="polite">
{#each ticker as t (t.id)}
@@ -586,6 +704,7 @@
{#if error}
<p class="error" role="alert" data-testid="admin-analysis-error">{error}</p>
{/if}
{/if}
<Modal
open={detailOpen}
@@ -708,6 +827,58 @@
.live-pill.on .live-dot {
animation: pulse 1.6s ease-in-out infinite;
}
.viewtabs {
margin-bottom: var(--space-3);
}
.now-analyzing {
position: sticky;
top: var(--space-2);
z-index: 5;
display: flex;
align-items: center;
gap: var(--space-3);
max-width: 44rem;
margin-bottom: var(--space-3);
padding: var(--space-2) var(--space-3);
border: 1px solid color-mix(in srgb, #d4762a 45%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, #d4762a 12%, transparent);
font-size: var(--font-sm);
}
.now-analyzing.done {
border-color: color-mix(in srgb, #2e7d32 45%, transparent);
background: color-mix(in srgb, #2e7d32 12%, transparent);
}
.now-analyzing.failed {
border-color: color-mix(in srgb, var(--danger) 45%, transparent);
background: color-mix(in srgb, var(--danger) 12%, transparent);
}
.now-icon {
font-size: var(--font-lg);
color: #b85e1a;
}
.now-analyzing.analyzing .now-icon {
animation: spin 1.4s linear infinite;
display: inline-block;
}
.now-analyzing.done .now-icon {
color: #2e7d32;
}
.now-analyzing.failed .now-icon {
color: var(--danger);
}
.now-label {
flex: 1;
min-width: 0;
}
.now-jump {
flex-shrink: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.ticker {
list-style: none;
margin: 0 0 var(--space-3);

View File

@@ -6,6 +6,9 @@
import ActiveJobsTable from '$lib/components/crawler/ActiveJobsTable.svelte';
import MissingCoversTable from '$lib/components/crawler/MissingCoversTable.svelte';
import DeadJobsTable from '$lib/components/crawler/DeadJobsTable.svelte';
import CrawlerHistoryTable from '$lib/components/crawler/CrawlerHistoryTable.svelte';
import CrawlerMetricsPanel from '$lib/components/crawler/CrawlerMetricsPanel.svelte';
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
import SessionModal from '$lib/components/crawler/SessionModal.svelte';
import RestartConfirmModal from '$lib/components/crawler/RestartConfirmModal.svelte';
import RequeueAllConfirmModal from '$lib/components/crawler/RequeueAllConfirmModal.svelte';
@@ -27,6 +30,10 @@
type RequeueScope
} from '$lib/api/admin';
// Segmented Live/History/Metrics view. Live keeps the SSE-driven
// dashboard; History is a searchable job log; Metrics shows durations.
let view = $state<'live' | 'history' | 'metrics'>('live');
let status: CrawlerStatus | null = $state(null);
let error: string | null = $state(null);
let notice: string | null = $state(null);
@@ -387,6 +394,20 @@
</span>
</div>
<div class="viewtabs">
<SegmentedControl
options={[
{ label: 'Live', value: 'live' },
{ label: 'History', value: 'history' },
{ label: 'Metrics', value: 'metrics' }
]}
value={view}
onchange={(v) => (view = v)}
ariaLabel="Crawler view"
testid="crawler-tab"
/>
</div>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
@@ -394,6 +415,11 @@
<p class="notice" role="status">{notice}</p>
{/if}
{#if view === 'history'}
<CrawlerHistoryTable onRequeue={requeue} {busy} />
{:else if view === 'metrics'}
<CrawlerMetricsPanel />
{:else}
{#if status}
<CrawlerHero {status} />
@@ -446,6 +472,7 @@
onRequeue={requeue}
onRequeueAll={() => (requeueAllModalOpen = true)}
/>
{/if}
<RestartConfirmModal
open={restartModalOpen}
@@ -488,6 +515,9 @@
.livedot.on {
color: var(--success, #0a7d2c);
}
.viewtabs {
margin-bottom: var(--space-4);
}
.notice {
color: var(--success, #0a7d2c);
padding: var(--space-2) var(--space-3);