From c6a6d1690d61a7e523fdeb1d8055d6e61c683814 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 19 Jun 2026 11:48:56 +0200 Subject: [PATCH] feat(admin): time-series trend charts on the metrics tabs (0.87.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- .../0030_page_analysis_analyzed_at_index.sql | 8 + backend/src/api/admin/analysis.rs | 52 +++++ backend/src/api/admin/crawler/metrics.rs | 19 +- backend/src/domain/crawl_metrics.rs | 14 ++ backend/src/repo/crawl_metrics.rs | 47 ++++- backend/src/repo/page_analysis.rs | 33 +++ backend/tests/admin_metrics_series.rs | 194 ++++++++++++++++++ frontend/package.json | 2 +- frontend/src/lib/admin/series.test.ts | 51 +++++ frontend/src/lib/admin/series.ts | 75 +++++++ frontend/src/lib/api/admin.test.ts | 18 +- frontend/src/lib/api/admin.ts | 34 +++ .../analysis/AnalysisMetricsPanel.svelte | 49 ++++- .../lib/components/charts/TrendChart.svelte | 168 +++++++++++++++ .../charts/TrendChart.svelte.test.ts | 53 +++++ .../crawler/CrawlerMetricsPanel.svelte | 45 ++++ 18 files changed, 856 insertions(+), 10 deletions(-) create mode 100644 backend/migrations/0030_page_analysis_analyzed_at_index.sql create mode 100644 backend/tests/admin_metrics_series.rs create mode 100644 frontend/src/lib/admin/series.test.ts create mode 100644 frontend/src/lib/admin/series.ts create mode 100644 frontend/src/lib/components/charts/TrendChart.svelte create mode 100644 frontend/src/lib/components/charts/TrendChart.svelte.test.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 2556483..cb907d4 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.86.0" +version = "0.87.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d7a4083..edc2e71 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.86.0" +version = "0.87.0" edition = "2021" default-run = "mangalord" diff --git a/backend/migrations/0030_page_analysis_analyzed_at_index.sql b/backend/migrations/0030_page_analysis_analyzed_at_index.sql new file mode 100644 index 0000000..0db975d --- /dev/null +++ b/backend/migrations/0030_page_analysis_analyzed_at_index.sql @@ -0,0 +1,8 @@ +-- Index for the Analysis trend-chart series, which buckets terminal page +-- analyses by `analyzed_at` (date_trunc) over a recent window. Without it the +-- bucketed GROUP BY falls back to a full scan of page_analysis (one row per +-- page in the library). Partial to the rows the series query actually reads +-- (terminal status with a timestamp), keeping the index small. +CREATE INDEX IF NOT EXISTS page_analysis_analyzed_at_idx + ON page_analysis (analyzed_at) + WHERE status <> 'pending' AND analyzed_at IS NOT NULL; diff --git a/backend/src/api/admin/analysis.rs b/backend/src/api/admin/analysis.rs index 267fa90..96d5ff5 100644 --- a/backend/src/api/admin/analysis.rs +++ b/backend/src/api/admin/analysis.rs @@ -44,6 +44,7 @@ pub fn routes() -> Router { .route("/admin/analysis/pages/:id", get(page_detail)) .route("/admin/analysis/history", get(list_history)) .route("/admin/analysis/metrics", get(metrics)) + .route("/admin/analysis/metrics/series", get(metrics_series)) .route("/admin/analysis/status/stream", get(stream_status)) } @@ -215,6 +216,46 @@ pub(super) fn window_since(days: i64) -> Option> { } } +// --- shared trend-series plumbing (crawler + analysis charts) --------------- + +/// Query params for the bucketed trend-series endpoints. `bucket` is +/// optional; when absent it's derived from `days`. +#[derive(Debug, Deserialize, Default)] +pub struct SeriesParams { + #[serde(default)] + pub days: i64, + #[serde(default)] + pub bucket: Option, +} + +/// Envelope for a trend series. +#[derive(Debug, Serialize)] +pub struct SeriesResponse { + pub buckets: Vec, +} + +/// Resolve the `date_trunc` granularity: an explicit `bucket` (validated to +/// `hour`|`day`, else 400) wins; otherwise short windows bucket by hour and +/// longer ones by day so the point count stays chart-friendly. +pub(super) fn resolve_bucket( + days: i64, + explicit: Option<&str>, +) -> Result { + use crate::repo::crawl_metrics::Bucket; + match explicit { + Some("hour") => Ok(Bucket::Hour), + Some("day") => Ok(Bucket::Day), + Some(other) => Err(AppError::InvalidInput(format!( + "invalid bucket '{other}' (expected 'hour' or 'day')" + ))), + None => Ok(if days > 0 && days <= 2 { + Bucket::Hour + } else { + Bucket::Day + }), + } +} + async fn metrics( State(state): State, _admin: RequireAdmin, @@ -225,6 +266,17 @@ async fn metrics( Ok(Json(m)) } +async fn metrics_series( + State(state): State, + _admin: RequireAdmin, + Query(params): Query, +) -> AppResult>> { + let bucket = resolve_bucket(params.days, params.bucket.as_deref())?; + let buckets = + repo::page_analysis::analysis_series(&state.db, bucket, window_since(params.days)).await?; + Ok(Json(SeriesResponse { buckets })) +} + #[derive(Debug, Deserialize)] pub struct ReenqueueBody { /// Skip pages that already have a `done` analysis row. Defaults to diff --git a/backend/src/api/admin/crawler/metrics.rs b/backend/src/api/admin/crawler/metrics.rs index e545d6c..cd3c202 100644 --- a/backend/src/api/admin/crawler/metrics.rs +++ b/backend/src/api/admin/crawler/metrics.rs @@ -11,20 +11,31 @@ use serde::{Deserialize, Serialize}; use crate::app::AppState; use crate::auth::extractor::RequireAdmin; -use crate::domain::crawl_metrics::{OpRow, OpSummary}; +use crate::domain::crawl_metrics::{MetricsBucket, OpRow, OpSummary}; use crate::error::AppResult; use crate::repo; use crate::repo::crawl_metrics::OpFilter; use super::default_limit; -// Reuse the analysis handler's window helper so both endpoints clamp `days` -// identically. -use crate::api::admin::analysis::window_since; +// Reuse the analysis handler's window + bucket helpers so all the metrics +// endpoints clamp `days` and resolve `bucket` identically. +use crate::api::admin::analysis::{resolve_bucket, window_since, SeriesParams, SeriesResponse}; pub(super) fn routes() -> Router { Router::new() .route("/admin/crawler/metrics", get(summary)) .route("/admin/crawler/metrics/ops", get(list_ops)) + .route("/admin/crawler/metrics/series", get(series)) +} + +async fn series( + State(state): State, + _admin: RequireAdmin, + Query(params): Query, +) -> AppResult>> { + let bucket = resolve_bucket(params.days, params.bucket.as_deref())?; + let buckets = repo::crawl_metrics::series(&state.db, bucket, window_since(params.days)).await?; + Ok(Json(SeriesResponse { buckets })) } #[derive(Debug, Deserialize, Default)] diff --git a/backend/src/domain/crawl_metrics.rs b/backend/src/domain/crawl_metrics.rs index e617cca..df31e4a 100644 --- a/backend/src/domain/crawl_metrics.rs +++ b/backend/src/domain/crawl_metrics.rs @@ -11,6 +11,20 @@ use serde::Serialize; use sqlx::FromRow; use uuid::Uuid; +/// One time bucket of a metrics series — count, ok/failed split, and mean +/// duration over the bucket. Shared by the crawl-ops and analysis trend +/// charts (both are `{t, n, ok, failed, avg_ms}` over a `date_trunc` window). +/// Empty intervals are simply absent; the client fills the continuous axis. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct MetricsBucket { + /// Bucket start (`date_trunc(unit, finished_at|analyzed_at)`). + pub t: DateTime, + pub n: i64, + pub ok: i64, + pub failed: i64, + pub avg_ms: Option, +} + /// Per-operation-type average roll-up over a time window. #[derive(Debug, Clone, Serialize, FromRow)] pub struct OpSummary { diff --git a/backend/src/repo/crawl_metrics.rs b/backend/src/repo/crawl_metrics.rs index d166407..663e54e 100644 --- a/backend/src/repo/crawl_metrics.rs +++ b/backend/src/repo/crawl_metrics.rs @@ -8,7 +8,24 @@ use chrono::{DateTime, Utc}; use sqlx::PgPool; use uuid::Uuid; -use crate::domain::crawl_metrics::{OpRow, OpSummary}; +use crate::domain::crawl_metrics::{MetricsBucket, OpRow, OpSummary}; + +/// Time-bucket granularity for trend series. A closed enum (not a free +/// string) so the `date_trunc` unit can never be attacker-controlled. +#[derive(Debug, Clone, Copy)] +pub enum Bucket { + Hour, + Day, +} + +impl Bucket { + pub fn as_str(self) -> &'static str { + match self { + Bucket::Hour => "hour", + Bucket::Day => "day", + } + } +} /// Operation kinds, kept as consts so call sites and the CHECK constraint /// don't drift from a free-typed literal. @@ -75,6 +92,34 @@ pub async fn summary( .await } +/// Bucketed throughput/success/duration series over an optional window. +/// Plain `GROUP BY date_trunc(...)` — empty intervals are absent and the +/// client fills the continuous axis. Uses `crawl_metrics_time_idx`. +pub async fn series( + pool: &PgPool, + bucket: Bucket, + since: Option>, +) -> sqlx::Result> { + sqlx::query_as::<_, MetricsBucket>( + r#" + SELECT + date_trunc($1, finished_at) AS t, + COUNT(*) AS n, + COUNT(*) FILTER (WHERE outcome = 'ok') AS ok, + COUNT(*) FILTER (WHERE outcome = 'failed') AS failed, + AVG(duration_ms)::float8 AS avg_ms + FROM crawl_metrics + WHERE ($2::timestamptz IS NULL OR finished_at >= $2) + GROUP BY t + ORDER BY t + "#, + ) + .bind(bucket.as_str()) + .bind(since) + .fetch_all(pool) + .await +} + /// Filters for [`list_ops`]. `None`/empty widens the scope. #[derive(Debug, Default, Clone)] pub struct OpFilter<'a> { diff --git a/backend/src/repo/page_analysis.rs b/backend/src/repo/page_analysis.rs index 4815c23..420e8b6 100644 --- a/backend/src/repo/page_analysis.rs +++ b/backend/src/repo/page_analysis.rs @@ -495,6 +495,39 @@ pub async fn analysis_metrics( Ok(AnalysisMetrics { n, ok, failed, avg_ms, by_model }) } +/// Bucketed analysis throughput/success/duration series over an optional +/// window, for the Analysis-tab trend charts. `ok`/`failed` follow the +/// `done`/`failed` statuses; mean duration is over completed pages only. +/// Empty intervals are absent (client fills the axis). The +/// `page_analysis_analyzed_at_idx` (migration 0030) backs the scan. +pub async fn analysis_series( + pool: &PgPool, + bucket: crate::repo::crawl_metrics::Bucket, + since: Option>, +) -> AppResult> { + let rows = sqlx::query_as::<_, crate::domain::crawl_metrics::MetricsBucket>( + r#" + SELECT + date_trunc($1, analyzed_at) AS t, + COUNT(*) AS n, + COUNT(*) FILTER (WHERE status = 'done') AS ok, + COUNT(*) FILTER (WHERE status = 'failed') AS failed, + AVG(duration_ms) FILTER (WHERE status = 'done')::float8 AS avg_ms + FROM page_analysis + WHERE status <> 'pending' + AND analyzed_at IS NOT NULL + AND ($2::timestamptz IS NULL OR analyzed_at >= $2) + GROUP BY t + ORDER BY t + "#, + ) + .bind(bucket.as_str()) + .bind(since) + .fetch_all(pool) + .await?; + Ok(rows) +} + /// Persist a completed analysis for a page, replacing any previous result. /// /// The model's free-form `kind` / `content_type` strings are mapped onto diff --git a/backend/tests/admin_metrics_series.rs b/backend/tests/admin_metrics_series.rs new file mode 100644 index 0000000..25aa504 --- /dev/null +++ b/backend/tests/admin_metrics_series.rs @@ -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()); +} diff --git a/frontend/package.json b/frontend/package.json index a31f43c..06d336f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.86.0", + "version": "0.87.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/admin/series.test.ts b/frontend/src/lib/admin/series.test.ts new file mode 100644 index 0000000..4e30159 --- /dev/null +++ b/frontend/src/lib/admin/series.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { buildSeries, unitForDays, truncUtc } from './series'; +import type { MetricsBucket } from '$lib/api/admin'; + +function bucket(t: string, n: number, ok: number, avg: number | null): MetricsBucket { + return { t, n, ok, failed: n - ok, avg_ms: avg }; +} + +describe('series helpers', () => { + it('picks hour vs day by window', () => { + expect(unitForDays(1)).toBe('hour'); + expect(unitForDays(2)).toBe('hour'); + expect(unitForDays(7)).toBe('day'); + expect(unitForDays(0)).toBe('day'); + }); + + it('truncUtc snaps to hour/day boundaries', () => { + const ms = Date.parse('2026-06-18T09:37:42Z'); + expect(new Date(truncUtc(ms, 'hour')).toISOString()).toBe('2026-06-18T09:00:00.000Z'); + expect(new Date(truncUtc(ms, 'day')).toISOString()).toBe('2026-06-18T00:00:00.000Z'); + }); + + it('fills empty buckets: throughput 0, success/duration null', () => { + // days=2 → hourly. Provide data only at 10:00; 11:00 must be a gap. + const now = Date.parse('2026-06-18T11:30:00Z'); + const buckets = [bucket('2026-06-18T10:00:00Z', 4, 3, 200)]; + const s = buildSeries(buckets, 2, now); + // Find the 10:00 and 11:00 slots. + const at10 = s.throughput.findIndex((p) => p.t === '2026-06-18T10:00:00.000Z'); + expect(at10).toBeGreaterThanOrEqual(0); + expect(s.throughput[at10].value).toBe(4); + expect(s.success[at10].value).toBe(75); // 3/4 + expect(s.duration[at10].value).toBe(200); + + const at11 = s.throughput.findIndex((p) => p.t === '2026-06-18T11:00:00.000Z'); + expect(s.throughput[at11].value).toBe(0); // no ops → zero throughput + expect(s.success[at11].value).toBeNull(); // no denominator → gap + expect(s.duration[at11].value).toBeNull(); + }); + + it('all-time spans only the data present', () => { + const buckets = [ + bucket('2026-06-10T00:00:00Z', 1, 1, 100), + bucket('2026-06-12T00:00:00Z', 1, 0, 100) + ]; + const s = buildSeries(buckets, 0); + // 10th, 11th (gap), 12th → 3 day slots. + expect(s.throughput.length).toBe(3); + expect(s.throughput[1].value).toBe(0); + }); +}); diff --git a/frontend/src/lib/admin/series.ts b/frontend/src/lib/admin/series.ts new file mode 100644 index 0000000..8f73f83 --- /dev/null +++ b/frontend/src/lib/admin/series.ts @@ -0,0 +1,75 @@ +// Turn the backend's sparse, ordered metric buckets into the three +// continuous point series the trend charts render. Empty intervals become +// explicit points so the chart shows real gaps rather than interpolating +// across missing time: throughput is 0 (no ops happened), while success-rate +// and duration are null (no denominator / no samples) and render as a gap. + +import type { MetricsBucket } from '$lib/api/admin'; + +export type SeriesPoint = { t: string; value: number | null }; + +export type TrendSeries = { + throughput: SeriesPoint[]; + success: SeriesPoint[]; + duration: SeriesPoint[]; +}; + +/** Bucket unit the backend uses for a given window (kept in lockstep with + * `resolve_bucket` on the server: short windows → hour, else day). */ +export function unitForDays(days: number): 'hour' | 'day' { + return days > 0 && days <= 2 ? 'hour' : 'day'; +} + +function stepMs(unit: 'hour' | 'day'): number { + return unit === 'hour' ? 3_600_000 : 86_400_000; +} + +/** Truncate a UTC timestamp to the start of its hour/day, matching Postgres + * `date_trunc` (which the bucket `t` values already are). */ +export function truncUtc(ms: number, unit: 'hour' | 'day'): number { + const d = new Date(ms); + if (unit === 'hour') { + d.setUTCMinutes(0, 0, 0); + } else { + d.setUTCHours(0, 0, 0, 0); + } + return d.getTime(); +} + +export function buildSeries( + buckets: MetricsBucket[], + days: number, + now: number = Date.now() +): TrendSeries { + const unit = unitForDays(days); + const step = stepMs(unit); + const byT = new Map(); + for (const b of buckets) byT.set(truncUtc(new Date(b.t).getTime(), unit), b); + + let startMs: number; + let endMs: number; + if (days > 0) { + endMs = truncUtc(now, unit); + startMs = truncUtc(now - days * 86_400_000, unit); + } else { + // All-time: span only the data we have. + if (buckets.length === 0) return { throughput: [], success: [], duration: [] }; + const times = buckets.map((b) => truncUtc(new Date(b.t).getTime(), unit)); + startMs = Math.min(...times); + endMs = Math.max(...times); + } + + const throughput: SeriesPoint[] = []; + const success: SeriesPoint[] = []; + const duration: SeriesPoint[] = []; + // Guard against an absurd point count (defensive; backend caps days at 365). + let guard = 0; + for (let t = startMs; t <= endMs && guard < 5000; t += step, guard++) { + const b = byT.get(t); + const iso = new Date(t).toISOString(); + throughput.push({ t: iso, value: b ? b.n : 0 }); + success.push({ t: iso, value: b && b.n > 0 ? (b.ok / b.n) * 100 : null }); + duration.push({ t: iso, value: b && b.avg_ms != null ? b.avg_ms : null }); + } + return { throughput, success, duration }; +} diff --git a/frontend/src/lib/api/admin.test.ts b/frontend/src/lib/api/admin.test.ts index 42b5e6e..0571508 100644 --- a/frontend/src/lib/api/admin.test.ts +++ b/frontend/src/lib/api/admin.test.ts @@ -45,7 +45,9 @@ import { getAnalysisMetrics, listAuditLog, getHealth, - updateHealthThresholds + updateHealthThresholds, + getCrawlerMetricsSeries, + getAnalysisMetricsSeries } from './admin'; function ok(body: unknown, status = 200): Response { @@ -532,6 +534,20 @@ describe('admin crawler api client', () => { } }; + it('getCrawlerMetricsSeries GETs the crawler series with days', async () => { + fetchSpy.mockResolvedValueOnce(ok({ buckets: [] })); + await getCrawlerMetricsSeries(7); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/admin\/crawler\/metrics\/series\?days=7$/); + }); + + it('getAnalysisMetricsSeries omits days=0 (all-time)', async () => { + fetchSpy.mockResolvedValueOnce(ok({ buckets: [] })); + await getAnalysisMetricsSeries(0); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/admin\/analysis\/metrics\/series$/); + }); + it('getHealth GETs /v1/admin/health', async () => { fetchSpy.mockResolvedValueOnce(ok(healthFixture)); const r = await getHealth(); diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts index 8d1d775..f39a668 100644 --- a/frontend/src/lib/api/admin.ts +++ b/frontend/src/lib/api/admin.ts @@ -1061,3 +1061,37 @@ export async function updateHealthThresholds(t: HealthThresholds): Promise 0) params.set('days', String(days)); + const qs = params.toString(); + return qs ? `?${qs}` : ''; +} + +export async function getCrawlerMetricsSeries( + days = 7, + init?: RequestInit +): Promise { + return request(`/v1/admin/crawler/metrics/series${seriesQs(days)}`, init); +} + +export async function getAnalysisMetricsSeries( + days = 7, + init?: RequestInit +): Promise { + return request(`/v1/admin/analysis/metrics/series${seriesQs(days)}`, init); +} diff --git a/frontend/src/lib/components/analysis/AnalysisMetricsPanel.svelte b/frontend/src/lib/components/analysis/AnalysisMetricsPanel.svelte index d11f2db..b76a64c 100644 --- a/frontend/src/lib/components/analysis/AnalysisMetricsPanel.svelte +++ b/frontend/src/lib/components/analysis/AnalysisMetricsPanel.svelte @@ -1,12 +1,20 @@ + +
+
+ {label} + {#if hasData} + peak {format(max)} · latest {latest != null ? format(latest) : '—'} + {/if} +
+ {#if !hasData} +
+ No data in this window +
+ {:else} + + {#each runs as run, ri (ri)} + + + {#if run.length === 1} + + {/if} + {/each} + +
+ {shortTime(points[0].t)} + {shortTime(points[points.length - 1].t)} +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/charts/TrendChart.svelte.test.ts b/frontend/src/lib/components/charts/TrendChart.svelte.test.ts new file mode 100644 index 0000000..4c37bdf --- /dev/null +++ b/frontend/src/lib/components/charts/TrendChart.svelte.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/svelte'; +import TrendChart from './TrendChart.svelte'; + +afterEach(() => cleanup()); + +describe('TrendChart', () => { + it('renders an empty state when there is no data', () => { + render(TrendChart, { + props: { + points: [ + { t: '2026-06-18T09:00:00Z', value: null }, + { t: '2026-06-18T10:00:00Z', value: null } + ], + label: 'Throughput' + } + }); + expect(screen.getByTestId('trend-empty')).toBeTruthy(); + }); + + it('draws a path and labels peak/latest when data is present', () => { + const { container } = render(TrendChart, { + props: { + points: [ + { t: '2026-06-18T09:00:00Z', value: 2 }, + { t: '2026-06-18T10:00:00Z', value: 6 }, + { t: '2026-06-18T11:00:00Z', value: 4 } + ], + label: 'Throughput' + } + }); + const line = container.querySelector('path.line') as SVGPathElement; + expect(line).toBeTruthy(); + expect(line.getAttribute('d')).toMatch(/^M /); + // Header summarizes peak + latest. + expect(screen.getByText(/peak 6 · latest 4/)).toBeTruthy(); + }); + + it('breaks the line across null gaps into separate paths', () => { + const { container } = render(TrendChart, { + props: { + points: [ + { t: '2026-06-18T09:00:00Z', value: 2 }, + { t: '2026-06-18T10:00:00Z', value: null }, + { t: '2026-06-18T11:00:00Z', value: 4 } + ], + label: 'Success' + } + }); + // Two runs → two line paths (and two area paths). + expect(container.querySelectorAll('path.line').length).toBe(2); + }); +}); diff --git a/frontend/src/lib/components/crawler/CrawlerMetricsPanel.svelte b/frontend/src/lib/components/crawler/CrawlerMetricsPanel.svelte index 46a2bba..e2d6958 100644 --- a/frontend/src/lib/components/crawler/CrawlerMetricsPanel.svelte +++ b/frontend/src/lib/components/crawler/CrawlerMetricsPanel.svelte @@ -2,8 +2,11 @@ import { onMount } from 'svelte'; import Pager from '$lib/components/Pager.svelte'; import { fmtDuration } from '$lib/format'; + import TrendChart from '$lib/components/charts/TrendChart.svelte'; + import { buildSeries, type TrendSeries } from '$lib/admin/series'; import { getCrawlerMetrics, + getCrawlerMetricsSeries, listCrawlerOps, type OpSummary, type OpRow, @@ -15,6 +18,8 @@ let days = $state(7); let summary = $state([]); let summaryLoading = $state(true); + let series = $state(null); + let seriesCtrl: AbortController | null = null; let rows = $state([]); let total = $state(0); @@ -65,6 +70,19 @@ } } + async function loadSeries() { + seriesCtrl?.abort(); + const ctrl = new AbortController(); + seriesCtrl = ctrl; + try { + const resp = await getCrawlerMetricsSeries(days, { signal: ctrl.signal }); + series = buildSeries(resp.buckets, days); + } catch (e) { + if ((e as Error)?.name === 'AbortError') return; + // A chart failure shouldn't blank the whole panel; leave prior series. + } + } + async function loadOps() { opsLoading = true; try { @@ -86,12 +104,14 @@ onMount(() => { loadSummary(); + loadSeries(); loadOps(); }); function onWindowChange() { page = 1; loadSummary(); + loadSeries(); loadOps(); } function onOpsFilter() { @@ -138,6 +158,24 @@ {/if} + {#if series} +
+ + `${v.toFixed(0)}%`} + accent="var(--success, #16a34a)" + /> + fmtDuration(v)} + accent="var(--warning, #d97706)" + /> +
+ {/if} +

Average durations by type

{#if summaryLoading}

Loading…

@@ -246,6 +284,13 @@ justify-content: flex-end; margin-bottom: var(--space-2); } + .charts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: var(--space-3) var(--space-4); + margin-bottom: var(--space-4); + max-width: 60rem; + } label { font-size: var(--font-sm); color: var(--text-muted);