feat(admin): time-series trend charts on the metrics tabs (0.87.0)
Turn the dormant timing data in crawl_metrics / page_analysis into "is it
healthy over time" views. New bucketed series queries (GROUP BY date_trunc,
hour|day via a closed Bucket enum so the unit can't be attacker-controlled)
behind GET /v1/admin/{crawler,analysis}/metrics/series, with a shared
SeriesParams/resolve_bucket helper (bad bucket → 400) and migration 0030
indexing page_analysis(analyzed_at) for the analysis scan.
Frontend: a dependency-free SVG TrendChart (line+area, null buckets render as
gaps, empty-state, role=img) embedded above the per-op tables in Crawler and
Analysis → Metrics, driven by each panel's existing window selector with
AbortController-cancelled fetches. A buildSeries() util fills the continuous
bucket axis (throughput 0 for empty buckets, success/duration null) — unit
tested alongside the chart and the series API client.
Closes the Phase-1 observability set (audit log · health checks · trends).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
194
backend/tests/admin_metrics_series.rs
Normal file
194
backend/tests/admin_metrics_series.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
//! Integration tests for the bucketed trend-series queries + endpoints:
|
||||
//! `repo::crawl_metrics::series`, `repo::page_analysis::analysis_series`, and
|
||||
//! `GET /v1/admin/{crawler,analysis}/metrics/series`.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::Router;
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use common::{body_json, get_with_cookie, harness, register_user};
|
||||
use mangalord::repo::crawl_metrics::{self, Bucket};
|
||||
use mangalord::repo::page_analysis;
|
||||
|
||||
async fn seed_admin(pool: &PgPool, app: &Router) -> String {
|
||||
let (username, cookie) = register_user(app).await;
|
||||
let u = mangalord::repo::user::find_by_username(pool, &username)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
mangalord::repo::user::set_is_admin_unchecked(pool, u.id, true)
|
||||
.await
|
||||
.unwrap();
|
||||
cookie
|
||||
}
|
||||
|
||||
async fn insert_metric(pool: &PgPool, outcome: &str, duration_ms: i64, finished_at: &str) {
|
||||
sqlx::query(
|
||||
"INSERT INTO crawl_metrics (op, outcome, duration_ms, finished_at) \
|
||||
VALUES ('manga_detail', $1, $2, $3::timestamptz)",
|
||||
)
|
||||
.bind(outcome)
|
||||
.bind(duration_ms)
|
||||
.bind(finished_at)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn crawl_series_buckets_by_hour_and_day(pool: PgPool) {
|
||||
// Two rows in hour 09, one in hour 10 (same day).
|
||||
insert_metric(&pool, "ok", 100, "2026-06-18T09:10:00Z").await;
|
||||
insert_metric(&pool, "failed", 300, "2026-06-18T09:50:00Z").await;
|
||||
insert_metric(&pool, "ok", 200, "2026-06-18T10:05:00Z").await;
|
||||
|
||||
let hourly = crawl_metrics::series(&pool, Bucket::Hour, None).await.unwrap();
|
||||
assert_eq!(hourly.len(), 2, "two hour buckets");
|
||||
// Ordered ascending; first bucket = hour 09 with 2 rows (1 ok / 1 failed).
|
||||
assert_eq!(hourly[0].n, 2);
|
||||
assert_eq!(hourly[0].ok, 1);
|
||||
assert_eq!(hourly[0].failed, 1);
|
||||
assert_eq!(hourly[0].avg_ms, Some(200.0)); // (100+300)/2
|
||||
assert_eq!(hourly[1].n, 1);
|
||||
|
||||
let daily = crawl_metrics::series(&pool, Bucket::Day, None).await.unwrap();
|
||||
assert_eq!(daily.len(), 1, "all three collapse into one day bucket");
|
||||
assert_eq!(daily[0].n, 3);
|
||||
assert_eq!(daily[0].failed, 1);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn crawl_series_respects_since_window(pool: PgPool) {
|
||||
insert_metric(&pool, "ok", 100, "2020-01-01T00:00:00Z").await; // ancient
|
||||
insert_metric(&pool, "ok", 100, "2026-06-18T10:00:00Z").await; // recent
|
||||
let since = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&chrono::Utc);
|
||||
let got = crawl_metrics::series(&pool, Bucket::Day, Some(since))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(got.len(), 1, "only the in-window row");
|
||||
}
|
||||
|
||||
async fn seed_page(pool: &PgPool) -> Uuid {
|
||||
let manga = Uuid::new_v4();
|
||||
let chapter = Uuid::new_v4();
|
||||
let page = Uuid::new_v4();
|
||||
sqlx::query("INSERT INTO mangas (id, title) VALUES ($1, 'M')")
|
||||
.bind(manga)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO chapters (id, manga_id, number) VALUES ($1, $2, 1)")
|
||||
.bind(chapter)
|
||||
.bind(manga)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO pages (id, chapter_id, page_number, storage_key, content_type) \
|
||||
VALUES ($1, $2, 1, 'k', 'image/png')",
|
||||
)
|
||||
.bind(page)
|
||||
.bind(chapter)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
page
|
||||
}
|
||||
|
||||
async fn insert_analysis(pool: &PgPool, page_id: Uuid, status: &str, dur: i64, analyzed_at: &str) {
|
||||
sqlx::query(
|
||||
"INSERT INTO page_analysis (page_id, status, duration_ms, analyzed_at) \
|
||||
VALUES ($1, $2, $3, $4::timestamptz)",
|
||||
)
|
||||
.bind(page_id)
|
||||
.bind(status)
|
||||
.bind(dur)
|
||||
.bind(analyzed_at)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn analysis_series_splits_ok_failed_by_bucket(pool: PgPool) {
|
||||
let p1 = seed_page(&pool).await;
|
||||
let p2 = seed_page(&pool).await;
|
||||
let p3 = seed_page(&pool).await;
|
||||
insert_analysis(&pool, p1, "done", 500, "2026-06-18T09:00:00Z").await;
|
||||
insert_analysis(&pool, p2, "failed", 999, "2026-06-18T09:30:00Z").await;
|
||||
insert_analysis(&pool, p3, "done", 700, "2026-06-18T11:00:00Z").await;
|
||||
|
||||
let hourly = page_analysis::analysis_series(&pool, Bucket::Hour, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(hourly.len(), 2);
|
||||
assert_eq!(hourly[0].n, 2);
|
||||
assert_eq!(hourly[0].ok, 1);
|
||||
assert_eq!(hourly[0].failed, 1);
|
||||
// Mean duration is done-only → 500 (the failed 999 is excluded).
|
||||
assert_eq!(hourly[0].avg_ms, Some(500.0));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn crawler_series_endpoint_shape_and_gating(pool: PgPool) {
|
||||
let h = harness(pool.clone());
|
||||
// Non-admin → 403.
|
||||
let (_u, plain) = register_user(&h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(get_with_cookie("/api/v1/admin/crawler/metrics/series", &plain))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let cookie = seed_admin(&pool, &h.app).await;
|
||||
insert_metric(&pool, "ok", 100, "2026-06-18T09:10:00Z").await;
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(get_with_cookie(
|
||||
"/api/v1/admin/crawler/metrics/series?days=1",
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
assert!(body["buckets"].is_array());
|
||||
|
||||
// Bad bucket → 400.
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(get_with_cookie(
|
||||
"/api/v1/admin/crawler/metrics/series?bucket=year",
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn analysis_series_endpoint_ok(pool: PgPool) {
|
||||
let h = harness(pool.clone());
|
||||
let cookie = seed_admin(&pool, &h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(get_with_cookie(
|
||||
"/api/v1/admin/analysis/metrics/series?days=7",
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
assert!(body["buckets"].is_array());
|
||||
}
|
||||
Reference in New Issue
Block a user