feat(analysis): live SSE updates in the admin coverage dashboard

The Analysis section now reflects worker progress in real time:

- Opens an EventSource on the live stream while mounted; a "Live" pill
  reflects connection state and reconnects on drop (probes after repeated
  failures so a lost session logs out).
- Applies events incrementally: enqueued marks in-scope loaded pages
  queued; started flips a chip to a pulsing "analyzing"; completed flips
  it green and live-bumps the manga + chapter coverage badges (guarded so
  no double count); failed flips it red. A compact activity ticker shows
  the last few events.
- Server numbers stay authoritative: re-loading the overview or a page
  grid clears the matching live overrides.

API client: analysisStatusStreamUrl() + AnalysisEvent type.

Tests: vitest for the stream URL; Playwright asserts SSE frames flow into
the ticker (existing coverage/drill/queue specs get a default quiet
stream). svelte-check + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 21:12:56 +02:00
parent d0cd31c9a7
commit 8b5bd99446
4 changed files with 334 additions and 9 deletions

View File

@@ -155,6 +155,14 @@ async function mockAdmin(page: Page, cap: Captured) {
})
);
// Default: keep the SSE connection pending (no events) so tests that
// don't care about live updates don't trigger reconnect churn. The
// live-updates test overrides this with a fulfilling stream.
await page.route(
'**/api/v1/admin/analysis/status/stream',
() => new Promise(() => {})
);
await page.route('**/api/v1/admin/analysis/reenqueue', (r) => {
cap.reenqueue = JSON.parse(r.request().postData() ?? '{}');
return r.fulfill({
@@ -221,6 +229,49 @@ test.describe('/admin/analysis', () => {
);
});
test('live SSE events drive the indicator and activity ticker', async ({
page
}) => {
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
await mockAdmin(page, cap);
// Override the default hanging stream with one that emits two frames.
const frames =
`event: analysis\ndata: ${JSON.stringify({
kind: 'started',
page_id: pageNone,
manga_id: mangaId,
chapter_id: chapterId,
page_number: 2
})}\n\n` +
`event: analysis\ndata: ${JSON.stringify({
kind: 'completed',
page_id: pageNone,
manga_id: mangaId,
chapter_id: chapterId,
page_number: 2
})}\n\n`;
let served = false;
await page.route('**/api/v1/admin/analysis/status/stream', (r) => {
if (served) return new Promise(() => {}); // hang on reconnect
served = true;
return r.fulfill({
status: 200,
headers: { 'content-type': 'text/event-stream' },
body: frames
});
});
await page.setViewportSize(DESKTOP);
await page.goto('/admin/analysis');
// The two SSE frames are parsed and applied to the activity ticker.
// (The mocked stream closes after its body, so the live pill flips
// back to "Reconnecting…" — the ticker is the durable signal.)
const tick = page.getByTestId('admin-analysis-ticker');
await expect(tick).toContainText('Analyzing page 2');
await expect(tick).toContainText('Analyzed page 2');
});
test('queue an unanalyzed page from its detail modal', async ({ page }) => {
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
await mockAdmin(page, cap);

View File

@@ -32,7 +32,8 @@ import {
getAnalysisMangaCoverage,
getAnalysisChapterCoverage,
getAnalysisChapterPages,
getAnalysisPageDetail
getAnalysisPageDetail,
analysisStatusStreamUrl
} from './admin';
function ok(body: unknown, status = 200): Response {
@@ -580,4 +581,10 @@ describe('admin crawler api client', () => {
expect(url).toMatch(/\/v1\/admin\/analysis\/pages\/p1$/);
expect(d.status).toBe('none');
});
it('analysisStatusStreamUrl points at the SSE endpoint', () => {
expect(analysisStatusStreamUrl()).toMatch(
/\/v1\/admin\/analysis\/status\/stream$/
);
});
});

View File

@@ -532,3 +532,27 @@ export async function getAnalysisPageDetail(
`/v1/admin/analysis/pages/${encodeURIComponent(pageId)}`
);
}
/** One live analysis event from the SSE stream. */
export type AnalysisEvent =
| {
kind: 'enqueued';
count: number;
manga_id: string | null;
chapter_id: string | null;
}
| {
kind: 'started' | 'completed' | 'failed';
page_id: string;
manga_id: string;
chapter_id: string;
page_number: number;
};
/** URL of the live analysis SSE stream. Open with `new EventSource(...)`
* while the admin Analysis page is mounted and close it on navigate-away.
* Each message is a named `analysis` event whose `data` is an
* {@link AnalysisEvent}; a `lagged` event signals dropped frames. */
export function analysisStatusStreamUrl(): string {
return apiUrl('/v1/admin/analysis/status/stream');
}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { ApiError } from '$lib/api/client';
import {
reenqueueAnalysis,
@@ -8,11 +8,13 @@
getAnalysisChapterCoverage,
getAnalysisChapterPages,
getAnalysisPageDetail,
analysisStatusStreamUrl,
type MangaCoverage,
type ChapterCoverage,
type PageStatusItem,
type PageAnalysisDetail,
type ReenqueueAnalysisOptions
type ReenqueueAnalysisOptions,
type AnalysisEvent
} from '$lib/api/admin';
import { chapterLabel } from '$lib/api/chapters';
import CoverageBadge from '$lib/components/CoverageBadge.svelte';
@@ -50,10 +52,154 @@
let notice = $state<string | null>(null);
let error = $state<string | null>(null);
// --- Live status (SSE) ---
type LiveStatus = 'analyzing' | 'done' | 'failed' | 'queued';
// Per-page override applied on top of the fetched grid status.
let liveStatus = $state<Record<string, LiveStatus>>({});
// Pages we've already live-counted into coverage, so a repeat
// `completed` can't double-bump a badge.
let counted = new Set<string>();
let ticker = $state<{ id: number; text: string }[]>([]);
let tickerSeq = 0;
let live = $state(false);
let source: EventSource | null = null;
let sseErrors = 0;
onMount(() => {
void loadCoverage(true);
openStream();
});
onDestroy(() => {
source?.close();
source = null;
});
function pushTicker(text: string) {
ticker = [{ id: ++tickerSeq, text }, ...ticker].slice(0, 8);
}
/** Effective chip status: live override wins over the fetched value. */
function chipStatus(p: PageStatusItem): string {
return liveStatus[p.page_id] ?? p.status;
}
/** mangaId that owns a loaded chapter, via the chapters cache. */
function mangaOfChapter(chapterId: string): string | null {
for (const [mid, val] of Object.entries(chapters)) {
if (Array.isArray(val) && val.some((c) => c.chapter_id === chapterId)) {
return mid;
}
}
return null;
}
function bumpCoverage(mangaId: string, chapterId: string) {
mangas = mangas.map((m) =>
m.manga_id === mangaId
? {
...m,
analyzed_pages: Math.min(m.total_pages, m.analyzed_pages + 1)
}
: m
);
const chs = chapters[mangaId];
if (Array.isArray(chs)) {
chapters = {
...chapters,
[mangaId]: chs.map((c) =>
c.chapter_id === chapterId
? {
...c,
analyzed_pages: Math.min(
c.total_pages,
c.analyzed_pages + 1
)
}
: c
)
};
}
}
/** Mark loaded, in-scope, not-yet-done pages as queued. */
function markQueued(ev: Extract<AnalysisEvent, { kind: 'enqueued' }>) {
const next = { ...liveStatus };
for (const [chapterId, val] of Object.entries(pages)) {
if (!Array.isArray(val)) continue;
const inScope =
ev.chapter_id != null
? chapterId === ev.chapter_id
: ev.manga_id != null
? mangaOfChapter(chapterId) === ev.manga_id
: true; // whole library
if (!inScope) continue;
for (const p of val) {
const cur: string = next[p.page_id] ?? p.status;
if (cur === 'none' || cur === 'failed') next[p.page_id] = 'queued';
}
}
liveStatus = next;
}
function applyEvent(ev: AnalysisEvent) {
if (ev.kind === 'enqueued') {
const scope = ev.chapter_id
? ' (chapter)'
: ev.manga_id
? ' (manga)'
: ' (library)';
pushTicker(`Queued ${ev.count} page${ev.count === 1 ? '' : 's'}${scope}`);
markQueued(ev);
return;
}
if (ev.kind === 'started') {
liveStatus = { ...liveStatus, [ev.page_id]: 'analyzing' };
pushTicker(`Analyzing page ${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);
}
pushTicker(`✓ Analyzed page ${ev.page_number}`);
} else if (ev.kind === 'failed') {
liveStatus = { ...liveStatus, [ev.page_id]: 'failed' };
pushTicker(`✗ Failed page ${ev.page_number}`);
}
}
function openStream() {
if (source) return;
const es = new EventSource(analysisStatusStreamUrl(), { withCredentials: true });
es.addEventListener('analysis', (e) => {
try {
applyEvent(JSON.parse((e as MessageEvent).data) as AnalysisEvent);
live = true;
sseErrors = 0;
} catch {
// ignore a malformed frame
}
});
// Dropped frames — resync the overview from the server.
es.addEventListener('lagged', () => void loadCoverage(true));
es.onopen = () => {
live = true;
sseErrors = 0;
};
es.onerror = () => {
live = false;
sseErrors += 1;
// EventSource hides the status code; after several failures,
// probe a normal admin call so the global on401 hook can fire.
if (sseErrors >= 4) {
sseErrors = 0;
getAnalysisMangaCoverage({ limit: 1 }).catch(() => {});
}
};
source = es;
}
function onSearchInput() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => loadCoverage(true), 300);
@@ -66,6 +212,9 @@
// Collapse open rows so stale chapter/page state doesn't linger.
expandedManga = null;
expandedChapter = null;
// Server numbers are authoritative again — drop live overrides.
liveStatus = {};
counted = new Set();
} else {
loadingMore = true;
}
@@ -122,7 +271,13 @@
async function loadPages(id: string) {
pages[id] = 'loading';
try {
pages[id] = await getAnalysisChapterPages(id);
const fresh = await getAnalysisChapterPages(id);
// Backend status is authoritative for just-fetched pages; drop
// any live overrides for them so the grid reflects the server.
const next = { ...liveStatus };
for (const p of fresh) delete next[p.page_id];
liveStatus = next;
pages[id] = fresh;
} catch (e) {
delete pages[id];
error = e instanceof ApiError ? e.message : 'Failed to load pages.';
@@ -220,13 +375,32 @@
<title>Mangalord | Admin — Analysis</title>
</svelte:head>
<h1 class="heading">Content analysis</h1>
<div class="title-row">
<h1 class="heading">Content analysis</h1>
<span
class="live-pill"
class:on={live}
data-testid="admin-analysis-live"
title={live ? 'Live updates connected' : 'Reconnecting…'}
>
<span class="live-dot"></span>
{live ? 'Live' : 'Reconnecting…'}
</span>
</div>
<p class="lede">
Coverage of the AI analysis worker (OCR, auto-tags, scene description,
NSFW moderation). Browse what's analyzed, queue more, and inspect any
page's result.
page's result. Updates stream live as pages are queued and processed.
</p>
{#if ticker.length > 0}
<ul class="ticker" data-testid="admin-analysis-ticker" aria-live="polite">
{#each ticker as t (t.id)}
<li>{t.text}</li>
{/each}
</ul>
{/if}
<label class="checkbox">
<input
type="checkbox"
@@ -365,11 +539,11 @@
{#each ps as p (p.page_id)}
<button
type="button"
class="page-chip {p.status}"
title={`Page ${p.page_number} ${p.status}`}
class="page-chip {chipStatus(p)}"
title={`Page ${p.page_number} ${chipStatus(p)}`}
onclick={() => openDetail(p.page_id)}
data-testid={`admin-analysis-page-${p.page_id}`}
data-status={p.status}
data-status={chipStatus(p)}
>
{p.page_number}
</button>
@@ -377,6 +551,7 @@
</div>
<p class="legend">
<span class="dot done"></span> analyzed
<span class="dot analyzing"></span> analyzing
<span class="dot queued"></span> queued
<span class="dot failed"></span> failed
<span class="dot none"></span> not analyzed
@@ -496,9 +671,68 @@
</Modal>
<style>
.title-row {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-2);
}
.heading {
margin-bottom: var(--space-2);
}
.title-row .heading {
margin-bottom: 0;
}
.live-pill {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--font-xs);
font-weight: var(--weight-semibold);
padding: 2px var(--space-2);
border-radius: var(--radius-pill);
background: var(--surface-elevated);
color: var(--text-muted);
border: 1px solid var(--border);
}
.live-pill.on {
color: #2e7d32;
border-color: color-mix(in srgb, #2e7d32 40%, transparent);
}
.live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.live-pill.on .live-dot {
animation: pulse 1.6s ease-in-out infinite;
}
.ticker {
list-style: none;
margin: 0 0 var(--space-3);
padding: var(--space-2) var(--space-3);
max-width: 44rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
font-size: var(--font-sm);
color: var(--text-muted);
display: flex;
flex-direction: column;
gap: 2px;
max-height: 9rem;
overflow: hidden;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.lede {
color: var(--text-muted);
margin-bottom: var(--space-4);
@@ -620,6 +854,12 @@
border-color: color-mix(in srgb, #2563eb 45%, transparent);
color: #2563eb;
}
.page-chip.analyzing {
background: color-mix(in srgb, #d4762a 22%, transparent);
border-color: color-mix(in srgb, #d4762a 55%, transparent);
color: #b85e1a;
animation: pulse 1.2s ease-in-out infinite;
}
.page-chip.failed {
background: color-mix(in srgb, var(--danger) 18%, transparent);
border-color: color-mix(in srgb, var(--danger) 45%, transparent);
@@ -647,6 +887,9 @@
.dot.queued {
background: #2563eb;
}
.dot.analyzing {
background: #d4762a;
}
.dot.failed {
background: var(--danger);
}