Web dashboard step 6: jobs and stats

Job catalogue and daily-epub job run, the systemd job unit and polkit rule,
SystemdRunner/MockRunner, the Jobs pages with status and journal tail, the
stats_data refactor with a byte-identical CLI, the stats page and the
overview sparklines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
2026-09-03 18:01:21 +00:00
co-authored by Claude Fable 5.1
parent d36f203e4c
commit aa3de51d9c
21 changed files with 2958 additions and 112 deletions
+40 -1
View File
@@ -425,9 +425,10 @@ struct OverviewTemplate {
active_jobs: Vec<JobLine>,
finished_jobs: Vec<JobLine>,
config_warnings: Vec<String>,
sparklines: Vec<stats::Sparkline>,
}
/// `GET /dashboard` — the overview (§9.1). Sparklines arrive with step 6.
/// `GET /dashboard` — the overview (§9.1).
async fn overview(
State(state): State<AppState>,
auth: AuthSession,
@@ -443,6 +444,7 @@ async fn overview(
let (ratings, ratings_total) = ratings_this_week(db, now).await?;
let unrated = unrated_picks(db).await?;
let (active_jobs, finished_jobs) = jobs_summary(db, &config).await?;
let sparklines = overview_sparklines(db).await?;
let config_warnings = config
.check_report(state.config_path.as_deref())
.into_iter()
@@ -461,10 +463,41 @@ async fn overview(
active_jobs,
finished_jobs,
config_warnings,
sparklines,
})
.into_response())
}
/// How many finished non-dry runs the overview sparklines cover (§9.1).
const SPARKLINE_RUNS: i64 = 30;
/// Cost per run, selected per run and generation seconds over the last 30
/// non-dry runs (§9.1), drawn by `dashboard/_sparkline.html`.
async fn overview_sparklines(db: &Db) -> Result<Vec<stats::Sparkline>, WebError> {
let runs = crate::curate::telemetry::run_series(db, None, Some(SPARKLINE_RUNS))
.await
.map_err(db_err)?;
let labels = (
runs.first().map(|run| run.date.as_str()).unwrap_or(""),
runs.last().map(|run| run.date.as_str()).unwrap_or(""),
);
let costs: Vec<f64> = runs.iter().map(|run| run.cost_usd).collect();
let selected: Vec<f64> = runs.iter().map(|run| run.selected as f64).collect();
let seconds: Vec<f64> = runs
.iter()
.map(|run| run.duration_secs.unwrap_or(0) as f64)
.collect();
Ok(vec![
stats::Sparkline::line("Cost per run", &costs, labels, "runs", fmt_usd),
stats::Sparkline::line("Selected per run", &selected, labels, "runs", |n| {
format!("{n:.0}")
}),
stats::Sparkline::line("Generation time", &seconds, labels, "runs", |secs| {
RunReport::format_duration(secs as i64)
}),
])
}
async fn last_run_card(db: &Db, config: &Config) -> Result<Option<LastRunCard>, WebError> {
let Some(row) = sqlx::query(
"SELECT id, date, status, started_at, finished_at, entries_fetched, candidates,
@@ -1145,5 +1178,11 @@ pub(crate) mod tests {
assert!(body.contains("deepseek"), "{body}");
assert!(body.contains("voyage"), "{body}");
assert!(body.contains("Ratings this week"), "{body}");
// Step 6: three sparklines over the last 30 runs (two finished here).
assert!(body.contains("Cost per run"), "{body}");
assert!(body.contains("Generation time"), "{body}");
assert_eq!(body.matches("<polyline").count(), 3, "{body}");
assert!(body.contains("2 runs · max $0.11"), "{body}");
assert!(!body.contains("style=\""), "no inline styles under the CSP");
}
}