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:
@@ -9,13 +9,19 @@
|
||||
//! * `POST /admin/pages/:id/analyze` — force re-analysis of a single page
|
||||
//! (re-runs even if already `done`).
|
||||
|
||||
use std::convert::Infallible;
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use futures_util::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::analysis::events::AnalysisEvent;
|
||||
use crate::api::pagination::PagedResponse;
|
||||
use crate::app::AppState;
|
||||
use crate::auth::extractor::RequireAdmin;
|
||||
@@ -35,6 +41,36 @@ pub fn routes() -> Router<AppState> {
|
||||
.route("/admin/analysis/mangas/:id/chapters", get(coverage_chapters))
|
||||
.route("/admin/analysis/chapters/:id/pages", get(chapter_pages))
|
||||
.route("/admin/analysis/pages/:id", get(page_detail))
|
||||
.route("/admin/analysis/status/stream", get(stream_status))
|
||||
}
|
||||
|
||||
/// Live analysis events (SSE). Forwards each `AnalysisEvent` as a named
|
||||
/// `analysis` event; on broadcast lag (a slow client) emits a `lagged`
|
||||
/// event so the dashboard can refresh. EventSource sends the session
|
||||
/// cookie, so `RequireAdmin` gates the stream like any other admin route.
|
||||
async fn stream_status(
|
||||
State(state): State<AppState>,
|
||||
_admin: RequireAdmin,
|
||||
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
||||
let rx = state.analysis_events.subscribe();
|
||||
let stream = futures_util::stream::unfold(rx, |mut rx| async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(ev) => {
|
||||
let event = Event::default()
|
||||
.event("analysis")
|
||||
.json_data(&ev)
|
||||
.unwrap_or_else(|_| Event::default().comment("serialize error"));
|
||||
return Some((Ok(event), rx));
|
||||
}
|
||||
Err(RecvError::Lagged(_)) => {
|
||||
return Some((Ok(Event::default().event("lagged").data("")), rx));
|
||||
}
|
||||
Err(RecvError::Closed) => return None,
|
||||
}
|
||||
}
|
||||
});
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -207,6 +243,16 @@ async fn reenqueue(
|
||||
let enqueued =
|
||||
repo::page_analysis::enqueue_pages(&state.db, scope, body.only_unanalyzed).await?;
|
||||
|
||||
// Push a live event so connected dashboards mark the in-scope pages as
|
||||
// queued. Skip the no-op (nothing actually enqueued).
|
||||
if enqueued > 0 {
|
||||
state.analysis_events.publish(AnalysisEvent::Enqueued {
|
||||
count: enqueued,
|
||||
manga_id: body.manga_id,
|
||||
chapter_id: body.chapter_id,
|
||||
});
|
||||
}
|
||||
|
||||
repo::admin_audit::insert(
|
||||
&state.db,
|
||||
admin.0.id,
|
||||
|
||||
Reference in New Issue
Block a user