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

@@ -0,0 +1,158 @@
//! DB access for the `crawl_metrics` timing log (migration 0028).
//!
//! `record` is the best-effort write called from each crawl operation;
//! `summary` and `list_ops` are the admin Metrics-tab reads. Mirrors the
//! plain-fn + `query_as` style used across `repo`.
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use crate::domain::crawl_metrics::{OpRow, OpSummary};
/// Operation kinds, kept as consts so call sites and the CHECK constraint
/// don't drift from a free-typed literal.
pub const OP_MANGA_LIST: &str = "manga_list";
pub const OP_MANGA_DETAIL: &str = "manga_detail";
pub const OP_MANGA_COVER: &str = "manga_cover";
pub const OP_CHAPTER: &str = "chapter";
/// Insert one completed-operation timing row. Best-effort: callers log and
/// continue on error so a metrics-write failure never fails the actual crawl
/// work. `outcome` is `"ok"` or `"failed"`.
#[allow(clippy::too_many_arguments)]
pub async fn record(
pool: &PgPool,
op: &str,
manga_id: Option<Uuid>,
chapter_id: Option<Uuid>,
outcome: &str,
duration_ms: i64,
items: Option<i32>,
error: Option<&str>,
) -> sqlx::Result<()> {
sqlx::query(
"INSERT INTO crawl_metrics \
(op, manga_id, chapter_id, outcome, duration_ms, items, error) \
VALUES ($1, $2, $3, $4, $5, $6, $7)",
)
.bind(op)
.bind(manga_id)
.bind(chapter_id)
.bind(outcome)
.bind(duration_ms)
.bind(items)
.bind(error)
.execute(pool)
.await?;
Ok(())
}
/// Per-op average roll-up over an optional time window (`since = None` → all
/// time). One row per `op` that has any metric in the window, with mean
/// duration, counts, success split, and mean `items`.
pub async fn summary(
pool: &PgPool,
since: Option<DateTime<Utc>>,
) -> sqlx::Result<Vec<OpSummary>> {
sqlx::query_as::<_, OpSummary>(
r#"
SELECT
op,
AVG(duration_ms)::float8 AS avg_ms,
COUNT(*) AS n,
COUNT(*) FILTER (WHERE outcome = 'ok') AS ok,
COUNT(*) FILTER (WHERE outcome = 'failed') AS failed,
AVG(items)::float8 AS avg_items
FROM crawl_metrics
WHERE ($1::timestamptz IS NULL OR finished_at >= $1)
GROUP BY op
ORDER BY op
"#,
)
.bind(since)
.fetch_all(pool)
.await
}
/// Filters for [`list_ops`]. `None`/empty widens the scope.
#[derive(Debug, Default, Clone)]
pub struct OpFilter<'a> {
pub op: Option<&'a str>,
pub outcome: Option<&'a str>,
pub since: Option<DateTime<Utc>>,
}
/// Paginated, newest-first recent-operations log resolved to manga/chapter
/// labels. Returns the page slice plus the filtered total.
pub async fn list_ops(
pool: &PgPool,
filter: OpFilter<'_>,
limit: i64,
offset: i64,
) -> sqlx::Result<(Vec<OpRow>, i64)> {
let items = sqlx::query_as::<_, OpRow>(
r#"
SELECT
cm.id,
cm.op,
cm.manga_id,
m.title AS manga_title,
cm.chapter_id,
c.number AS chapter_number,
cm.outcome,
cm.duration_ms,
cm.items,
cm.error,
cm.finished_at
FROM crawl_metrics cm
LEFT JOIN mangas m ON m.id = cm.manga_id
LEFT JOIN chapters c ON c.id = cm.chapter_id
WHERE ($1::text IS NULL OR cm.op = $1)
AND ($2::text IS NULL OR cm.outcome = $2)
AND ($3::timestamptz IS NULL OR cm.finished_at >= $3)
ORDER BY cm.finished_at DESC
LIMIT $4 OFFSET $5
"#,
)
.bind(filter.op)
.bind(filter.outcome)
.bind(filter.since)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;
let (total,): (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*)
FROM crawl_metrics cm
WHERE ($1::text IS NULL OR cm.op = $1)
AND ($2::text IS NULL OR cm.outcome = $2)
AND ($3::timestamptz IS NULL OR cm.finished_at >= $3)
"#,
)
.bind(filter.op)
.bind(filter.outcome)
.bind(filter.since)
.fetch_one(pool)
.await?;
Ok((items, total))
}
/// Delete metric rows older than `retention_days`. `0` disables the reaper
/// (returns 0 without touching the table). Mirrors `jobs::reap_done`.
pub async fn reap(pool: &PgPool, retention_days: u32) -> sqlx::Result<u64> {
if retention_days == 0 {
return Ok(0);
}
let result = sqlx::query(
"DELETE FROM crawl_metrics \
WHERE finished_at < now() - ($1::bigint || ' days')::interval",
)
.bind(retention_days as i64)
.execute(pool)
.await?;
Ok(result.rows_affected())
}