Merge branch 'step6' into web-dashboard

# Conflicts:
#	src/web/static/app.css
This commit is contained in:
2026-09-03 18:01:36 +00:00
21 changed files with 2962 additions and 112 deletions
+629 -1
View File
@@ -1,10 +1,638 @@
//! Dashboard: jobs pages. Filled in by web dashboard plan step 6.
//! Dashboard: the Jobs pages (`/dashboard/jobs`, dashboard plan §14.4).
//!
//! The catalogue as cards, the `jobs` table, `POST /dashboard/jobs/{name}`
//! (insert `requested`, ask the runner to start the unit) and the job page
//! with the live unit status and the journal tail. The server never runs the
//! pipeline in-process: `job run` inside the unit does the work (§14.2).
use askama::Template;
use axum::Router;
use axum::body::Bytes;
use axum::extract::{Extension, Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::get;
use axum_login::tower_sessions::Session;
use jiff::Timestamp;
use crate::config::Config;
use crate::jobs::{self, EXITED_BEFORE_START, Job, JobRow};
use crate::server::AppState;
use crate::web::session::{AuthSession, Viewer};
use crate::web::{Flash, Html, Page, UnitStatus, WebError, take_flash};
use super::{db_err, duration_between, fmt_duration, fmt_stored_time};
/// Routes contributed by this page group (merged by `dashboard::router`).
pub fn routes() -> Router<AppState> {
Router::new()
.route("/dashboard/jobs", get(index))
.route("/dashboard/jobs/{key}", get(show).post(start))
}
/// How many rows the jobs table shows.
const TABLE_ROWS: i64 = 200;
/// A `requested` row whose unit already stopped with a failure this long after
/// the request is marked failed (§14.4).
const EXIT_GRACE_SECS: i64 = 30;
#[derive(Debug, Clone)]
struct JobCard {
name: String,
description: &'static str,
dangerous: bool,
/// The `generate` card carries the date input for `generate-YYYY-MM-DD`.
dated: bool,
lock: Option<&'static str>,
}
#[derive(Debug, Clone)]
struct JobLine {
id: i64,
name: String,
requested_by: String,
requested: String,
started: String,
finished: String,
duration: String,
status: String,
message: Option<String>,
run_id: Option<i64>,
}
#[derive(Template)]
#[template(path = "dashboard/jobs.html")]
struct JobsTemplate {
page: Page,
jobs_enabled: bool,
cards: Vec<JobCard>,
jobs: Vec<JobLine>,
today: String,
}
#[derive(Template)]
#[template(path = "dashboard/job.html")]
struct JobTemplate {
page: Page,
job: JobLine,
unit: String,
description: &'static str,
refresh: bool,
status: Option<UnitStatus>,
status_error: Option<String>,
log: String,
log_error: Option<String>,
log_lines: u32,
}
fn cards() -> Vec<JobCard> {
Job::CATALOGUE
.iter()
.map(|job| JobCard {
name: job.name(),
description: job.description(),
dangerous: job.dangerous(),
dated: matches!(job, Job::Generate { .. }),
lock: job.takes_lock(),
})
.collect()
}
fn job_line(row: &JobRow, config: &Config) -> JobLine {
JobLine {
id: row.id,
name: row.name.clone(),
requested_by: match (&row.requested_by_name, row.requested_by) {
(Some(name), _) => name.clone(),
(None, Some(id)) => format!("user {id}"),
(None, None) => "by hand".into(),
},
requested: fmt_stored_time(Some(&row.requested_at), config),
started: fmt_stored_time(row.started_at.as_deref(), config),
finished: fmt_stored_time(row.finished_at.as_deref(), config),
duration: fmt_duration(
row.started_at
.as_deref()
.and_then(|started| duration_between(started, row.finished_at.as_deref())),
),
status: row.status.clone(),
message: row.message.clone(),
run_id: row.run_id,
}
}
async fn jobs_template(
state: &AppState,
viewer: Option<Viewer>,
flash: Option<Flash>,
) -> Result<JobsTemplate, WebError> {
let config = state.config();
let rows = jobs::list(&state.db, TABLE_ROWS).await.map_err(db_err)?;
let mut page = Page::new("Jobs", viewer, "dashboard");
page.flash = flash;
let today = config
.tz()
.map(|tz| Timestamp::now().to_zoned(tz).date().to_string())
.unwrap_or_else(|_| {
Timestamp::now()
.to_zoned(jiff::tz::TimeZone::UTC)
.date()
.to_string()
});
Ok(JobsTemplate {
page,
jobs_enabled: config.server.jobs_enabled,
cards: cards(),
jobs: rows.iter().map(|row| job_line(row, &config)).collect(),
today,
})
}
/// `GET /dashboard/jobs`: the catalogue cards and the jobs table.
async fn index(
State(state): State<AppState>,
auth: AuthSession,
Extension(session): Extension<Session>,
) -> Result<Response, WebError> {
let viewer = auth.user().await.map(Viewer::from);
let flash = take_flash(&session).await?;
Ok(Html(jobs_template(&state, viewer, flash).await?).into_response())
}
/// The `date` field of the start form, when present and non-empty.
fn form_date(body: &[u8]) -> Option<String> {
url::form_urlencoded::parse(body)
.find(|(key, _)| key == "date")
.map(|(_, value)| value.trim().to_string())
.filter(|value| !value.is_empty())
}
async fn set_flash(session: &Session, kind: &str, text: String) -> Result<(), WebError> {
session
.insert(
"flash",
Flash {
kind: kind.into(),
text,
},
)
.await
.map_err(|error| WebError::Internal(error.into()))
}
/// `POST /dashboard/jobs/{name}` (admin, origin-checked): parse the name,
/// refuse a duplicate `requested`/`running` unit (409), insert `requested`,
/// start the unit; a failed start marks the row `failed`.
async fn start(
State(state): State<AppState>,
auth: AuthSession,
Extension(session): Extension<Session>,
Path(name): Path<String>,
body: Bytes,
) -> Result<Response, WebError> {
let viewer = auth
.user()
.await
.map(Viewer::from)
.ok_or_else(|| WebError::Unauthenticated {
next: "/dashboard/jobs".into(),
})?;
let mut job = Job::parse(&name).ok_or(WebError::NotFound)?;
if let (Job::Generate { date: None }, Some(date)) = (job, form_date(&body)) {
let parsed = date
.parse::<jiff::civil::Date>()
.map_err(|_| WebError::BadRequest(format!("invalid date {date:?}")))?;
job = Job::Generate { date: Some(parsed) };
}
let config = state.config();
if !config.server.jobs_enabled {
set_flash(
&session,
"error",
"Jobs are disabled on this server (server.jobs_enabled = false).".into(),
)
.await?;
return Ok(Redirect::to("/dashboard/jobs").into_response());
}
let unit = job.unit();
if let Some(active) = jobs::active_for_unit(&state.db, &unit)
.await
.map_err(db_err)?
{
let flash = Flash {
kind: "error".into(),
text: format!(
"{} is already requested or running (job {active}).",
job.name()
),
};
let template = jobs_template(&state, Some(viewer), Some(flash)).await?;
return Ok((StatusCode::CONFLICT, Html(template)).into_response());
}
// Pick up a hand-edited config.toml before the unit starts (§4.2); a file
// that no longer loads keeps the previous config live and is only logged.
if let Err(error) = crate::web::WebState::reload_if_changed(&state) {
tracing::warn!(%error, "config.toml on disk does not load; keeping the previous config");
}
let now = Timestamp::now();
let id = jobs::insert_requested(&state.db, &job, Some(viewer.id), now)
.await
.map_err(db_err)?;
match state.web.jobs.start(&unit).await {
Ok(()) => {
tracing::info!(user = %viewer.username, job = %job.name(), job_id = id, %unit, "job requested");
set_flash(&session, "success", format!("Started {}.", job.name())).await?;
}
Err(error) => {
tracing::warn!(user = %viewer.username, job = %job.name(), job_id = id, %unit, %error, "job start failed");
jobs::finish(
&state.db,
id,
jobs::Outcome::Failed,
&format!("could not start {unit}: {error}"),
None,
Timestamp::now(),
)
.await
.map_err(db_err)?;
set_flash(
&session,
"error",
format!("Could not start {}: {error}", job.name()),
)
.await?;
}
}
Ok(Redirect::to(&format!("/dashboard/jobs/{id}")).into_response())
}
/// `GET /dashboard/jobs/{id}`: the row, the live unit status and the journal
/// tail; applies the 30-second "unit exited before the job started" rule.
async fn show(
State(state): State<AppState>,
auth: AuthSession,
Extension(session): Extension<Session>,
Path(key): Path<String>,
) -> Result<Response, WebError> {
let viewer = auth.user().await.map(Viewer::from);
let id: i64 = key.parse().map_err(|_| WebError::NotFound)?;
let config = state.config();
let db = &state.db;
let mut row = jobs::get(db, id)
.await
.map_err(db_err)?
.ok_or(WebError::NotFound)?;
let job = Job::parse(&row.name);
let (status, status_error) = match state.web.jobs.status(&row.unit).await {
Ok(status) => (Some(status), None),
Err(error) => (None, Some(error)),
};
if let Some(status) = &status
&& row.status == "requested"
&& status.exited_unsuccessfully()
&& requested_secs_ago(&row, Timestamp::now()) >= EXIT_GRACE_SECS
{
jobs::finish(
db,
id,
jobs::Outcome::Failed,
EXITED_BEFORE_START,
None,
Timestamp::now(),
)
.await
.map_err(db_err)?;
row = jobs::get(db, id)
.await
.map_err(db_err)?
.ok_or(WebError::NotFound)?;
}
let lines = config.server.journal_lines;
let (log, log_error) = match state.web.jobs.log(&row.unit, lines as usize).await {
Ok(log) => (log, None),
Err(error) => (String::new(), Some(error)),
};
let mut page = Page::new(format!("Job {id} · {}", row.name), viewer, "dashboard");
page.flash = take_flash(&session).await?;
Ok(Html(JobTemplate {
page,
refresh: row.is_active(),
unit: row.unit.clone(),
description: job.map(|job| job.description()).unwrap_or(""),
job: job_line(&row, &config),
status,
status_error,
log,
log_error,
log_lines: lines,
})
.into_response())
}
fn requested_secs_ago(row: &JobRow, now: Timestamp) -> i64 {
row.requested_at
.parse::<Timestamp>()
.map(|requested| now.as_second() - requested.as_second())
.unwrap_or(i64::MAX)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Method, Request, header};
use tower::ServiceExt;
use super::*;
use crate::db::Db;
use crate::server::router;
use crate::web::MockRunner;
use crate::web::dashboard::tests::{assert_admin_only, get, login_cookie, response_text};
async fn app_with_runner(
config: Config,
runner: Arc<dyn crate::web::JobRunner>,
) -> (tempfile::TempDir, Db, axum::Router) {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
.await
.unwrap();
crate::web::users::add(&db, "reader", "correct horse battery", false)
.await
.unwrap();
crate::web::users::add(&db, "admin", "correct horse battery", true)
.await
.unwrap();
let app = router(AppState::with_jobs(db.clone(), config, None, runner));
(dir, db, app)
}
async fn post(app: &axum::Router, uri: &str, body: &str, cookie: &str) -> Response {
app.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(uri)
.header(header::COOKIE, cookie)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header("sec-fetch-site", "same-origin")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap()
}
fn location(response: &Response) -> String {
response
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap()
.to_string()
}
#[test]
fn form_date_reads_the_optional_field() {
assert_eq!(form_date(b"date=2026-09-03"), Some("2026-09-03".into()));
assert_eq!(form_date(b"date=+2026-09-03+"), Some("2026-09-03".into()));
assert_eq!(form_date(b"date="), None);
assert_eq!(form_date(b""), None);
assert_eq!(form_date(b"other=1"), None);
}
#[tokio::test]
async fn jobs_page_lists_the_catalogue_and_the_table() {
let runner = Arc::new(MockRunner::default());
let (_dir, db, app) = app_with_runner(Config::default(), runner).await;
let now: Timestamp = "2026-09-03T10:00:00Z".parse().unwrap();
let id = jobs::insert_requested(&db, &Job::FeaturesPrune, None, now)
.await
.unwrap();
jobs::finish(&db, id, jobs::Outcome::Ok, "pruned 0 embeddings", None, now)
.await
.unwrap();
let body = assert_admin_only(&app, "/dashboard/jobs").await;
for job in Job::CATALOGUE {
assert!(
body.contains(&format!("action=\"/dashboard/jobs/{}\"", job.name())),
"{}: {body}",
job.name()
);
let escaped_description = job.description().replace('\'', "&#39;");
assert!(body.contains(&escaped_description), "{}", job.name());
}
assert!(body.contains("data-confirm"), "generate needs confirmation");
assert!(body.contains("type=\"date\""), "the dated generate input");
assert!(body.contains("pruned 0 embeddings"), "{body}");
assert!(body.contains(&format!("/dashboard/jobs/{id}")), "{body}");
assert!(!body.contains("style=\""), "no inline styles under the CSP");
}
#[tokio::test]
async fn starting_a_job_inserts_a_row_and_starts_the_unit() {
let runner = Arc::new(MockRunner::default());
let (_dir, db, app) = app_with_runner(
Config::default(),
runner.clone() as Arc<dyn crate::web::JobRunner>,
)
.await;
let admin = login_cookie(&app, "admin", "correct horse battery").await;
let response = post(&app, "/dashboard/jobs/features-prune", "", &admin).await;
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let target = location(&response);
assert!(target.starts_with("/dashboard/jobs/"), "{target}");
let row = jobs::list(&db, 10).await.unwrap().remove(0);
assert_eq!(target, format!("/dashboard/jobs/{}", row.id));
assert_eq!(row.name, "features-prune");
assert_eq!(row.unit, "daily-epub-job@features-prune.service");
assert_eq!(row.status, "requested");
assert_eq!(row.requested_by_name.as_deref(), Some("admin"));
assert_eq!(
runner.calls(),
vec!["start daily-epub-job@features-prune.service".to_string()]
);
// The job page shows the row, the unit and refreshes while active.
let page = get(&app, &target, Some(&admin)).await;
assert_eq!(page.status(), StatusCode::OK);
let body = response_text(page).await;
assert!(
body.contains("daily-epub-job@features-prune.service"),
"{body}"
);
assert!(body.contains("data-refresh=\"5\""), "{body}");
assert!(body.contains("badge requested"), "{body}");
assert!(body.contains("Started features-prune."), "the flash");
// Simulate the unit claiming the request, then prove a running
// duplicate is refused with 409 and no second row.
assert_eq!(
jobs::claim(&db, &Job::FeaturesPrune, Timestamp::now())
.await
.unwrap(),
row.id
);
let duplicate = post(&app, "/dashboard/jobs/features-prune", "", &admin).await;
assert_eq!(duplicate.status(), StatusCode::CONFLICT);
let body = response_text(duplicate).await;
assert!(body.contains("already requested or running"), "{body}");
assert_eq!(jobs::list(&db, 10).await.unwrap().len(), 1);
assert_eq!(
runner
.calls()
.iter()
.filter(|call| call.starts_with("start "))
.count(),
1,
"no second start"
);
// The dated generate form starts the dated unit.
let dated = post(&app, "/dashboard/jobs/generate", "date=2026-09-03", &admin).await;
assert_eq!(dated.status(), StatusCode::SEE_OTHER);
let row = jobs::list(&db, 10).await.unwrap().remove(0);
assert_eq!(row.name, "generate-2026-09-03");
assert_eq!(
runner.calls().last().unwrap(),
"start daily-epub-job@generate-2026-09-03.service"
);
let bad_date = post(&app, "/dashboard/jobs/generate", "date=soon", &admin).await;
assert_eq!(bad_date.status(), StatusCode::BAD_REQUEST);
// Unknown names never reach the runner.
for name in ["../x", "Generate", "backup"] {
let unknown = post(&app, &format!("/dashboard/jobs/{name}"), "", &admin).await;
assert_eq!(unknown.status(), StatusCode::NOT_FOUND, "{name}");
}
assert_eq!(
runner
.calls()
.iter()
.filter(|call| call.starts_with("start "))
.count(),
2
);
// Readers cannot start jobs.
let reader = login_cookie(&app, "reader", "correct horse battery").await;
let forbidden = post(&app, "/dashboard/jobs/features-prune", "", &reader).await;
assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn a_failed_start_marks_the_row_failed() {
let runner = Arc::new(MockRunner::default());
runner.fail_starts(Some("polkit: access denied"));
let (_dir, db, app) = app_with_runner(
Config::default(),
runner.clone() as Arc<dyn crate::web::JobRunner>,
)
.await;
let admin = login_cookie(&app, "admin", "correct horse battery").await;
let response = post(&app, "/dashboard/jobs/dry-run", "", &admin).await;
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let row = jobs::list(&db, 10).await.unwrap().remove(0);
assert_eq!(row.status, "failed");
assert!(row.finished_at.is_some());
assert_eq!(
row.message.as_deref(),
Some("could not start daily-epub-job@dry-run.service: polkit: access denied")
);
let body = response_text(get(&app, &location(&response), Some(&admin)).await).await;
assert!(body.contains("polkit: access denied"), "{body}");
assert!(
!body.contains("data-refresh"),
"a finished job does not refresh"
);
// The unit is free again.
runner.fail_starts(None);
let again = post(&app, "/dashboard/jobs/dry-run", "", &admin).await;
assert_eq!(again.status(), StatusCode::SEE_OTHER);
assert_eq!(jobs::list(&db, 10).await.unwrap()[0].status, "requested");
}
#[tokio::test]
async fn job_page_marks_a_unit_that_exited_before_the_job_started() {
let runner = Arc::new(MockRunner::default());
let unit = Job::ProfileRebuild.unit();
runner.set_status(
&unit,
UnitStatus {
active_state: "inactive".into(),
sub_state: "dead".into(),
result: "exit-code".into(),
exit_status: Some(1),
started: Some("Thu 2026-09-03 05:30:01 EDT".into()),
exited: Some("Thu 2026-09-03 05:30:02 EDT".into()),
},
);
runner.set_log("Sep 03 05:30:01 daily-epub[1]: Error: profile rebuild is already running");
let (_dir, db, app) = app_with_runner(
Config::default(),
runner.clone() as Arc<dyn crate::web::JobRunner>,
)
.await;
let admin = login_cookie(&app, "admin", "correct horse battery").await;
// Requested a minute ago and never claimed.
let requested_at = Timestamp::now() - jiff::Span::new().seconds(60);
let id = jobs::insert_requested(&db, &Job::ProfileRebuild, None, requested_at)
.await
.unwrap();
let body =
response_text(get(&app, &format!("/dashboard/jobs/{id}"), Some(&admin)).await).await;
assert!(body.contains(EXITED_BEFORE_START), "{body}");
assert!(body.contains("badge failed"), "{body}");
assert!(body.contains("exit-code"), "the live unit status");
assert!(body.contains("is already running"), "the journal tail");
assert_eq!(jobs::get(&db, id).await.unwrap().unwrap().status, "failed");
assert!(runner.calls().contains(&format!(
"log {unit} {}",
Config::default().server.journal_lines
)));
// A fresh request whose unit has not run yet is left alone.
runner.set_status(
&Job::FeaturesPrune.unit(),
UnitStatus {
active_state: "inactive".into(),
sub_state: "dead".into(),
result: "success".into(),
..UnitStatus::default()
},
);
let fresh = jobs::insert_requested(&db, &Job::FeaturesPrune, None, Timestamp::now())
.await
.unwrap();
let body =
response_text(get(&app, &format!("/dashboard/jobs/{fresh}"), Some(&admin)).await).await;
assert!(body.contains("badge requested"), "{body}");
assert_eq!(
jobs::get(&db, fresh).await.unwrap().unwrap().status,
"requested"
);
let missing = get(&app, "/dashboard/jobs/999999", Some(&admin)).await;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn disabled_jobs_refuse_starts_without_a_row() {
let mut config = Config::default();
config.server.jobs_enabled = false;
let (_dir, db, app) = app_with_runner(config, Arc::new(crate::web::DisabledRunner)).await;
let admin = login_cookie(&app, "admin", "correct horse battery").await;
let body = response_text(get(&app, "/dashboard/jobs", Some(&admin)).await).await;
assert!(body.contains("Jobs are disabled"), "{body}");
let response = post(&app, "/dashboard/jobs/features-prune", "", &admin).await;
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(location(&response), "/dashboard/jobs");
assert!(jobs::list(&db, 10).await.unwrap().is_empty());
}
}
+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");
}
}
+639 -2
View File
@@ -1,10 +1,647 @@
//! Dashboard: stats pages. Filled in by web dashboard plan step 6.
//! Dashboard: the stats page (`/dashboard/stats`, dashboard plan §12) and the
//! server-rendered SVG sparkline the overview shares (§9.1).
//!
//! The figures come from `telemetry::stats_data`, the same source as
//! `daily-epub stats`; the page shows them as tables plus three sparklines
//! (cost per day stacked per provider, selected per issue, ratings per week by
//! label) and the retriever yield with its up/down ratio.
use std::collections::BTreeMap;
use std::fmt::Write as _;
use askama::Template;
use axum::Router;
use axum::extract::{Extension, Query, State};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum_login::tower_sessions::Session;
use jiff::Timestamp;
use serde::Deserialize;
use crate::curate::telemetry::{self, StatsData};
use crate::report::RunReport;
use crate::server::AppState;
use crate::web::session::{AuthSession, Viewer};
use crate::web::{Html, Page, WebError, take_flash};
use super::fmt_usd;
/// Routes contributed by this page group (merged by `dashboard::router`).
pub fn routes() -> Router<AppState> {
Router::new()
Router::new().route("/dashboard/stats", get(stats))
}
/// The windows the page offers; anything else falls back to the first.
pub const WINDOWS: [i64; 3] = [14, 30, 90];
// ---------------------------------------------------------------------------
// Sparklines (§9.1, §12)
// ---------------------------------------------------------------------------
const SPARK_WIDTH: f64 = 240.0;
const SPARK_HEIGHT: f64 = 48.0;
const SPARK_PAD: f64 = 2.0;
/// Fill classes cycle through this many series colours (`.spark .s0` …).
pub const SERIES_CLASSES: usize = 6;
/// One stacked-bar segment: pre-formatted SVG coordinates, a series index for
/// its fill class and a `<title>` label.
#[derive(Debug, Clone, PartialEq)]
pub struct Bar {
pub x: String,
pub y: String,
pub w: String,
pub h: String,
pub series: usize,
pub label: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Legend {
pub name: String,
pub series: usize,
}
/// A small SVG chart rendered by `dashboard/_sparkline.html` from numbers
/// only — presentation attributes and classes, no inline styles (the CSP is
/// `style-src 'self'`) and no `|safe`.
#[derive(Debug, Clone, PartialEq)]
pub struct Sparkline {
pub title: String,
pub caption: String,
pub width: u32,
pub height: u32,
/// `<polyline points>` of a line chart; empty for a bar chart.
pub points: String,
pub bars: Vec<Bar>,
pub legend: Vec<Legend>,
pub first_label: String,
pub last_label: String,
pub empty: bool,
}
fn coord(value: f64) -> String {
let text = format!("{value:.1}");
text.strip_suffix(".0").map(str::to_string).unwrap_or(text)
}
impl Sparkline {
fn blank(title: &str) -> Self {
Self {
title: title.to_string(),
caption: String::new(),
width: SPARK_WIDTH as u32,
height: SPARK_HEIGHT as u32,
points: String::new(),
bars: Vec::new(),
legend: Vec::new(),
first_label: String::new(),
last_label: String::new(),
empty: true,
}
}
/// A line over `values` (baseline 0), labelled with the first and last
/// point's label; the caption reports the count and the maximum.
pub fn line(
title: &str,
values: &[f64],
labels: (&str, &str),
unit: &str,
format_max: impl Fn(f64) -> String,
) -> Self {
let mut spark = Self::blank(title);
if values.is_empty() {
return spark;
}
let max = values.iter().copied().fold(0.0_f64, f64::max);
let inner_w = SPARK_WIDTH - 2.0 * SPARK_PAD;
let inner_h = SPARK_HEIGHT - 2.0 * SPARK_PAD;
let step = if values.len() > 1 {
inner_w / (values.len() - 1) as f64
} else {
0.0
};
let mut points = String::new();
for (index, value) in values.iter().enumerate() {
let x = if values.len() > 1 {
SPARK_PAD + step * index as f64
} else {
SPARK_WIDTH / 2.0
};
let y = if max > 0.0 {
SPARK_HEIGHT - SPARK_PAD - value.max(0.0) / max * inner_h
} else {
SPARK_HEIGHT - SPARK_PAD
};
if !points.is_empty() {
points.push(' ');
}
let _ = write!(points, "{},{}", coord(x), coord(y));
}
spark.points = points;
spark.caption = format!("{} {unit} · max {}", values.len(), format_max(max));
spark.first_label = labels.0.to_string();
spark.last_label = labels.1.to_string();
spark.empty = false;
spark
}
/// Stacked bars: one column per `(label, segments)` where each segment is
/// `(series name, value)`; series get fill classes in first-seen order.
pub fn stacked(
title: &str,
columns: &[(String, Vec<(String, f64)>)],
unit: &str,
format_max: impl Fn(f64) -> String,
) -> Self {
let mut spark = Self::blank(title);
if columns.is_empty() {
return spark;
}
let mut series: Vec<String> = Vec::new();
for (_, segments) in columns {
for (name, _) in segments {
if !series.contains(name) {
series.push(name.clone());
}
}
}
let max = columns
.iter()
.map(|(_, segments)| segments.iter().map(|(_, v)| v.max(0.0)).sum::<f64>())
.fold(0.0_f64, f64::max);
let inner_w = SPARK_WIDTH - 2.0 * SPARK_PAD;
let inner_h = SPARK_HEIGHT - 2.0 * SPARK_PAD;
let slot = inner_w / columns.len() as f64;
let gap = if slot > 3.0 { 1.0 } else { 0.0 };
let bar_w = (slot - gap).max(0.5);
let mut bars = Vec::new();
for (index, (label, segments)) in columns.iter().enumerate() {
let x = SPARK_PAD + slot * index as f64;
let mut top = SPARK_HEIGHT - SPARK_PAD;
for (name, value) in segments {
let value = value.max(0.0);
if value <= 0.0 || max <= 0.0 {
continue;
}
let h = value / max * inner_h;
top -= h;
let series_index = series.iter().position(|s| s == name).unwrap_or(0);
bars.push(Bar {
x: coord(x),
y: coord(top),
w: coord(bar_w),
h: coord(h),
series: series_index % SERIES_CLASSES,
label: format!("{label} · {name}: {}", format_max(value)),
});
}
}
spark.bars = bars;
spark.legend = series
.iter()
.enumerate()
.map(|(index, name)| Legend {
name: name.clone(),
series: index % SERIES_CLASSES,
})
.collect();
spark.caption = format!("{} {unit} · max {}", columns.len(), format_max(max));
spark.first_label = columns[0].0.clone();
spark.last_label = columns[columns.len() - 1].0.clone();
spark.empty = false;
spark
}
}
// ---------------------------------------------------------------------------
// Stats page (§12)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct StatsQuery {
days: Option<i64>,
}
#[derive(Debug, Clone)]
struct KeyValue {
key: String,
value: String,
}
#[derive(Debug, Clone)]
struct RetrieverLine {
retriever: String,
rated: i64,
up: i64,
down: i64,
ratio: String,
}
#[derive(Debug, Clone)]
struct CostRow {
date: String,
cells: Vec<String>,
total: String,
}
#[derive(Debug, Clone)]
struct RunLine {
run_id: i64,
date: String,
status: String,
cost: String,
selected: i64,
duration: String,
}
#[derive(Template)]
#[template(path = "dashboard/stats.html")]
struct StatsTemplate {
page: Page,
days: i64,
windows: Vec<i64>,
since_date: String,
today: String,
summary: Vec<KeyValue>,
ratings: Vec<KeyValue>,
retrievers: Vec<RetrieverLine>,
exploration: Vec<KeyValue>,
providers: Vec<String>,
cost_rows: Vec<CostRow>,
cost_per_day: Vec<KeyValue>,
runs: Vec<RunLine>,
sparklines: Vec<Sparkline>,
text: String,
}
/// `?days=` clamped to one of [`WINDOWS`].
pub fn window(days: Option<i64>) -> i64 {
days.filter(|days| WINDOWS.contains(days))
.unwrap_or(WINDOWS[0])
}
/// `GET /dashboard/stats?days=14|30|90`.
async fn stats(
State(state): State<AppState>,
auth: AuthSession,
Extension(session): Extension<Session>,
Query(query): Query<StatsQuery>,
) -> Result<Response, WebError> {
let viewer = auth.user().await.map(Viewer::from);
let days = window(query.days);
let now = Timestamp::now();
let data = telemetry::stats_data(&state.db, days, now)
.await
.map_err(WebError::Internal)?;
let text = telemetry::render_stats_text(&data);
let summary = vec![
KeyValue {
key: "Issues".into(),
value: data.issues.to_string(),
},
KeyValue {
key: "Articles published".into(),
value: data.published.to_string(),
},
KeyValue {
key: "Mean issue size".into(),
value: format!("{} articles", data.mean_issue_size()),
},
KeyValue {
key: "Explicit ratings".into(),
value: data.total_ratings.to_string(),
},
KeyValue {
key: "Ratings per issue".into(),
value: data.ratings_per_issue(),
},
KeyValue {
key: "Mean generation time".into(),
value: match data.mean_generation_secs() {
Some(secs) => format!(
"{} ({} runs)",
RunReport::format_duration(secs),
data.durations.len()
),
None => "n/a (0 runs)".into(),
},
},
];
let ratings = data
.ratings_by_label
.iter()
.map(|(label, n)| KeyValue {
key: label.clone(),
value: n.to_string(),
})
.collect();
let retrievers = data
.per_retriever
.iter()
.map(|(retriever, counts)| RetrieverLine {
retriever: retriever.clone(),
rated: counts.rated,
up: counts.up,
down: counts.down,
ratio: counts.ratio(),
})
.collect();
let exploration = vec![
KeyValue {
key: "Admitted".into(),
value: data.exploration_admitted.to_string(),
},
KeyValue {
key: "Selected".into(),
value: data.exploration_selected.to_string(),
},
KeyValue {
key: "Rated positively".into(),
value: data.exploration_positive.to_string(),
},
];
let providers: Vec<String> = data.provider_totals.keys().cloned().collect();
let cost_rows = data
.cost_by_day
.iter()
.map(|(date, by_provider)| CostRow {
date: date.clone(),
cells: providers
.iter()
.map(|provider| {
by_provider
.get(provider)
.map(|usd| format!("{usd:.3}"))
.unwrap_or_else(|| "".into())
})
.collect(),
total: format!("{:.3}", by_provider.values().sum::<f64>()),
})
.collect();
let mut cost_per_day: Vec<KeyValue> = data
.provider_totals
.iter()
.map(|(provider, total)| KeyValue {
key: provider.clone(),
value: format!("${:.3}", data.per_day(*total)),
})
.collect();
cost_per_day.push(KeyValue {
key: "total".into(),
value: format!("${:.3}", data.per_day(data.grand_total())),
});
let runs = data
.runs
.iter()
.map(|run| RunLine {
run_id: run.run_id,
date: run.date.clone(),
status: run.status.clone(),
cost: fmt_usd(run.cost_usd),
selected: run.selected,
duration: super::fmt_duration(run.duration_secs),
})
.collect();
let mut page = Page::new("Stats", viewer, "dashboard");
page.flash = take_flash(&session).await?;
Ok(Html(StatsTemplate {
page,
days,
windows: WINDOWS.to_vec(),
since_date: data.since_date.clone(),
today: data.today.clone(),
summary,
ratings,
retrievers,
exploration,
providers,
cost_rows,
cost_per_day,
runs,
sparklines: stats_sparklines(&data),
text,
})
.into_response())
}
/// Every UTC date from `since` to `today` inclusive.
fn dates_between(since: &str, today: &str) -> Vec<String> {
let (Ok(mut date), Ok(end)) = (
since.parse::<jiff::civil::Date>(),
today.parse::<jiff::civil::Date>(),
) else {
return Vec::new();
};
let mut dates = Vec::new();
while date <= end && dates.len() < 400 {
dates.push(date.to_string());
match date.tomorrow() {
Ok(next) => date = next,
Err(_) => break,
}
}
dates
}
/// The stats page's three charts (§12).
fn stats_sparklines(data: &StatsData) -> Vec<Sparkline> {
let providers: Vec<String> = data.provider_totals.keys().cloned().collect();
let empty = BTreeMap::new();
let cost_columns: Vec<(String, Vec<(String, f64)>)> =
dates_between(&data.since_date, &data.today)
.into_iter()
.map(|date| {
let by_provider = data.cost_by_day.get(&date).unwrap_or(&empty);
let segments = providers
.iter()
.map(|provider| {
(
provider.clone(),
by_provider.get(provider).copied().unwrap_or(0.0),
)
})
.collect();
(date, segments)
})
.collect();
let cost = if data.cost_by_day.is_empty() {
Sparkline::stacked("Cost per day", &[], "days", fmt_usd)
} else {
Sparkline::stacked("Cost per day", &cost_columns, "days", |usd| {
format!("${usd:.3}")
})
};
let selected_values: Vec<f64> = data
.selected_per_issue
.iter()
.map(|(_, n)| *n as f64)
.collect();
let selected = Sparkline::line(
"Selected per issue",
&selected_values,
(
data.selected_per_issue
.first()
.map(|(date, _)| date.as_str())
.unwrap_or(""),
data.selected_per_issue
.last()
.map(|(date, _)| date.as_str())
.unwrap_or(""),
),
"issues",
|n| format!("{n:.0}"),
);
let week_columns: Vec<(String, Vec<(String, f64)>)> = data
.ratings_per_week
.iter()
.map(|(week, by_label)| {
(
week.clone(),
by_label
.iter()
.map(|(label, n)| (label.clone(), *n as f64))
.collect(),
)
})
.collect();
let weekly = Sparkline::stacked("Ratings per week", &week_columns, "weeks", |n| {
format!("{n:.0}")
});
vec![cost, selected, weekly]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::web::dashboard::tests::{
app_with_users, assert_admin_only, get, login_cookie, response_text, seed,
};
#[test]
fn window_falls_back_to_fourteen_days() {
assert_eq!(window(None), 14);
assert_eq!(window(Some(30)), 30);
assert_eq!(window(Some(90)), 90);
assert_eq!(window(Some(7)), 14);
assert_eq!(window(Some(-1)), 14);
}
#[test]
fn line_sparkline_scales_to_the_maximum_and_marks_empties() {
let spark = Sparkline::line(
"Cost",
&[0.0, 2.0, 1.0],
("2026-09-01", "2026-09-03"),
"runs",
fmt_usd,
);
assert!(!spark.empty);
assert_eq!(spark.points, "2,46 120,2 238,24");
assert_eq!(spark.caption, "3 runs · max $2.00");
assert_eq!(spark.first_label, "2026-09-01");
assert_eq!(spark.last_label, "2026-09-03");
assert!(spark.bars.is_empty());
let flat = Sparkline::line("Flat", &[0.0, 0.0], ("a", "b"), "runs", fmt_usd);
assert_eq!(flat.points, "2,46 238,46");
let none = Sparkline::line("None", &[], ("", ""), "runs", fmt_usd);
assert!(none.empty);
assert!(none.points.is_empty());
}
#[test]
fn stacked_sparkline_assigns_series_classes_in_first_seen_order() {
let columns = vec![
(
"2026-09-01".to_string(),
vec![("deepseek".to_string(), 0.1), ("voyage".to_string(), 0.1)],
),
(
"2026-09-02".to_string(),
vec![("deepseek".to_string(), 0.0), ("voyage".to_string(), 0.4)],
),
];
let spark =
Sparkline::stacked("Cost per day", &columns, "days", |usd| format!("${usd:.3}"));
assert!(!spark.empty);
assert_eq!(spark.legend.len(), 2);
assert_eq!(spark.legend[0].name, "deepseek");
assert_eq!(spark.legend[0].series, 0);
assert_eq!(spark.legend[1].series, 1);
// Three visible segments: the zero-valued one is skipped.
assert_eq!(spark.bars.len(), 3);
assert_eq!(spark.bars[0].series, 0);
assert_eq!(spark.bars[0].h, "11");
assert_eq!(spark.bars[0].y, "35");
assert_eq!(spark.bars[1].y, "24", "stacked on top of the first");
assert_eq!(spark.bars[2].h, "44");
assert_eq!(spark.bars[2].label, "2026-09-02 · voyage: $0.400");
assert_eq!(spark.caption, "2 days · max $0.400");
let none = Sparkline::stacked("None", &[], "days", fmt_usd);
assert!(none.empty);
}
#[test]
fn dates_between_is_inclusive() {
assert_eq!(
dates_between("2026-08-30", "2026-09-01"),
vec!["2026-08-30", "2026-08-31", "2026-09-01"]
);
assert!(dates_between("2026-09-02", "2026-09-01").is_empty());
assert!(dates_between("bad", "2026-09-01").is_empty());
}
#[tokio::test]
async fn stats_page_shows_tables_sparklines_and_the_text() {
let seed = seed().await;
// The seed is dated 2026-09-02; the page windows on `now`, so move the
// runs and rating events into the last hour to keep the test stable.
let now = Timestamp::now();
let earlier = crate::db::fmt_ts(now - jiff::Span::new().hours(1));
sqlx::query("UPDATE runs SET started_at = ?, finished_at = ?")
.bind(&earlier)
.bind(crate::db::fmt_ts(now))
.execute(seed.db.pool())
.await
.unwrap();
sqlx::query("UPDATE rating_events SET event_at = ?")
.bind(&earlier)
.execute(seed.db.pool())
.await
.unwrap();
let app = app_with_users(&seed.db).await;
let body = assert_admin_only(&app, "/dashboard/stats").await;
assert!(body.contains("?days=30"), "{body}");
assert!(body.contains("?days=90"), "{body}");
assert!(body.contains("Retriever yield"), "{body}");
assert!(body.contains("<rect"), "{body}");
assert!(body.contains("Ratings per week"), "{body}");
assert!(body.contains("badge loved"), "{body}");
assert!(body.contains("deepseek"), "{body}");
assert!(
body.contains("mean generation time:"),
"the CLI text is included"
);
assert!(!body.contains("style=\""), "no inline styles under the CSP");
assert!(
body.contains(&format!("/dashboard/runs/{}", seed.run_id)),
"{body}"
);
// An out-of-catalogue window falls back to 14 days.
let admin = login_cookie(&app, "admin", "correct horse battery").await;
let fallback = get(&app, "/dashboard/stats?days=7", Some(&admin)).await;
assert_eq!(fallback.status(), axum::http::StatusCode::OK);
let body = response_text(fallback).await;
assert!(body.contains("stats: last 14 days"), "{body}");
}
}
+55 -5
View File
@@ -35,6 +35,20 @@ pub struct UnitStatus {
pub sub_state: String,
pub result: String,
pub exit_status: Option<i32>,
/// `ExecMainStartTimestamp`, as systemd prints it; empty when never run.
pub started: Option<String>,
/// `ExecMainExitTimestamp`, as systemd prints it.
pub exited: Option<String>,
}
impl UnitStatus {
/// The unit ran and stopped with a failure result (`exit-code`, `failed`,
/// `signal`, `timeout`, …); a never-started unit reports `success`.
pub fn exited_unsuccessfully(&self) -> bool {
matches!(self.active_state.as_str(), "inactive" | "failed")
&& !self.result.is_empty()
&& self.result != "success"
}
}
#[derive(Debug, Default)]
@@ -55,11 +69,15 @@ impl JobRunner for DisabledRunner {
}
}
/// In-memory runner for router tests. Step 6 will add scripted results alongside
/// these recorded calls when the jobs pages begin invoking the runner.
/// In-memory runner for router tests: records every call and answers with
/// scripted results (`start` succeeds, `status` is inactive/success and `log`
/// is empty unless told otherwise).
#[derive(Debug, Default)]
pub struct MockRunner {
calls: Mutex<Vec<String>>,
start_error: Mutex<Option<String>>,
statuses: Mutex<std::collections::HashMap<String, UnitStatus>>,
log_text: Mutex<String>,
}
impl MockRunner {
@@ -67,6 +85,24 @@ impl MockRunner {
self.calls.lock().expect("mock runner lock").clone()
}
/// Make every `start` fail with `message` (`None` restores success).
pub fn fail_starts(&self, message: Option<&str>) {
*self.start_error.lock().expect("mock runner lock") = message.map(str::to_string);
}
/// Script the `status` answer for one unit.
pub fn set_status(&self, unit: &str, status: UnitStatus) {
self.statuses
.lock()
.expect("mock runner lock")
.insert(unit.to_string(), status);
}
/// Script the journal text every `log` call returns.
pub fn set_log(&self, text: &str) {
*self.log_text.lock().expect("mock runner lock") = text.to_string();
}
fn record(&self, call: String) {
self.calls.lock().expect("mock runner lock").push(call);
}
@@ -76,17 +112,31 @@ impl MockRunner {
impl JobRunner for MockRunner {
async fn start(&self, unit: &str) -> Result<(), String> {
self.record(format!("start {unit}"));
Ok(())
match self.start_error.lock().expect("mock runner lock").clone() {
Some(message) => Err(message),
None => Ok(()),
}
}
async fn status(&self, unit: &str) -> Result<UnitStatus, String> {
self.record(format!("status {unit}"));
Ok(UnitStatus::default())
Ok(self
.statuses
.lock()
.expect("mock runner lock")
.get(unit)
.cloned()
.unwrap_or_else(|| UnitStatus {
active_state: "inactive".into(),
sub_state: "dead".into(),
result: "success".into(),
..UnitStatus::default()
}))
}
async fn log(&self, unit: &str, lines: usize) -> Result<String, String> {
self.record(format!("log {unit} {lines}"));
Ok(String::new())
Ok(self.log_text.lock().expect("mock runner lock").clone())
}
}
+13 -1
View File
@@ -73,7 +73,6 @@ pre.preview { white-space:pre-wrap; overflow-wrap:anywhere; font:.85rem/1.4 ui-m
.versions pre.preview { max-height:6rem; border:0; padding:0; }
.versions form { margin:0; }
@media (max-width:60rem) { .profile-grid { display:block; } }
||||||| 849231e
/* step 3: dashboard reads (overview, runs, articles) */
.dashboard h1 { font-size:1.5rem; margin:.5rem 0; }
.dashboard h1 a { color:inherit; }
@@ -125,3 +124,16 @@ details.explain { margin:1rem 0; }
.settings .add-provider { max-width:36rem; }
.history pre { margin:0; white-space:pre-wrap; font-size:.8rem; }
@media (max-width:40rem) { .setting { display:block; } }
||||||| d36f203
/* step 6: jobs and stats */
.sparklines { display:grid; grid-template-columns:repeat(auto-fit,minmax(16rem,1fr)); gap:1rem 1.5rem; margin:1rem 0; }
.spark-figure { margin:0; min-width:0; } .spark-figure figcaption { font-size:.9rem; margin-bottom:.2rem; }
.spark { display:block; width:100%; max-width:26rem; height:auto; border-bottom:1px solid var(--rule); }
.spark .s0 { fill:var(--good); } .spark .s1 { fill:var(--loved); } .spark .s2 { fill:var(--down); } .spark .s3 { fill:var(--accent); } .spark .s4 { fill:var(--muted); } .spark .s5 { fill:var(--fg); }
.spark .line { stroke:var(--accent); }
.spark-axis { display:flex; justify-content:space-between; max-width:26rem; font-size:.75rem; }
.spark-legend { list-style:none; padding:0; margin:.3rem 0 0; display:flex; flex-wrap:wrap; gap:.2rem .8rem; font-size:.8rem; }
.spark-legend svg { vertical-align:middle; }
.job-cards form.inline { margin:.4rem 0 0; } .job-cards label { display:inline-grid; }
td.message,dd.message { overflow-wrap:anywhere; max-width:32rem; }
pre.journal { max-height:40rem; }
+5
View File
@@ -65,3 +65,8 @@ document.addEventListener("click", (event) => {
if (!input) return;
input.value = button.dataset.default;
});
/* step 6: reload a job page every N seconds while its job is requested/running */
document.querySelectorAll("[data-refresh]").forEach((element) => {
const seconds = Number(element.dataset.refresh);
if (seconds > 0) setTimeout(() => window.location.reload(), seconds * 1000);
});
@@ -0,0 +1,8 @@
<figure class="spark-figure"><figcaption>{{ spark.title }}{% if !spark.caption.is_empty() %} <span class="muted">· {{ spark.caption }}</span>{% endif %}</figcaption>
{% if spark.empty %}<p class="muted">No data yet.</p>{% else %}<svg class="spark" viewBox="0 0 {{ spark.width }} {{ spark.height }}" width="{{ spark.width }}" height="{{ spark.height }}" role="img" aria-label="{{ spark.title }}">
{% for bar in spark.bars %}<rect class="s{{ bar.series }}" x="{{ bar.x }}" y="{{ bar.y }}" width="{{ bar.w }}" height="{{ bar.h }}"><title>{{ bar.label }}</title></rect>
{% endfor %}{% if !spark.points.is_empty() %}<polyline class="line" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" points="{{ spark.points }}"/>{% endif %}
</svg>
<div class="spark-axis muted"><span>{{ spark.first_label }}</span><span>{{ spark.last_label }}</span></div>
{% if !spark.legend.is_empty() %}<ul class="spark-legend">{% for item in spark.legend %}<li><svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><rect class="s{{ item.series }}" width="10" height="10"/></svg> {{ item.name }}</li>{% endfor %}</ul>{% endif %}
{% endif %}</figure>
+32
View File
@@ -0,0 +1,32 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard"{% if refresh %} data-refresh="5"{% endif %}>
<h1><a href="/dashboard/jobs">Jobs</a> Job {{ job.id }} · {{ job.name }} <span class="badge {{ job.status }}">{{ job.status }}</span></h1>
{% if !description.is_empty() %}<p class="muted">{{ description }}</p>{% endif %}
<div class="cards">
<section class="card"><h2>Job</h2>
<dl class="kv">
<dt>Unit</dt><dd><code>{{ unit }}</code></dd>
<dt>Requested</dt><dd>{{ job.requested }} · {{ job.requested_by }}</dd>
<dt>Started</dt><dd>{{ job.started }}</dd>
<dt>Finished</dt><dd>{{ job.finished }} · {{ job.duration }}</dd>
<dt>Status</dt><dd><span class="badge {{ job.status }}">{{ job.status }}</span></dd>
{% if let Some(message) = job.message %}<dt>Message</dt><dd class="message">{{ message }}</dd>{% endif %}
{% if let Some(run_id) = job.run_id %}<dt>Run</dt><dd><a href="/dashboard/runs/{{ run_id }}">Run {{ run_id }}</a></dd>{% endif %}
</dl>
{% if refresh %}<p class="muted">This page reloads every 5 seconds while the job is requested or running.</p>{% endif %}
</section>
<section class="card"><h2>Unit</h2>
{% if let Some(status) = status %}<dl class="kv">
<dt>Active</dt><dd>{{ status.active_state }}{% if !status.sub_state.is_empty() %} ({{ status.sub_state }}){% endif %}</dd>
<dt>Result</dt><dd>{{ status.result }}{% if let Some(code) = status.exit_status %} · exit status {{ code }}{% endif %}</dd>
{% if let Some(started) = status.started %}<dt>Main started</dt><dd>{{ started }}</dd>{% endif %}
{% if let Some(exited) = status.exited %}<dt>Main exited</dt><dd>{{ exited }}</dd>{% endif %}
</dl>{% endif %}
{% if let Some(error) = status_error %}<p class="error">Unit status unavailable: {{ error }}</p>{% endif %}
<p class="muted">From <code>systemctl show {{ unit }}</code>.</p>
</section>
</div>
<h2>Log</h2>
{% if let Some(error) = log_error %}<p class="error">Journal unavailable: {{ error }}</p>{% endif %}
<p class="muted">Last {{ log_lines }} lines of <code>journalctl -u {{ unit }}</code>.</p>
<pre class="preview journal">{{ log }}</pre>
</section>{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard">
<h1>Jobs</h1>
{% if jobs_enabled %}<p class="muted">Each job starts <code>daily-epub-job@&lt;name&gt;.service</code> through systemd; the unit runs <code>daily-epub job run &lt;name&gt;</code> and records itself here. Jobs that take the run lock wait for nothing: a second <code>generate</code> while one is running fails immediately.</p>{% else %}<p class="notice">Jobs are disabled on this server (<code>server.jobs_enabled = false</code>); run the commands by hand instead.</p>{% endif %}
<div class="cards job-cards">{% for card in cards %}<section class="card">
<h2>{{ card.name }}</h2>
<p>{{ card.description }}</p>
{% if let Some(lock) = card.lock %}<p class="muted">Takes the <code>{{ lock }}</code> lock.</p>{% endif %}
<form method="post" action="/dashboard/jobs/{{ card.name }}" class="inline"{% if card.dangerous %} data-confirm="Start {{ card.name }}? This republishes the issue of that date."{% endif %}>
{% if card.dated %}<label>Date <input type="date" name="date" value="{{ today }}"></label>{% endif %}
<button type="submit"{% if !jobs_enabled %} disabled{% endif %}>Start</button>
</form>
</section>{% endfor %}</div>
<h2>History</h2>
{% if jobs.is_empty() %}<p class="muted">No jobs recorded yet.</p>{% else %}<div class="scroll-x"><table data-filter>
<thead><tr><th>job</th><th>name</th><th>requested by</th><th>requested</th><th>started</th><th>finished</th><th class="num">duration</th><th>status</th><th>message</th><th>run</th></tr></thead>
<tbody>{% for job in jobs %}<tr>
<td><a href="/dashboard/jobs/{{ job.id }}">{{ job.id }}</a></td>
<td>{{ job.name }}</td>
<td>{{ job.requested_by }}</td>
<td>{{ job.requested }}</td>
<td>{{ job.started }}</td>
<td>{{ job.finished }}</td>
<td class="num">{{ job.duration }}</td>
<td><span class="badge {{ job.status }}">{{ job.status }}</span></td>
<td class="message">{% if let Some(message) = job.message %}{{ message }}{% endif %}</td>
<td>{% if let Some(run_id) = job.run_id %}<a href="/dashboard/runs/{{ run_id }}">{{ run_id }}</a>{% endif %}</td>
</tr>{% endfor %}</tbody></table></div>{% endif %}
</section>{% endblock %}
+2 -1
View File
@@ -32,7 +32,8 @@
<p><a href="/dashboard/settings">Settings</a></p>
</section>{% endif %}
</div>
<!-- step 6: sparklines (cost per run, selected per run, generation seconds; last 30 non-dry runs) -->
<h2>Last 30 runs</h2>
<div class="sparklines">{% for item in sparklines %}{% let spark = item %}{% include "dashboard/_sparkline.html" %}{% endfor %}</div>
<h2>Unrated picks</h2>
{% if unrated.is_empty() %}<p class="muted">Every pick from the last three issues has a verdict.</p>{% else %}<p class="muted">Picks from the last three issues without a verdict yet.</p>
<ul class="picks">{% for pick in unrated %}<li><a href="{{ pick.issue_href }}">{{ pick.title }}</a> <span class="muted">· {{ pick.feed }} · {{ pick.issue_date }}</span>
+31
View File
@@ -0,0 +1,31 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard">
<h1>Stats</h1>
<p class="muted">Last {{ days }} days ({{ since_date }} → {{ today }}) · window: {% for window in windows %}{% if *window == days %}<strong>{{ window }}</strong>{% else %}<a href="/dashboard/stats?days={{ window }}">{{ window }}</a>{% endif %}{% if !loop.last %} · {% endif %}{% endfor %} days · the same figures as <code>daily-epub stats --days {{ days }}</code>.</p>
<div class="sparklines">{% for item in sparklines %}{% let spark = item %}{% include "dashboard/_sparkline.html" %}{% endfor %}</div>
<div class="cards">
<section class="card"><h2>Issues and ratings</h2>
<dl class="kv">{% for line in summary %}<dt>{{ line.key }}</dt><dd>{{ line.value }}</dd>{% endfor %}</dl>
{% if ratings.is_empty() %}<p class="muted">No explicit ratings in the window.</p>{% else %}<p>{% for line in ratings %}<span class="badge {{ line.key }}">{{ line.key }}</span> {{ line.value }}{% if !loop.last %} · {% endif %}{% endfor %}</p>{% endif %}
</section>
<section class="card"><h2>Retriever yield</h2>
{% if retrievers.is_empty() %}<p class="muted">No rated picks by admitting retriever in the window.</p>{% else %}<div class="scroll-x"><table>
<thead><tr><th>admitted by</th><th class="num">rated</th><th class="num">up</th><th class="num">down</th><th class="num">ratio</th></tr></thead>
<tbody>{% for line in retrievers %}<tr><td>{{ line.retriever }}</td><td class="num">{{ line.rated }}</td><td class="num">{{ line.up }}</td><td class="num">{{ line.down }}</td><td class="num">{{ line.ratio }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
<h3>Exploration</h3>
<dl class="kv">{% for line in exploration %}<dt>{{ line.key }}</dt><dd>{{ line.value }}</dd>{% endfor %}</dl>
</section>
<section class="card"><h2>Cost per day</h2>
<dl class="kv">{% for line in cost_per_day %}<dt>{{ line.key }}</dt><dd>{{ line.value }}</dd>{% endfor %}</dl>
<p class="muted">Window spend divided by {{ days }} days, per provider.</p>
</section>
</div>
<h2>Spend by day</h2>
{% if cost_rows.is_empty() %}<p class="muted">No provider costs recorded in the window.</p>{% else %}<div class="scroll-x"><table data-filter>
<thead><tr><th>day (UTC)</th>{% for provider in providers %}<th class="num">{{ provider }}</th>{% endfor %}<th class="num">total</th></tr></thead>
<tbody>{% for row in cost_rows %}<tr><td>{{ row.date }}</td>{% for cell in row.cells %}<td class="num">{{ cell }}</td>{% endfor %}<td class="num">{{ row.total }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
<h2>Runs in the window</h2>
{% if runs.is_empty() %}<p class="muted">No finished runs in the window.</p>{% else %}<div class="scroll-x"><table data-filter>
<thead><tr><th>run</th><th>date</th><th>status</th><th class="num">cost</th><th class="num">selected</th><th class="num">duration</th></tr></thead>
<tbody>{% for run in runs %}<tr><td><a href="/dashboard/runs/{{ run.run_id }}">{{ run.run_id }}</a></td><td><a href="/issues/{{ run.date }}">{{ run.date }}</a></td><td><span class="badge {{ run.status }}">{{ run.status }}</span></td><td class="num">{{ run.cost }}</td><td class="num">{{ run.selected }}</td><td class="num">{{ run.duration }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
<details class="explain"><summary>As text (<code>daily-epub stats --days {{ days }}</code>)</summary><pre class="preview">{{ text }}</pre></details>
</section>{% endblock %}