feat(admin): observability — job history, live now-analyzing, durations & metrics (0.84.0) (#6)
This commit was merged in pull request #6.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user