feat(admin): observability — job history, live now-analyzing, durations & metrics (0.84.0) (#6)
This commit was merged in pull request #6.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user