feat(analysis): live SSE event stream for the admin dashboard

Broadcasts analysis progress so the dashboard updates live:

- analysis::events: AnalysisEvents broadcaster + AnalysisEvent
  (Enqueued / Started / Completed / Failed), carrying the
  manga/chapter/page breadcrumb.
- The worker daemon resolves each page's breadcrumb (repo::page::locate)
  and publishes Started before dispatch and Completed/Failed after.
- The admin reenqueue publishes Enqueued (scoped by manga/chapter).
- GET /v1/admin/analysis/status/stream — SSE (RequireAdmin) forwarding
  each event as a named `analysis` frame; broadcast lag emits a `lagged`
  frame. AppState carries the always-present events bus.

Tests: worker publishes started+completed (with breadcrumb) and failed;
SSE route is admin-gated (403 non-admin) and returns text/event-stream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 21:06:20 +02:00
parent 6bb72b8775
commit d0cd31c9a7
13 changed files with 314 additions and 3 deletions

View File

@@ -42,6 +42,22 @@ pub async fn find_by_id(pool: &PgPool, id: Uuid) -> AppResult<Option<Page>> {
Ok(row)
}
/// Resolve a page's breadcrumb `(manga_id, chapter_id, page_number)` for
/// live event payloads. `None` if the page doesn't exist.
pub async fn locate(
pool: &PgPool,
page_id: Uuid,
) -> AppResult<Option<(Uuid, Uuid, i32)>> {
let row: Option<(Uuid, Uuid, i32)> = sqlx::query_as(
"SELECT c.manga_id, p.chapter_id, p.page_number \
FROM pages p JOIN chapters c ON c.id = p.chapter_id WHERE p.id = $1",
)
.bind(page_id)
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn list_for_chapter(pool: &PgPool, chapter_id: Uuid) -> AppResult<Vec<Page>> {
let rows = sqlx::query_as::<_, Page>(
r#"