Merge branch 'step6' into web-dashboard
# Conflicts: # src/web/static/app.css
This commit is contained in:
+629
-1
@@ -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('\'', "'");
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user