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) => { await page.route('**/api/v1/admin/analysis/reenqueue', (r) => {
cap.reenqueue = JSON.parse(r.request().postData() ?? '{}'); cap.reenqueue = JSON.parse(r.request().postData() ?? '{}');
return r.fulfill({ 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 }) => { test('queue an unanalyzed page from its detail modal', async ({ page }) => {
const cap: Captured = { reenqueue: null, analyzeCalls: 0 }; const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
await mockAdmin(page, cap); await mockAdmin(page, cap);

View File

@@ -32,7 +32,8 @@ import {
getAnalysisMangaCoverage, getAnalysisMangaCoverage,
getAnalysisChapterCoverage, getAnalysisChapterCoverage,
getAnalysisChapterPages, getAnalysisChapterPages,
getAnalysisPageDetail getAnalysisPageDetail,
analysisStatusStreamUrl
} from './admin'; } from './admin';
function ok(body: unknown, status = 200): Response { 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(url).toMatch(/\/v1\/admin\/analysis\/pages\/p1$/);
expect(d.status).toBe('none'); 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)}` `/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"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { ApiError } from '$lib/api/client'; import { ApiError } from '$lib/api/client';
import { import {
reenqueueAnalysis, reenqueueAnalysis,
@@ -8,11 +8,13 @@
getAnalysisChapterCoverage, getAnalysisChapterCoverage,
getAnalysisChapterPages, getAnalysisChapterPages,
getAnalysisPageDetail, getAnalysisPageDetail,
analysisStatusStreamUrl,
type MangaCoverage, type MangaCoverage,
type ChapterCoverage, type ChapterCoverage,
type PageStatusItem, type PageStatusItem,
type PageAnalysisDetail, type PageAnalysisDetail,
type ReenqueueAnalysisOptions type ReenqueueAnalysisOptions,
type AnalysisEvent
} from '$lib/api/admin'; } from '$lib/api/admin';
import { chapterLabel } from '$lib/api/chapters'; import { chapterLabel } from '$lib/api/chapters';
import CoverageBadge from '$lib/components/CoverageBadge.svelte'; import CoverageBadge from '$lib/components/CoverageBadge.svelte';
@@ -50,10 +52,154 @@
let notice = $state<string | null>(null); let notice = $state<string | null>(null);
let error = $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(() => { onMount(() => {
void loadCoverage(true); 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() { function onSearchInput() {
if (searchTimer) clearTimeout(searchTimer); if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => loadCoverage(true), 300); searchTimer = setTimeout(() => loadCoverage(true), 300);
@@ -66,6 +212,9 @@
// Collapse open rows so stale chapter/page state doesn't linger. // Collapse open rows so stale chapter/page state doesn't linger.
expandedManga = null; expandedManga = null;
expandedChapter = null; expandedChapter = null;
// Server numbers are authoritative again — drop live overrides.
liveStatus = {};
counted = new Set();
} else { } else {
loadingMore = true; loadingMore = true;
} }
@@ -122,7 +271,13 @@
async function loadPages(id: string) { async function loadPages(id: string) {
pages[id] = 'loading'; pages[id] = 'loading';
try { 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) { } catch (e) {
delete pages[id]; delete pages[id];
error = e instanceof ApiError ? e.message : 'Failed to load pages.'; error = e instanceof ApiError ? e.message : 'Failed to load pages.';
@@ -220,13 +375,32 @@
<title>Mangalord | Admin — Analysis</title> <title>Mangalord | Admin — Analysis</title>
</svelte:head> </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"> <p class="lede">
Coverage of the AI analysis worker (OCR, auto-tags, scene description, Coverage of the AI analysis worker (OCR, auto-tags, scene description,
NSFW moderation). Browse what's analyzed, queue more, and inspect any 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> </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"> <label class="checkbox">
<input <input
type="checkbox" type="checkbox"
@@ -365,11 +539,11 @@
{#each ps as p (p.page_id)} {#each ps as p (p.page_id)}
<button <button
type="button" type="button"
class="page-chip {p.status}" class="page-chip {chipStatus(p)}"
title={`Page ${p.page_number} ${p.status}`} title={`Page ${p.page_number} ${chipStatus(p)}`}
onclick={() => openDetail(p.page_id)} onclick={() => openDetail(p.page_id)}
data-testid={`admin-analysis-page-${p.page_id}`} data-testid={`admin-analysis-page-${p.page_id}`}
data-status={p.status} data-status={chipStatus(p)}
> >
{p.page_number} {p.page_number}
</button> </button>
@@ -377,6 +551,7 @@
</div> </div>
<p class="legend"> <p class="legend">
<span class="dot done"></span> analyzed <span class="dot done"></span> analyzed
<span class="dot analyzing"></span> analyzing
<span class="dot queued"></span> queued <span class="dot queued"></span> queued
<span class="dot failed"></span> failed <span class="dot failed"></span> failed
<span class="dot none"></span> not analyzed <span class="dot none"></span> not analyzed
@@ -496,9 +671,68 @@
</Modal> </Modal>
<style> <style>
.title-row {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-2);
}
.heading { .heading {
margin-bottom: var(--space-2); 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 { .lede {
color: var(--text-muted); color: var(--text-muted);
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
@@ -620,6 +854,12 @@
border-color: color-mix(in srgb, #2563eb 45%, transparent); border-color: color-mix(in srgb, #2563eb 45%, transparent);
color: #2563eb; 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 { .page-chip.failed {
background: color-mix(in srgb, var(--danger) 18%, transparent); background: color-mix(in srgb, var(--danger) 18%, transparent);
border-color: color-mix(in srgb, var(--danger) 45%, transparent); border-color: color-mix(in srgb, var(--danger) 45%, transparent);
@@ -647,6 +887,9 @@
.dot.queued { .dot.queued {
background: #2563eb; background: #2563eb;
} }
.dot.analyzing {
background: #d4762a;
}
.dot.failed { .dot.failed {
background: var(--danger); background: var(--danger);
} }