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:
@@ -44,6 +44,7 @@ pub fn routes() -> Router<AppState> {
|
||||
.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<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(
|
||||
State(state): State<AppState>,
|
||||
_admin: RequireAdmin,
|
||||
@@ -225,6 +266,17 @@ async fn metrics(
|
||||
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)]
|
||||
pub struct ReenqueueBody {
|
||||
/// Skip pages that already have a `done` analysis row. Defaults to
|
||||
|
||||
Reference in New Issue
Block a user