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

@@ -22,6 +22,7 @@ use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::analysis::events::{AnalysisEvent, AnalysisEvents};
use crate::analysis::vision::VisionClient;
use crate::crawler::jobs::{self, JobPayload, Lease, KIND_ANALYZE_PAGE};
use crate::repo;
@@ -43,6 +44,8 @@ pub struct AnalysisDaemonConfig {
pub dispatcher: Arc<dyn AnalyzeDispatcher>,
pub workers: usize,
pub job_timeout: Duration,
/// Live-event sink (Started/Completed/Failed) for admin SSE.
pub events: Arc<AnalysisEvents>,
}
pub struct AnalysisDaemonHandle {
@@ -71,6 +74,7 @@ pub fn spawn(
cancel: cancel.clone(),
dispatcher: Arc::clone(&cfg.dispatcher),
job_timeout: cfg.job_timeout,
events: Arc::clone(&cfg.events),
id,
};
join.spawn(async move { ctx.run().await });
@@ -83,6 +87,7 @@ struct WorkerContext {
cancel: CancellationToken,
dispatcher: Arc<dyn AnalyzeDispatcher>,
job_timeout: Duration,
events: Arc<AnalysisEvents>,
id: usize,
}
@@ -143,6 +148,20 @@ impl WorkerContext {
}
}
// Resolve the page breadcrumb once so live events carry the
// manga/chapter/number the dashboard keys on. A missing breadcrumb
// (page deleted) just suppresses events — the dispatch still runs
// and acks normally.
let breadcrumb = repo::page::locate(&self.pool, page_id).await.ok().flatten();
if let Some((manga_id, chapter_id, page_number)) = breadcrumb {
self.events.publish(AnalysisEvent::Started {
page_id,
manga_id,
chapter_id,
page_number,
});
}
// Heartbeat the lease while the vision call runs.
let heartbeat = {
let hb_pool = self.pool.clone();
@@ -171,6 +190,15 @@ impl WorkerContext {
Ok(Ok(Ok(()))) => Ok(()),
};
if let Some((manga_id, chapter_id, page_number)) = breadcrumb {
let event = if result.is_ok() {
AnalysisEvent::Completed { page_id, manga_id, chapter_id, page_number }
} else {
AnalysisEvent::Failed { page_id, manga_id, chapter_id, page_number }
};
self.events.publish(event);
}
match result {
Ok(()) => {
let _ = jobs::ack_done(&self.pool, lease.id).await;

View File

@@ -0,0 +1,78 @@
//! Live analysis events broadcast to admin SSE subscribers.
//!
//! A thin wrapper over a `tokio::sync::broadcast` channel. The worker
//! publishes per-page progress (`Started`/`Completed`/`Failed`) and the
//! admin enqueue path publishes `Enqueued`; the
//! `GET /v1/admin/analysis/status/stream` SSE endpoint forwards each event.
//! Discrete events (rather than the crawler's snapshot-on-change) map
//! directly onto "this page/chapter/manga just changed state", which the
//! dashboard applies incrementally.
use serde::Serialize;
use tokio::sync::broadcast;
use uuid::Uuid;
/// One live analysis event. Serialized with a `kind` discriminator so the
/// frontend can switch on it.
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AnalysisEvent {
/// A bulk re-enqueue happened. `manga_id` / `chapter_id` scope it (both
/// `None` = whole library). The UI optimistically marks in-scope pages
/// as queued.
Enqueued {
count: u64,
manga_id: Option<Uuid>,
chapter_id: Option<Uuid>,
},
/// The worker began analyzing a page.
Started {
page_id: Uuid,
manga_id: Uuid,
chapter_id: Uuid,
page_number: i32,
},
/// The worker finished a page successfully.
Completed {
page_id: Uuid,
manga_id: Uuid,
chapter_id: Uuid,
page_number: i32,
},
/// The worker failed a page (this attempt).
Failed {
page_id: Uuid,
manga_id: Uuid,
chapter_id: Uuid,
page_number: i32,
},
}
/// Broadcaster shared on `AppState`. Cheap to clone via `Arc`. Publishing
/// when there are no subscribers is a no-op (the send error is ignored).
pub struct AnalysisEvents {
tx: broadcast::Sender<AnalysisEvent>,
}
impl AnalysisEvents {
pub fn new() -> Self {
// Buffer is generous; a slow subscriber that lags is dropped frames
// (the SSE handler treats `Lagged` as "skip and continue").
let (tx, _rx) = broadcast::channel(512);
Self { tx }
}
pub fn publish(&self, event: AnalysisEvent) {
let _ = self.tx.send(event);
}
pub fn subscribe(&self) -> broadcast::Receiver<AnalysisEvent> {
self.tx.subscribe()
}
}
impl Default for AnalysisEvents {
fn default() -> Self {
Self::new()
}
}

View File

@@ -8,5 +8,6 @@
//! * [`daemon`] — the job-leasing worker loop (added in the worker phase).
pub mod daemon;
pub mod events;
pub mod prompt;
pub mod vision;