feat(admin): observability — job history, live now-analyzing, durations & metrics (0.84.0) (#6)
Some checks failed
deploy / test-backend (push) Failing after 19m59s
deploy / test-frontend (push) Successful in 9m54s
deploy / build-and-push (push) Has been skipped
deploy / deploy (push) Has been skipped

This commit was merged in pull request #6.
This commit is contained in:
2026-06-16 12:21:13 +00:00
parent 790549636f
commit d51ab2a049
41 changed files with 3655 additions and 21 deletions

View File

@@ -16,9 +16,11 @@ use uuid::Uuid;
use crate::crawler::jobs::{self, JobPayload};
use crate::domain::page_analysis::{
ChapterCoverage, ContentWarning, MangaCoverage, OcrKind, OcrLine, PageAnalysis,
PageAnalysisDetail, PageSearchItem, PageStatusItem, VisionAnalysis,
AnalysisHistoryRow, AnalysisMetrics, ChapterCoverage, ContentWarning, MangaCoverage,
ModelDuration, OcrKind, OcrLine, PageAnalysis, PageAnalysisDetail, PageSearchItem,
PageStatusItem, VisionAnalysis,
};
use chrono::{DateTime, Utc};
use crate::error::AppResult;
/// Filter set for [`page_search`]. `tags` are AND-ed (a page must carry
@@ -212,6 +214,90 @@ pub async fn chapter_page_status(
Ok(rows)
}
/// Filters for [`list_history`]. `None` widens the scope.
#[derive(Debug, Default, Clone)]
pub struct AnalysisHistoryFilter<'a> {
/// Exact analysis status (`done` or `failed`).
pub status: Option<&'a str>,
/// Only NSFW-flagged pages.
pub nsfw_only: bool,
/// Case-insensitive manga-title substring.
pub search: Option<&'a str>,
}
/// Paginated, newest-first analysis history — every completed/failed page
/// analysis resolved to its manga/chapter/page context. Reads the
/// persistent `page_analysis` table, so unlike the crawler job history it
/// is not bounded by job reaping. Returns the page slice plus the filtered
/// total. `pending` rows are excluded — history is terminal outcomes only.
pub async fn list_history(
pool: &PgPool,
filter: AnalysisHistoryFilter<'_>,
limit: i64,
offset: i64,
) -> AppResult<(Vec<AnalysisHistoryRow>, i64)> {
let search_pat = filter
.search
.map(|s| format!("%{}%", s.trim()))
.filter(|p| p.len() > 2);
let items = sqlx::query_as::<_, AnalysisHistoryRow>(
r#"
SELECT
pa.page_id,
p.page_number,
p.chapter_id,
c.number AS chapter_number,
c.manga_id,
m.title AS manga_title,
pa.status,
pa.is_nsfw,
pa.model,
pa.error,
pa.analyzed_at,
pa.duration_ms
FROM page_analysis pa
JOIN pages p ON p.id = pa.page_id
JOIN chapters c ON c.id = p.chapter_id
JOIN mangas m ON m.id = c.manga_id
WHERE pa.status <> 'pending'
AND ($1::text IS NULL OR pa.status = $1)
AND ($2::bool IS FALSE OR pa.is_nsfw)
AND ($3::text IS NULL OR m.title ILIKE $3)
ORDER BY pa.analyzed_at DESC NULLS LAST, pa.page_id
LIMIT $4 OFFSET $5
"#,
)
.bind(filter.status)
.bind(filter.nsfw_only)
.bind(&search_pat)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;
let (total,): (i64,) = sqlx::query_as(
r#"
SELECT count(*)
FROM page_analysis pa
JOIN pages p ON p.id = pa.page_id
JOIN chapters c ON c.id = p.chapter_id
JOIN mangas m ON m.id = c.manga_id
WHERE pa.status <> 'pending'
AND ($1::text IS NULL OR pa.status = $1)
AND ($2::bool IS FALSE OR pa.is_nsfw)
AND ($3::text IS NULL OR m.title ILIKE $3)
"#,
)
.bind(filter.status)
.bind(filter.nsfw_only)
.bind(&search_pat)
.fetch_one(pool)
.await?;
Ok((items, total))
}
/// Full analysis detail for one page. `None` when the page itself doesn't
/// exist; an existing-but-unanalyzed page returns `status = "none"` with
/// empty OCR/tags/warnings so the UI can show "not analyzed yet".
@@ -331,6 +417,66 @@ pub async fn mark_failed(pool: &PgPool, page_id: Uuid, error: &str) -> AppResult
Ok(())
}
/// Record the wall-clock the worker spent on a page. Best-effort: updates the
/// existing `page_analysis` row (written by `persist_analysis`/`mark_failed`);
/// a transient failure with no row yet simply updates nothing.
pub async fn record_duration(
pool: &PgPool,
page_id: Uuid,
duration_ms: i64,
) -> AppResult<()> {
sqlx::query("UPDATE page_analysis SET duration_ms = $2 WHERE page_id = $1")
.bind(page_id)
.bind(duration_ms)
.execute(pool)
.await?;
Ok(())
}
/// Aggregate analysis timing + outcome roll-up over an optional time window
/// (`since = None` → all time). Mean duration ignores NULL `duration_ms`
/// (pre-tracking rows); `by_model` breaks the mean out per model.
pub async fn analysis_metrics(
pool: &PgPool,
since: Option<DateTime<Utc>>,
) -> AppResult<AnalysisMetrics> {
let (n, ok, failed, avg_ms): (i64, i64, i64, Option<f64>) = sqlx::query_as(
r#"
SELECT
COUNT(*) AS n,
COUNT(*) FILTER (WHERE status = 'done') AS ok,
COUNT(*) FILTER (WHERE status = 'failed') AS failed,
-- Mean over successful pages only, so it reconciles with the
-- by-model breakdown below (which is done-only). Failed /
-- timed-out durations would otherwise skew the headline up.
AVG(duration_ms) FILTER (WHERE status = 'done')::float8 AS avg_ms
FROM page_analysis
WHERE status <> 'pending'
AND ($1::timestamptz IS NULL OR analyzed_at >= $1)
"#,
)
.bind(since)
.fetch_one(pool)
.await?;
let by_model = sqlx::query_as::<_, ModelDuration>(
r#"
SELECT model, AVG(duration_ms)::float8 AS avg_ms, COUNT(*) AS n
FROM page_analysis
WHERE status = 'done'
AND duration_ms IS NOT NULL
AND ($1::timestamptz IS NULL OR analyzed_at >= $1)
GROUP BY model
ORDER BY n DESC
"#,
)
.bind(since)
.fetch_all(pool)
.await?;
Ok(AnalysisMetrics { n, ok, failed, avg_ms, by_model })
}
/// Persist a completed analysis for a page, replacing any previous result.
///
/// The model's free-form `kind` / `content_type` strings are mapped onto