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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.86.0"
|
version = "0.87.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.86.0"
|
version = "0.87.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -44,6 +44,7 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.route("/admin/analysis/pages/:id", get(page_detail))
|
.route("/admin/analysis/pages/:id", get(page_detail))
|
||||||
.route("/admin/analysis/history", get(list_history))
|
.route("/admin/analysis/history", get(list_history))
|
||||||
.route("/admin/analysis/metrics", get(metrics))
|
.route("/admin/analysis/metrics", get(metrics))
|
||||||
|
.route("/admin/analysis/metrics/series", get(metrics_series))
|
||||||
.route("/admin/analysis/status/stream", get(stream_status))
|
.route("/admin/analysis/status/stream", get(stream_status))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +216,46 @@ pub(super) fn window_since(days: i64) -> Option<chrono::DateTime<chrono::Utc>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envelope for a trend series.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SeriesResponse<T> {
|
||||||
|
pub buckets: Vec<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<crate::repo::crawl_metrics::Bucket, AppError> {
|
||||||
|
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(
|
async fn metrics(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_admin: RequireAdmin,
|
_admin: RequireAdmin,
|
||||||
@@ -225,6 +266,17 @@ async fn metrics(
|
|||||||
Ok(Json(m))
|
Ok(Json(m))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn metrics_series(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_admin: RequireAdmin,
|
||||||
|
Query(params): Query<SeriesParams>,
|
||||||
|
) -> AppResult<Json<SeriesResponse<crate::domain::crawl_metrics::MetricsBucket>>> {
|
||||||
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ReenqueueBody {
|
pub struct ReenqueueBody {
|
||||||
/// Skip pages that already have a `done` analysis row. Defaults to
|
/// Skip pages that already have a `done` analysis row. Defaults to
|
||||||
|
|||||||
@@ -11,20 +11,31 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::auth::extractor::RequireAdmin;
|
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::error::AppResult;
|
||||||
use crate::repo;
|
use crate::repo;
|
||||||
use crate::repo::crawl_metrics::OpFilter;
|
use crate::repo::crawl_metrics::OpFilter;
|
||||||
|
|
||||||
use super::default_limit;
|
use super::default_limit;
|
||||||
// Reuse the analysis handler's window helper so both endpoints clamp `days`
|
// Reuse the analysis handler's window + bucket helpers so all the metrics
|
||||||
// identically.
|
// endpoints clamp `days` and resolve `bucket` identically.
|
||||||
use crate::api::admin::analysis::window_since;
|
use crate::api::admin::analysis::{resolve_bucket, window_since, SeriesParams, SeriesResponse};
|
||||||
|
|
||||||
pub(super) fn routes() -> Router<AppState> {
|
pub(super) fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/admin/crawler/metrics", get(summary))
|
.route("/admin/crawler/metrics", get(summary))
|
||||||
.route("/admin/crawler/metrics/ops", get(list_ops))
|
.route("/admin/crawler/metrics/ops", get(list_ops))
|
||||||
|
.route("/admin/crawler/metrics/series", get(series))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn series(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_admin: RequireAdmin,
|
||||||
|
Query(params): Query<SeriesParams>,
|
||||||
|
) -> AppResult<Json<SeriesResponse<MetricsBucket>>> {
|
||||||
|
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)]
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
|||||||
@@ -11,6 +11,20 @@ use serde::Serialize;
|
|||||||
use sqlx::FromRow;
|
use sqlx::FromRow;
|
||||||
use uuid::Uuid;
|
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<Utc>,
|
||||||
|
pub n: i64,
|
||||||
|
pub ok: i64,
|
||||||
|
pub failed: i64,
|
||||||
|
pub avg_ms: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-operation-type average roll-up over a time window.
|
/// Per-operation-type average roll-up over a time window.
|
||||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
pub struct OpSummary {
|
pub struct OpSummary {
|
||||||
|
|||||||
@@ -8,7 +8,24 @@ use chrono::{DateTime, Utc};
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
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
|
/// Operation kinds, kept as consts so call sites and the CHECK constraint
|
||||||
/// don't drift from a free-typed literal.
|
/// don't drift from a free-typed literal.
|
||||||
@@ -75,6 +92,34 @@ pub async fn summary(
|
|||||||
.await
|
.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<DateTime<Utc>>,
|
||||||
|
) -> sqlx::Result<Vec<MetricsBucket>> {
|
||||||
|
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.
|
/// Filters for [`list_ops`]. `None`/empty widens the scope.
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct OpFilter<'a> {
|
pub struct OpFilter<'a> {
|
||||||
|
|||||||
@@ -495,6 +495,39 @@ pub async fn analysis_metrics(
|
|||||||
Ok(AnalysisMetrics { n, ok, failed, avg_ms, by_model })
|
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<DateTime<Utc>>,
|
||||||
|
) -> AppResult<Vec<crate::domain::crawl_metrics::MetricsBucket>> {
|
||||||
|
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.
|
/// Persist a completed analysis for a page, replacing any previous result.
|
||||||
///
|
///
|
||||||
/// The model's free-form `kind` / `content_type` strings are mapped onto
|
/// The model's free-form `kind` / `content_type` strings are mapped onto
|
||||||
|
|||||||
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());
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.86.0",
|
"version": "0.87.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
51
frontend/src/lib/admin/series.test.ts
Normal file
51
frontend/src/lib/admin/series.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
75
frontend/src/lib/admin/series.ts
Normal file
75
frontend/src/lib/admin/series.ts
Normal file
@@ -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<number, MetricsBucket>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -45,7 +45,9 @@ import {
|
|||||||
getAnalysisMetrics,
|
getAnalysisMetrics,
|
||||||
listAuditLog,
|
listAuditLog,
|
||||||
getHealth,
|
getHealth,
|
||||||
updateHealthThresholds
|
updateHealthThresholds,
|
||||||
|
getCrawlerMetricsSeries,
|
||||||
|
getAnalysisMetricsSeries
|
||||||
} from './admin';
|
} from './admin';
|
||||||
|
|
||||||
function ok(body: unknown, status = 200): Response {
|
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 () => {
|
it('getHealth GETs /v1/admin/health', async () => {
|
||||||
fetchSpy.mockResolvedValueOnce(ok(healthFixture));
|
fetchSpy.mockResolvedValueOnce(ok(healthFixture));
|
||||||
const r = await getHealth();
|
const r = await getHealth();
|
||||||
|
|||||||
@@ -1061,3 +1061,37 @@ export async function updateHealthThresholds(t: HealthThresholds): Promise<Healt
|
|||||||
body: JSON.stringify(t)
|
body: JSON.stringify(t)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- metrics trend series (crawler + analysis charts) ----------------------
|
||||||
|
|
||||||
|
export type MetricsBucket = {
|
||||||
|
/** Bucket start (ISO timestamp). */
|
||||||
|
t: string;
|
||||||
|
n: number;
|
||||||
|
ok: number;
|
||||||
|
failed: number;
|
||||||
|
avg_ms: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MetricsSeries = { buckets: MetricsBucket[] };
|
||||||
|
|
||||||
|
function seriesQs(days: number): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (days > 0) params.set('days', String(days));
|
||||||
|
const qs = params.toString();
|
||||||
|
return qs ? `?${qs}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCrawlerMetricsSeries(
|
||||||
|
days = 7,
|
||||||
|
init?: RequestInit
|
||||||
|
): Promise<MetricsSeries> {
|
||||||
|
return request<MetricsSeries>(`/v1/admin/crawler/metrics/series${seriesQs(days)}`, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAnalysisMetricsSeries(
|
||||||
|
days = 7,
|
||||||
|
init?: RequestInit
|
||||||
|
): Promise<MetricsSeries> {
|
||||||
|
return request<MetricsSeries>(`/v1/admin/analysis/metrics/series${seriesQs(days)}`, init);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fmtDuration } from '$lib/format';
|
import { fmtDuration } from '$lib/format';
|
||||||
import { getAnalysisMetrics, type AnalysisMetrics } from '$lib/api/admin';
|
import TrendChart from '$lib/components/charts/TrendChart.svelte';
|
||||||
|
import { buildSeries, type TrendSeries } from '$lib/admin/series';
|
||||||
|
import {
|
||||||
|
getAnalysisMetrics,
|
||||||
|
getAnalysisMetricsSeries,
|
||||||
|
type AnalysisMetrics
|
||||||
|
} from '$lib/api/admin';
|
||||||
|
|
||||||
let days = $state(7);
|
let days = $state(7);
|
||||||
let metrics = $state<AnalysisMetrics | null>(null);
|
let metrics = $state<AnalysisMetrics | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
let series = $state<TrendSeries | null>(null);
|
||||||
|
let seriesCtrl: AbortController | null = null;
|
||||||
|
|
||||||
const successPct = $derived(
|
const successPct = $derived(
|
||||||
metrics && metrics.n > 0 ? Math.round((metrics.ok / metrics.n) * 100) : null
|
metrics && metrics.n > 0 ? Math.round((metrics.ok / metrics.n) * 100) : null
|
||||||
@@ -22,6 +30,20 @@
|
|||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
loadSeries();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSeries() {
|
||||||
|
seriesCtrl?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
seriesCtrl = ctrl;
|
||||||
|
try {
|
||||||
|
const resp = await getAnalysisMetricsSeries(days, { signal: ctrl.signal });
|
||||||
|
series = buildSeries(resp.buckets, days);
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error)?.name === 'AbortError') return;
|
||||||
|
// Chart failure is non-fatal — keep the tiles/table usable.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
@@ -45,6 +67,24 @@
|
|||||||
{:else if loading}
|
{:else if loading}
|
||||||
<p class="muted">Loading…</p>
|
<p class="muted">Loading…</p>
|
||||||
{:else if metrics}
|
{:else if metrics}
|
||||||
|
{#if series}
|
||||||
|
<div class="charts" data-testid="analysis-metrics-charts">
|
||||||
|
<TrendChart points={series.throughput} label="Pages analyzed (per bucket)" />
|
||||||
|
<TrendChart
|
||||||
|
points={series.success}
|
||||||
|
label="Success rate"
|
||||||
|
format={(v) => `${v.toFixed(0)}%`}
|
||||||
|
accent="var(--success, #16a34a)"
|
||||||
|
/>
|
||||||
|
<TrendChart
|
||||||
|
points={series.duration}
|
||||||
|
label="Avg duration"
|
||||||
|
format={(v) => fmtDuration(v)}
|
||||||
|
accent="var(--warning, #d97706)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="tiles">
|
<div class="tiles">
|
||||||
<div class="tile">
|
<div class="tile">
|
||||||
<span class="label">Pages analyzed</span>
|
<span class="label">Pages analyzed</span>
|
||||||
@@ -112,6 +152,13 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: var(--font-sm);
|
font-size: var(--font-sm);
|
||||||
}
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
.tiles {
|
.tiles {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||||
|
|||||||
168
frontend/src/lib/components/charts/TrendChart.svelte
Normal file
168
frontend/src/lib/components/charts/TrendChart.svelte
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// A small, dependency-free line+area sparkline for admin trend series.
|
||||||
|
// Null values render as gaps (a bucket with no data), so a missing point
|
||||||
|
// never draws a misleading line to zero.
|
||||||
|
type Point = { t: string; value: number | null };
|
||||||
|
|
||||||
|
let {
|
||||||
|
points,
|
||||||
|
label,
|
||||||
|
format = (v: number) => v.toFixed(0),
|
||||||
|
height = 72,
|
||||||
|
accent = 'var(--primary, #2563eb)'
|
||||||
|
}: {
|
||||||
|
points: Point[];
|
||||||
|
label: string;
|
||||||
|
format?: (v: number) => string;
|
||||||
|
height?: number;
|
||||||
|
accent?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Internal coordinate system; the SVG scales to its container width.
|
||||||
|
const W = 600;
|
||||||
|
const PAD = 6;
|
||||||
|
|
||||||
|
const values = $derived(
|
||||||
|
points.map((p) => p.value).filter((v): v is number => v != null)
|
||||||
|
);
|
||||||
|
const hasData = $derived(values.length > 0);
|
||||||
|
const max = $derived(hasData ? Math.max(...values) : 0);
|
||||||
|
const min = $derived(hasData ? Math.min(...values) : 0);
|
||||||
|
const latest = $derived.by(() => {
|
||||||
|
for (let i = points.length - 1; i >= 0; i--) {
|
||||||
|
if (points[i].value != null) return points[i].value as number;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function x(i: number): number {
|
||||||
|
if (points.length <= 1) return W / 2;
|
||||||
|
return PAD + (i / (points.length - 1)) * (W - 2 * PAD);
|
||||||
|
}
|
||||||
|
function y(v: number): number {
|
||||||
|
const range = max > min ? max - min : 1;
|
||||||
|
const norm = max > min ? (v - min) / range : 0.5;
|
||||||
|
const inner = height - 2 * PAD;
|
||||||
|
return PAD + (1 - norm) * inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Break the series into runs of consecutive non-null points so gaps stay
|
||||||
|
// gaps. Each run becomes one line path and one area path.
|
||||||
|
type Run = { i: number; v: number }[];
|
||||||
|
const runs = $derived.by<Run[]>(() => {
|
||||||
|
const out: Run[] = [];
|
||||||
|
let cur: Run = [];
|
||||||
|
points.forEach((p, i) => {
|
||||||
|
if (p.value == null) {
|
||||||
|
if (cur.length) out.push(cur);
|
||||||
|
cur = [];
|
||||||
|
} else {
|
||||||
|
cur.push({ i, v: p.value });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (cur.length) out.push(cur);
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
function linePath(run: Run): string {
|
||||||
|
return run.map((pt, k) => `${k === 0 ? 'M' : 'L'} ${x(pt.i).toFixed(1)} ${y(pt.v).toFixed(1)}`).join(' ');
|
||||||
|
}
|
||||||
|
function areaPath(run: Run): string {
|
||||||
|
if (run.length === 0) return '';
|
||||||
|
const base = height - PAD;
|
||||||
|
const top = run.map((pt) => `L ${x(pt.i).toFixed(1)} ${y(pt.v).toFixed(1)}`).join(' ');
|
||||||
|
const x0 = x(run[0].i).toFixed(1);
|
||||||
|
const xn = x(run[run.length - 1].i).toFixed(1);
|
||||||
|
return `M ${x0} ${base} ${top} L ${xn} ${base} Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortTime(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<figure class="trend" data-testid="trend-chart">
|
||||||
|
<figcaption>
|
||||||
|
<span class="lbl">{label}</span>
|
||||||
|
{#if hasData}
|
||||||
|
<span class="muted"
|
||||||
|
>peak {format(max)} · latest {latest != null ? format(latest) : '—'}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</figcaption>
|
||||||
|
{#if !hasData}
|
||||||
|
<div class="empty muted" style={`height:${height}px`} data-testid="trend-empty">
|
||||||
|
No data in this window
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${W} ${height}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
role="img"
|
||||||
|
aria-label={`${label}: ${values.length} points, latest ${latest != null ? format(latest) : 'none'}, peak ${format(max)}`}
|
||||||
|
>
|
||||||
|
{#each runs as run, ri (ri)}
|
||||||
|
<path class="area" d={areaPath(run)} fill={accent} />
|
||||||
|
<path class="line" d={linePath(run)} stroke={accent} />
|
||||||
|
{#if run.length === 1}
|
||||||
|
<circle cx={x(run[0].i)} cy={y(run[0].v)} r="3" fill={accent} />
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</svg>
|
||||||
|
<div class="xaxis muted">
|
||||||
|
<span>{shortTime(points[0].t)}</span>
|
||||||
|
<span>{shortTime(points[points.length - 1].t)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.trend {
|
||||||
|
margin: 0 0 var(--space-3) 0;
|
||||||
|
}
|
||||||
|
figcaption {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.lbl {
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.area {
|
||||||
|
opacity: 0.12;
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
.line {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
.xaxis {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
53
frontend/src/lib/components/charts/TrendChart.svelte.test.ts
Normal file
53
frontend/src/lib/components/charts/TrendChart.svelte.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,8 +2,11 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Pager from '$lib/components/Pager.svelte';
|
import Pager from '$lib/components/Pager.svelte';
|
||||||
import { fmtDuration } from '$lib/format';
|
import { fmtDuration } from '$lib/format';
|
||||||
|
import TrendChart from '$lib/components/charts/TrendChart.svelte';
|
||||||
|
import { buildSeries, type TrendSeries } from '$lib/admin/series';
|
||||||
import {
|
import {
|
||||||
getCrawlerMetrics,
|
getCrawlerMetrics,
|
||||||
|
getCrawlerMetricsSeries,
|
||||||
listCrawlerOps,
|
listCrawlerOps,
|
||||||
type OpSummary,
|
type OpSummary,
|
||||||
type OpRow,
|
type OpRow,
|
||||||
@@ -15,6 +18,8 @@
|
|||||||
let days = $state(7);
|
let days = $state(7);
|
||||||
let summary = $state<OpSummary[]>([]);
|
let summary = $state<OpSummary[]>([]);
|
||||||
let summaryLoading = $state(true);
|
let summaryLoading = $state(true);
|
||||||
|
let series = $state<TrendSeries | null>(null);
|
||||||
|
let seriesCtrl: AbortController | null = null;
|
||||||
|
|
||||||
let rows = $state<OpRow[]>([]);
|
let rows = $state<OpRow[]>([]);
|
||||||
let total = $state(0);
|
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() {
|
async function loadOps() {
|
||||||
opsLoading = true;
|
opsLoading = true;
|
||||||
try {
|
try {
|
||||||
@@ -86,12 +104,14 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadSummary();
|
loadSummary();
|
||||||
|
loadSeries();
|
||||||
loadOps();
|
loadOps();
|
||||||
});
|
});
|
||||||
|
|
||||||
function onWindowChange() {
|
function onWindowChange() {
|
||||||
page = 1;
|
page = 1;
|
||||||
loadSummary();
|
loadSummary();
|
||||||
|
loadSeries();
|
||||||
loadOps();
|
loadOps();
|
||||||
}
|
}
|
||||||
function onOpsFilter() {
|
function onOpsFilter() {
|
||||||
@@ -138,6 +158,24 @@
|
|||||||
<p class="error" role="alert">{error}</p>
|
<p class="error" role="alert">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if series}
|
||||||
|
<div class="charts" data-testid="crawler-metrics-charts">
|
||||||
|
<TrendChart points={series.throughput} label="Throughput (ops/bucket)" />
|
||||||
|
<TrendChart
|
||||||
|
points={series.success}
|
||||||
|
label="Success rate"
|
||||||
|
format={(v) => `${v.toFixed(0)}%`}
|
||||||
|
accent="var(--success, #16a34a)"
|
||||||
|
/>
|
||||||
|
<TrendChart
|
||||||
|
points={series.duration}
|
||||||
|
label="Avg duration"
|
||||||
|
format={(v) => fmtDuration(v)}
|
||||||
|
accent="var(--warning, #d97706)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<h2>Average durations by type</h2>
|
<h2>Average durations by type</h2>
|
||||||
{#if summaryLoading}
|
{#if summaryLoading}
|
||||||
<p class="muted">Loading…</p>
|
<p class="muted">Loading…</p>
|
||||||
@@ -246,6 +284,13 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-bottom: var(--space-2);
|
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 {
|
label {
|
||||||
font-size: var(--font-sm);
|
font-size: var(--font-sm);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
Reference in New Issue
Block a user