Web dashboard step 6: jobs and stats

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
2026-09-03 18:01:21 +00:00
co-authored by Claude Fable 5.1
parent d36f203e4c
commit aa3de51d9c
21 changed files with 2958 additions and 112 deletions
+388 -78
View File
@@ -624,16 +624,179 @@ fn first_retriever(admitted_by: Option<&str>) -> String {
.unwrap_or_else(|| "unknown".to_string())
}
#[derive(Debug, Default, Clone, Copy)]
struct UpDown {
rated: i64,
up: i64,
down: i64,
/// Rated picks by admitting retriever: how many were rated, up, down.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct UpDown {
pub rated: i64,
pub up: i64,
pub down: i64,
}
impl UpDown {
/// `"NN% up"`, or `n/a` with nothing rated.
pub fn ratio(&self) -> String {
if self.rated > 0 {
format!("{:.0}% up", 100.0 * self.up as f64 / self.rated as f64)
} else {
"n/a".to_string()
}
}
}
/// One finished, non-dry run as a point of the per-run series (dashboard
/// plan §9.1, §12).
#[derive(Debug, Clone, PartialEq)]
pub struct RunPoint {
pub run_id: i64,
pub date: String,
pub started_at: String,
pub status: String,
pub cost_usd: f64,
pub selected: i64,
pub duration_secs: Option<i64>,
}
/// Everything `daily-epub stats` prints, as data (dashboard plan §12): the
/// CLI renders it with [`render_stats_text`], the stats page as tables and
/// sparklines.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct StatsData {
pub days: i64,
/// First UTC date of the window.
pub since_date: String,
/// UTC date of `now`.
pub today: String,
pub issues: i64,
pub published: i64,
/// Explicit ratings per label, label order; `cleared` included.
pub ratings_by_label: Vec<(String, i64)>,
/// Explicit ratings excluding `cleared`.
pub total_ratings: i64,
pub per_retriever: BTreeMap<String, UpDown>,
pub exploration_admitted: i64,
pub exploration_selected: i64,
pub exploration_positive: i64,
/// Provider → spend over the whole window.
pub provider_totals: BTreeMap<String, f64>,
/// UTC date → provider → spend.
pub cost_by_day: BTreeMap<String, BTreeMap<String, f64>>,
/// Seconds of every finished run in the window (dry runs included), for
/// the mean generation time.
pub durations: Vec<i64>,
/// Finished non-dry runs in the window, oldest first.
pub runs: Vec<RunPoint>,
/// Issue date → picks, oldest first.
pub selected_per_issue: Vec<(String, i64)>,
/// Week (Monday, UTC) → label → explicit ratings.
pub ratings_per_week: BTreeMap<String, BTreeMap<String, i64>>,
}
impl StatsData {
/// `n` per issue with one decimal, or `n/a` without issues.
pub fn per_issue(&self, n: i64) -> String {
if self.issues > 0 {
format!("{:.1}", n as f64 / self.issues as f64)
} else {
"n/a".to_string()
}
}
pub fn mean_issue_size(&self) -> String {
self.per_issue(self.published)
}
pub fn ratings_per_issue(&self) -> String {
self.per_issue(self.total_ratings)
}
/// Spend per day over the window for one provider total.
pub fn per_day(&self, total: f64) -> f64 {
total / self.days as f64
}
/// Every provider's window spend added up, in provider order.
pub fn grand_total(&self) -> f64 {
let mut grand = 0.0;
for total in self.provider_totals.values() {
grand += total;
}
grand
}
/// Integer mean of the finished runs' seconds.
pub fn mean_generation_secs(&self) -> Option<i64> {
if self.durations.is_empty() {
None
} else {
Some(self.durations.iter().sum::<i64>() / self.durations.len() as i64)
}
}
}
/// `daily-epub stats [--days N]` as text: the whole evaluation framework
/// (§15.3). One fact per line, nothing wider than 80 columns.
pub async fn stats(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<String> {
Ok(render_stats_text(&stats_data(db, days, now).await?))
}
/// Finished non-dry runs as sparkline points, oldest first: those started
/// at or after `since` (RFC3339) and/or the newest `limit`.
pub async fn run_series(
db: &Db,
since: Option<&str>,
limit: Option<i64>,
) -> Result<Vec<RunPoint>, sqlx::Error> {
let rows = sqlx::query(
"SELECT id, date, started_at, finished_at, status, cost_usd, selected FROM runs
WHERE status != 'dry_run' AND finished_at IS NOT NULL
AND (? IS NULL OR started_at >= ?)
ORDER BY id DESC LIMIT ?",
)
.bind(since)
.bind(since)
.bind(limit.unwrap_or(i64::MAX))
.fetch_all(db.pool())
.await?;
let mut points: Vec<RunPoint> = rows
.iter()
.map(|row| {
let started_at: String = row.get("started_at");
let finished_at: Option<String> = row.get("finished_at");
let duration_secs = match (
started_at.parse::<Timestamp>(),
finished_at.as_deref().map(str::parse::<Timestamp>),
) {
(Ok(started), Some(Ok(finished))) => {
Some((finished.as_second() - started.as_second()).max(0))
}
_ => None,
};
RunPoint {
run_id: row.get("id"),
date: row.get("date"),
started_at,
status: row.get("status"),
cost_usd: row.get("cost_usd"),
selected: row.get("selected"),
duration_secs,
}
})
.collect();
points.reverse();
Ok(points)
}
/// The Monday (UTC) of the week containing `ts`, as `YYYY-MM-DD`.
pub fn week_start(ts: Timestamp) -> String {
let date = ts.to_zoned(jiff::tz::TimeZone::UTC).date();
let offset = i64::from(date.weekday().to_monday_zero_offset());
date.checked_sub(jiff::Span::new().days(offset))
.unwrap_or(date)
.to_string()
}
/// Gather every figure of [`StatsData`] for the last `days` days.
pub async fn stats_data(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<StatsData> {
let days = days.max(1);
let since_ts = now
.checked_sub(jiff::Span::new().hours(days.saturating_mul(24)))
@@ -641,30 +804,35 @@ pub async fn stats(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<String>
let since = fmt_ts(since_ts);
let utc = jiff::tz::TimeZone::UTC;
let since_date = since_ts.to_zoned(utc.clone()).date().to_string();
let today = now.to_zoned(utc).date().to_string();
let mut out = String::new();
let _ = writeln!(out, "stats: last {days} days ({since_date} → {today})");
let today = now.to_zoned(utc.clone()).date().to_string();
let mut data = StatsData {
days,
since_date: since_date.clone(),
today,
..StatsData::default()
};
// --- issues and articles ---
let issues: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues WHERE date >= ?")
data.issues = sqlx::query_scalar("SELECT COUNT(*) FROM issues WHERE date >= ?")
.bind(&since_date)
.fetch_one(db.pool())
.await?;
let published: i64 =
data.published =
sqlx::query_scalar("SELECT COUNT(*) FROM issue_articles WHERE issue_date >= ?")
.bind(&since_date)
.fetch_one(db.pool())
.await?;
let _ = writeln!(out, "issues: {issues}");
let _ = writeln!(out, "articles published: {published}");
let per_issue = |n: i64| {
if issues > 0 {
format!("{:.1}", n as f64 / issues as f64)
} else {
"n/a".to_string()
}
};
let _ = writeln!(out, "mean issue size: {} articles", per_issue(published));
let per_issue_rows = sqlx::query(
"SELECT issue_date, COUNT(*) AS n FROM issue_articles
WHERE issue_date >= ? GROUP BY issue_date ORDER BY issue_date",
)
.bind(&since_date)
.fetch_all(db.pool())
.await?;
data.selected_per_issue = per_issue_rows
.iter()
.map(|row| (row.get::<String, _>("issue_date"), row.get::<i64, _>("n")))
.collect();
// --- explicit ratings by label ---
let labels = sqlx::query(
@@ -674,21 +842,32 @@ pub async fn stats(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<String>
.bind(&since)
.fetch_all(db.pool())
.await?;
let mut total_ratings = 0i64;
let mut by_label = Vec::new();
for row in &labels {
let label = row.get::<String, _>("label");
let n = row.get::<i64, _>("n");
if label != "cleared" {
total_ratings += n;
data.total_ratings += n;
}
by_label.push((label, n));
data.ratings_by_label.push((label, n));
}
let _ = writeln!(out, "explicit ratings: {total_ratings}");
for (label, n) in &by_label {
let _ = writeln!(out, "explicit ratings ({label}): {n}");
let events = sqlx::query(
"SELECT label, event_at FROM rating_events
WHERE kind = 'explicit' AND event_at >= ? ORDER BY event_at",
)
.bind(&since)
.fetch_all(db.pool())
.await?;
for row in &events {
let Ok(event_at) = row.get::<String, _>("event_at").parse::<Timestamp>() else {
continue;
};
*data
.ratings_per_week
.entry(week_start(event_at))
.or_default()
.entry(row.get::<String, _>("label"))
.or_insert(0) += 1;
}
let _ = writeln!(out, "ratings per issue: {}", per_issue(total_ratings));
// --- up/down per admitting retriever, from rated picks ---
let rated_picks = sqlx::query(
@@ -712,8 +891,6 @@ pub async fn stats(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<String>
.bind(&since)
.fetch_all(db.pool())
.await?;
let mut per_retriever: BTreeMap<String, UpDown> = BTreeMap::new();
let mut exploration_positive = 0i64;
let mut seen: Option<ArticleId> = None;
for row in &rated_picks {
let article_id = row.get::<ArticleId, _>("article_id");
@@ -723,7 +900,7 @@ pub async fn stats(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<String>
seen = Some(article_id);
let value = row.get::<f64, _>("value");
let retriever = first_retriever(row.get::<Option<String>, _>("admitted_by").as_deref());
let entry = per_retriever.entry(retriever).or_default();
let entry = data.per_retriever.entry(retriever).or_default();
entry.rated += 1;
if value > 0.0 {
entry.up += 1;
@@ -735,24 +912,9 @@ pub async fn stats(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<String>
.map(|signals| signals.exploration)
.unwrap_or(false);
if exploration && value > 0.0 {
exploration_positive += 1;
data.exploration_positive += 1;
}
}
if per_retriever.is_empty() {
let _ = writeln!(out, "rated picks by admitting retriever: none");
}
for (retriever, counts) in &per_retriever {
let ratio = if counts.rated > 0 {
format!("{:.0}% up", 100.0 * counts.up as f64 / counts.rated as f64)
} else {
"n/a".to_string()
};
let _ = writeln!(
out,
"admitted by {retriever}: {} rated · {} up · {} down · {ratio}",
counts.rated, counts.up, counts.down
);
}
// --- exploration yield ---
let exploration_rows = sqlx::query(
@@ -764,31 +926,25 @@ pub async fn stats(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<String>
.bind(&since)
.fetch_all(db.pool())
.await?;
let mut exploration_admitted = 0i64;
let mut exploration_selected = 0i64;
for row in &exploration_rows {
match row.get::<String, _>("stage").as_str() {
"selected" => {
exploration_admitted += 1;
exploration_selected += 1;
data.exploration_admitted += 1;
data.exploration_selected += 1;
}
"admitted" | "assessed" | "shortlisted" => exploration_admitted += 1,
"admitted" | "assessed" | "shortlisted" => data.exploration_admitted += 1,
_ => {}
}
}
let _ = writeln!(out, "exploration admitted: {exploration_admitted}");
let _ = writeln!(out, "exploration selected: {exploration_selected}");
let _ = writeln!(out, "exploration rated positively: {exploration_positive}");
// --- cost per day per provider (§7.6) ---
let cost_rows = sqlx::query(
"SELECT provider_costs_json FROM runs
"SELECT started_at, provider_costs_json FROM runs
WHERE started_at >= ? AND provider_costs_json IS NOT NULL",
)
.bind(&since)
.fetch_all(db.pool())
.await?;
let mut totals: BTreeMap<String, f64> = BTreeMap::new();
for row in &cost_rows {
let raw = row.get::<String, _>("provider_costs_json");
let Ok(providers) =
@@ -796,20 +952,21 @@ pub async fn stats(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<String>
else {
continue;
};
let day = row
.get::<String, _>("started_at")
.parse::<Timestamp>()
.map(|ts| ts.to_zoned(utc.clone()).date().to_string())
.unwrap_or_else(|_| since_date.clone());
for (provider, usage) in providers {
*totals.entry(provider).or_insert(0.0) += usage.cost_usd;
*data.provider_totals.entry(provider.clone()).or_insert(0.0) += usage.cost_usd;
*data
.cost_by_day
.entry(day.clone())
.or_default()
.entry(provider)
.or_insert(0.0) += usage.cost_usd;
}
}
let mut grand = 0.0;
for (provider, total) in &totals {
grand += total;
let _ = writeln!(
out,
"cost per day ({provider}): ${:.3}",
total / days as f64
);
}
let _ = writeln!(out, "cost per day (total): ${:.3}", grand / days as f64);
// --- mean generation time ---
let run_rows = sqlx::query(
@@ -819,26 +976,81 @@ pub async fn stats(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<String>
.bind(&since)
.fetch_all(db.pool())
.await?;
let mut durations = Vec::new();
for row in &run_rows {
let started = row.get::<String, _>("started_at").parse::<Timestamp>();
let finished = row.get::<String, _>("finished_at").parse::<Timestamp>();
if let (Ok(started), Ok(finished)) = (started, finished) {
durations.push((finished.as_second() - started.as_second()).max(0));
data.durations
.push((finished.as_second() - started.as_second()).max(0));
}
}
if durations.is_empty() {
let _ = writeln!(out, "mean generation time: n/a (0 runs)");
} else {
let mean = durations.iter().sum::<i64>() / durations.len() as i64;
data.runs = run_series(db, Some(&since), None).await?;
Ok(data)
}
/// The CLI text of [`StatsData`], one fact per line.
pub fn render_stats_text(data: &StatsData) -> String {
let mut out = String::new();
let _ = writeln!(
out,
"stats: last {} days ({} → {})",
data.days, data.since_date, data.today
);
let _ = writeln!(out, "issues: {}", data.issues);
let _ = writeln!(out, "articles published: {}", data.published);
let _ = writeln!(out, "mean issue size: {} articles", data.mean_issue_size());
let _ = writeln!(out, "explicit ratings: {}", data.total_ratings);
for (label, n) in &data.ratings_by_label {
let _ = writeln!(out, "explicit ratings ({label}): {n}");
}
let _ = writeln!(out, "ratings per issue: {}", data.ratings_per_issue());
if data.per_retriever.is_empty() {
let _ = writeln!(out, "rated picks by admitting retriever: none");
}
for (retriever, counts) in &data.per_retriever {
let _ = writeln!(
out,
"mean generation time: {} ({} runs)",
RunReport::format_duration(mean),
durations.len()
"admitted by {retriever}: {} rated · {} up · {} down · {}",
counts.rated,
counts.up,
counts.down,
counts.ratio()
);
}
Ok(out)
let _ = writeln!(out, "exploration admitted: {}", data.exploration_admitted);
let _ = writeln!(out, "exploration selected: {}", data.exploration_selected);
let _ = writeln!(
out,
"exploration rated positively: {}",
data.exploration_positive
);
for (provider, total) in &data.provider_totals {
let _ = writeln!(
out,
"cost per day ({provider}): ${:.3}",
data.per_day(*total)
);
}
let _ = writeln!(
out,
"cost per day (total): ${:.3}",
data.per_day(data.grand_total())
);
match data.mean_generation_secs() {
None => {
let _ = writeln!(out, "mean generation time: n/a (0 runs)");
}
Some(mean) => {
let _ = writeln!(
out,
"mean generation time: {} ({} runs)",
RunReport::format_duration(mean),
data.durations.len()
);
}
}
out
}
// ---------------------------------------------------------------------------
@@ -1523,6 +1735,41 @@ mod tests {
text.lines().all(|line| line.chars().count() <= 80),
"no line wider than 80 columns"
);
// Dashboard plan §12: the CLI output is byte-identical before and
// after the `stats_data` / `render_stats_text` split.
assert_eq!(text, STATS_TEXT_BEFORE_REFACTOR);
let data = stats_data(&db, 14, now).await.unwrap();
assert_eq!(render_stats_text(&data), text);
// The figures the stats page adds on top of the text.
assert_eq!(data.issues, 2);
assert_eq!(data.total_ratings, 4);
assert_eq!(
data.selected_per_issue,
vec![("2026-08-30".to_string(), 2), ("2026-09-01".to_string(), 1)]
);
assert_eq!(
data.cost_by_day["2026-08-30"]["deepseek"],
0.10 + 0.04,
"both 08-30 runs land on the same day"
);
assert_eq!(data.cost_by_day["2026-09-01"]["gemini"], 0.07);
assert_eq!(data.ratings_per_week["2026-08-31"]["loved"], 2);
assert_eq!(data.ratings_per_week["2026-08-31"]["not_for_me"], 1);
assert_eq!(data.ratings_per_week["2026-08-17"]["cleared"], 1);
assert_eq!(data.runs.len(), 3, "three finished non-dry runs");
assert_eq!(data.runs[0].run_id, run_ids[0], "oldest first");
assert_eq!(data.runs[0].duration_secs, Some(1200));
assert_eq!(data.runs[2].date, "2026-09-01");
assert_eq!(data.per_retriever["knn"].ratio(), "100% up");
assert_eq!(data.mean_generation_secs(), Some(900));
// The newest-N form the overview uses.
let last_two = run_series(&db, None, Some(2)).await.unwrap();
assert_eq!(
last_two.iter().map(|p| p.run_id).collect::<Vec<_>>(),
vec![run_ids[1], run_ids[2]]
);
// An empty database still prints every heading.
let (_dir, empty) = db_with_articles(&[]).await;
@@ -1537,6 +1784,69 @@ mod tests {
] {
assert!(text.contains(line), "missing {line:?} in:\n{text}");
}
assert_eq!(text, STATS_TEXT_EMPTY_BEFORE_REFACTOR);
}
/// `stats(db, 14, 2026-09-02T12:00:00Z)` over the seed above, captured
/// from the pre-refactor implementation.
const STATS_TEXT_BEFORE_REFACTOR: &str = "\
stats: last 14 days (2026-08-19 → 2026-09-02)
issues: 2
articles published: 3
mean issue size: 1.5 articles
explicit ratings: 4
explicit ratings (cleared): 1
explicit ratings (good): 1
explicit ratings (loved): 2
explicit ratings (not_for_me): 1
ratings per issue: 2.0
admitted by blend: 1 rated · 0 up · 1 down · 0% up
admitted by exploration: 1 rated · 1 up · 0 down · 100% up
admitted by knn: 1 rated · 1 up · 0 down · 100% up
exploration admitted: 2
exploration selected: 1
exploration rated positively: 1
cost per day (anthropic): $0.043
cost per day (deepseek): $0.020
cost per day (gemini): $0.005
cost per day (voyage): $0.001
cost per day (total): $0.069
mean generation time: 15m00s (3 runs)
";
const STATS_TEXT_EMPTY_BEFORE_REFACTOR: &str = "\
stats: last 7 days (2026-08-26 → 2026-09-02)
issues: 0
articles published: 0
mean issue size: n/a articles
explicit ratings: 0
ratings per issue: n/a
rated picks by admitting retriever: none
exploration admitted: 0
exploration selected: 0
exploration rated positively: 0
cost per day (total): $0.000
mean generation time: n/a (0 runs)
";
#[test]
fn week_start_is_the_utc_monday() {
assert_eq!(
week_start("2026-09-02T12:00:00Z".parse().unwrap()),
"2026-08-31"
);
assert_eq!(
week_start("2026-08-31T00:00:00Z".parse().unwrap()),
"2026-08-31"
);
assert_eq!(
week_start("2026-09-06T23:59:59Z".parse().unwrap()),
"2026-08-31"
);
assert_eq!(
week_start("2026-09-07T00:00:00Z".parse().unwrap()),
"2026-09-07"
);
}
#[tokio::test]
+606
View File
@@ -0,0 +1,606 @@
//! Operator jobs (dashboard plan §14): the fixed catalogue the Jobs page can
//! start, the `jobs` table lifecycle shared by the page and `daily-epub job
//! run`, and the systemd-backed [`JobRunner`].
//!
//! A job is a `daily-epub-job@<name>.service` instance. The web server never
//! runs the pipeline in-process: it inserts a `requested` row, asks systemd to
//! start the unit (polkit allows `start` on exactly that unit pattern), and the
//! unit's `job run <name>` claims the row, does the work and finishes it.
use std::time::Duration;
use async_trait::async_trait;
use jiff::Timestamp;
use jiff::civil::Date;
use sqlx::Row as _;
use crate::db::{Db, fmt_ts};
use crate::web::{JobRunner, UnitStatus};
/// Prefix of every job unit; the polkit rule matches the same pattern.
pub const UNIT_PREFIX: &str = "daily-epub-job@";
/// Marker written by the page's 30-second rule (§14.4).
pub const EXITED_BEFORE_START: &str = "unit exited before the job started; see the log";
// ---------------------------------------------------------------------------
// Catalogue (§14.1)
// ---------------------------------------------------------------------------
/// The jobs an admin can start from the dashboard (§14.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Job {
/// `generate` (today) or `generate-YYYY-MM-DD`.
Generate { date: Option<Date> },
/// `dry-run` → `generate --dry-run`.
DryRun,
/// `profile-rebuild` → `profile rebuild`.
ProfileRebuild,
/// `features-backfill` → `features backfill --days 30 --yes`.
FeaturesBackfill,
/// `backfill-social` → `backfill-social --days 7`.
BackfillSocial,
/// `features-prune` → `features prune`.
FeaturesPrune,
}
impl Job {
/// The catalogue in the order the Jobs page lists it.
pub const CATALOGUE: [Job; 6] = [
Job::Generate { date: None },
Job::DryRun,
Job::ProfileRebuild,
Job::FeaturesBackfill,
Job::BackfillSocial,
Job::FeaturesPrune,
];
/// `^[a-z0-9-]+$`: the only characters a job (and so a unit instance) name
/// may contain.
pub fn valid_name(name: &str) -> bool {
!name.is_empty()
&& name
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
}
/// Parse a catalogue name or the dated `generate-YYYY-MM-DD` form; anything
/// else (including `../x`, uppercase, unknown names) is `None`.
pub fn parse(name: &str) -> Option<Job> {
if !Self::valid_name(name) {
return None;
}
match name {
"generate" => Some(Job::Generate { date: None }),
"dry-run" => Some(Job::DryRun),
"profile-rebuild" => Some(Job::ProfileRebuild),
"features-backfill" => Some(Job::FeaturesBackfill),
"backfill-social" => Some(Job::BackfillSocial),
"features-prune" => Some(Job::FeaturesPrune),
_ => {
let date = name.strip_prefix("generate-")?;
// Exactly `YYYY-MM-DD`; the round trip rejects `2026-9-3`.
let parsed: Date = date.parse().ok()?;
(parsed.to_string() == date).then_some(Job::Generate { date: Some(parsed) })
}
}
}
pub fn name(&self) -> String {
match self {
Job::Generate { date: None } => "generate".into(),
Job::Generate { date: Some(date) } => format!("generate-{date}"),
Job::DryRun => "dry-run".into(),
Job::ProfileRebuild => "profile-rebuild".into(),
Job::FeaturesBackfill => "features-backfill".into(),
Job::BackfillSocial => "backfill-social".into(),
Job::FeaturesPrune => "features-prune".into(),
}
}
/// `daily-epub-job@<name>.service`.
pub fn unit(&self) -> String {
unit_for(&self.name())
}
pub fn description(&self) -> &'static str {
match self {
Job::Generate { date: None } => {
"Build and publish today's issue, exactly as the morning timer does."
}
Job::Generate { date: Some(_) } => {
"Build and publish the issue of a given date, republishing it if it exists."
}
Job::DryRun => "Run the whole pipeline but publish nothing and record no issue.",
Job::ProfileRebuild => {
"Regenerate the learned taste adjustments from ratings with the editor model."
}
Job::FeaturesBackfill => {
"Embed rated and recently published articles (30 days) and interests into the cache."
}
Job::BackfillSocial => "Re-poll social scores for the last 7 days of entries.",
Job::FeaturesPrune => {
"Drop stale embeddings, old candidate telemetry and old assessments per the retention config."
}
}
}
/// The run-lock name `main::lock_holder` uses for the same command, when
/// the job takes the lock at all.
pub fn takes_lock(&self) -> Option<&'static str> {
match self {
Job::Generate { .. } | Job::DryRun => Some("generate"),
Job::ProfileRebuild => Some("profile rebuild"),
Job::FeaturesBackfill => Some("features backfill"),
Job::BackfillSocial => Some("backfill-social"),
Job::FeaturesPrune => None,
}
}
/// Needs a confirmation dialog: `generate` republishes an issue.
pub fn dangerous(&self) -> bool {
matches!(self, Job::Generate { .. })
}
}
/// `daily-epub-job@<name>.service` for a validated job name.
pub fn unit_for(name: &str) -> String {
format!("{UNIT_PREFIX}{name}.service")
}
// ---------------------------------------------------------------------------
// `jobs` table lifecycle (§14.2, §14.4)
// ---------------------------------------------------------------------------
/// One `jobs` row.
#[derive(Debug, Clone)]
pub struct JobRow {
pub id: i64,
pub name: String,
pub unit: String,
pub requested_by: Option<i64>,
/// The requester's username, when the user still exists.
pub requested_by_name: Option<String>,
pub requested_at: String,
pub started_at: Option<String>,
pub finished_at: Option<String>,
pub status: String,
pub message: Option<String>,
pub run_id: Option<i64>,
}
impl JobRow {
pub fn is_active(&self) -> bool {
matches!(self.status.as_str(), "requested" | "running")
}
}
const ROW_SELECT: &str =
"SELECT j.id, j.name, j.unit, j.requested_by, u.username AS requested_by_name,
j.requested_at, j.started_at, j.finished_at, j.status, j.message, j.run_id
FROM jobs j LEFT JOIN users u ON u.id = j.requested_by";
fn row_from(row: &sqlx::sqlite::SqliteRow) -> JobRow {
JobRow {
id: row.get("id"),
name: row.get("name"),
unit: row.get("unit"),
requested_by: row.get("requested_by"),
requested_by_name: row.get("requested_by_name"),
requested_at: row.get("requested_at"),
started_at: row.get("started_at"),
finished_at: row.get("finished_at"),
status: row.get("status"),
message: row.get("message"),
run_id: row.get("run_id"),
}
}
/// One job by id.
pub async fn get(db: &Db, id: i64) -> Result<Option<JobRow>, sqlx::Error> {
let row = sqlx::query(sqlx::AssertSqlSafe(format!("{ROW_SELECT} WHERE j.id = ?")))
.bind(id)
.fetch_optional(db.pool())
.await?;
Ok(row.as_ref().map(row_from))
}
/// The newest `limit` jobs.
pub async fn list(db: &Db, limit: i64) -> Result<Vec<JobRow>, sqlx::Error> {
let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
"{ROW_SELECT} ORDER BY j.id DESC LIMIT ?"
)))
.bind(limit)
.fetch_all(db.pool())
.await?;
Ok(rows.iter().map(row_from).collect())
}
/// The id of a `requested`/`running` job for this unit, if any (the page
/// refuses a duplicate start, §14.4).
pub async fn active_for_unit(db: &Db, unit: &str) -> Result<Option<i64>, sqlx::Error> {
sqlx::query_scalar(
"SELECT id FROM jobs WHERE unit = ? AND status IN ('requested', 'running')
ORDER BY id DESC LIMIT 1",
)
.bind(unit)
.fetch_optional(db.pool())
.await
}
/// Insert a `requested` row (the dashboard, or `job run` when nobody asked).
pub async fn insert_requested(
db: &Db,
job: &Job,
requested_by: Option<i64>,
now: Timestamp,
) -> Result<i64, sqlx::Error> {
let row = sqlx::query(
"INSERT INTO jobs (name, unit, requested_by, requested_at, status)
VALUES (?, ?, ?, ?, 'requested') RETURNING id",
)
.bind(job.name())
.bind(job.unit())
.bind(requested_by)
.bind(fmt_ts(now))
.fetch_one(db.pool())
.await?;
Ok(row.get::<i64, _>("id"))
}
/// `job run`'s first step (§14.2): the newest `requested` row with this name,
/// or a fresh one with `requested_by NULL`, flipped to `running`.
pub async fn claim(db: &Db, job: &Job, now: Timestamp) -> Result<i64, sqlx::Error> {
let existing: Option<i64> = sqlx::query_scalar(
"SELECT id FROM jobs WHERE name = ? AND status = 'requested' ORDER BY id DESC LIMIT 1",
)
.bind(job.name())
.fetch_optional(db.pool())
.await?;
let id = match existing {
Some(id) => id,
None => insert_requested(db, job, None, now).await?,
};
sqlx::query("UPDATE jobs SET status = 'running', started_at = ? WHERE id = ?")
.bind(fmt_ts(now))
.bind(id)
.execute(db.pool())
.await?;
Ok(id)
}
/// Terminal state of a job.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Ok,
Failed,
}
impl Outcome {
pub fn as_str(self) -> &'static str {
match self {
Outcome::Ok => "ok",
Outcome::Failed => "failed",
}
}
}
/// Finish a job: status, `finished_at`, the one-line message and, for a
/// generate job, the run it produced.
pub async fn finish(
db: &Db,
id: i64,
outcome: Outcome,
message: &str,
run_id: Option<i64>,
now: Timestamp,
) -> Result<(), sqlx::Error> {
let message = message.split_whitespace().collect::<Vec<_>>().join(" ");
sqlx::query(
"UPDATE jobs SET status = ?, finished_at = ?, message = ?, run_id = ? WHERE id = ?",
)
.bind(outcome.as_str())
.bind(fmt_ts(now))
.bind(message)
.bind(run_id)
.bind(id)
.execute(db.pool())
.await?;
Ok(())
}
// ---------------------------------------------------------------------------
// SystemdRunner (§14.4)
// ---------------------------------------------------------------------------
/// Talks to systemd with `systemctl`/`journalctl` (10 s timeout each, stderr
/// in the error). Production runner when `server.jobs_enabled` is true.
#[derive(Debug, Clone)]
pub struct SystemdRunner {
timeout: Duration,
}
impl Default for SystemdRunner {
fn default() -> Self {
Self {
timeout: Duration::from_secs(10),
}
}
}
const SHOW_PROPERTIES: &str =
"ActiveState,SubState,Result,ExecMainStatus,ExecMainStartTimestamp,ExecMainExitTimestamp";
impl SystemdRunner {
async fn run(&self, program: &str, args: &[&str]) -> Result<String, String> {
let command = format!("{program} {}", args.join(" "));
let output = tokio::time::timeout(
self.timeout,
tokio::process::Command::new(program)
.args(args)
.kill_on_drop(true)
.output(),
)
.await
.map_err(|_| format!("{command}: timed out after {:?}", self.timeout))?
.map_err(|error| format!("{command}: {error}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
if output.status.success() {
Ok(stdout)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!(
"{command}: exit {}: {}",
output
.status
.code()
.map(|code| code.to_string())
.unwrap_or_else(|| "signal".into()),
stderr.trim()
))
}
}
}
/// Parse `systemctl show -p …` output (`Key=Value` lines).
pub fn parse_unit_status(text: &str) -> UnitStatus {
let mut status = UnitStatus::default();
for line in text.lines() {
let Some((key, value)) = line.split_once('=') else {
continue;
};
let value = value.trim();
match key.trim() {
"ActiveState" => status.active_state = value.to_string(),
"SubState" => status.sub_state = value.to_string(),
"Result" => status.result = value.to_string(),
"ExecMainStatus" => status.exit_status = value.parse().ok(),
"ExecMainStartTimestamp" => {
status.started = (!value.is_empty()).then(|| value.to_string())
}
"ExecMainExitTimestamp" => {
status.exited = (!value.is_empty()).then(|| value.to_string())
}
_ => {}
}
}
status
}
#[async_trait]
impl JobRunner for SystemdRunner {
async fn start(&self, unit: &str) -> Result<(), String> {
self.run("systemctl", &["start", "--no-block", unit])
.await
.map(|_| ())
}
async fn status(&self, unit: &str) -> Result<UnitStatus, String> {
self.run("systemctl", &["show", "-p", SHOW_PROPERTIES, unit])
.await
.map(|text| parse_unit_status(&text))
}
async fn log(&self, unit: &str, lines: usize) -> Result<String, String> {
let lines = lines.to_string();
self.run(
"journalctl",
&["-u", unit, "-n", &lines, "--no-pager", "-o", "short-iso"],
)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
const POLKIT_RULES: &str = include_str!("../systemd/50-daily-epub.rules");
const JOB_UNIT: &str = include_str!("../systemd/daily-epub-job@.service");
const SERVER_UNIT: &str = include_str!("../systemd/daily-epub.service");
const POLKIT_UNIT_REGEX: &str = r"/^daily-epub-job@[a-z0-9-]+\.service$/";
/// The polkit rule's regex, by hand: `^daily-epub-job@[a-z0-9-]+\.service$`.
fn polkit_regex_matches(unit: &str) -> bool {
unit.strip_prefix(UNIT_PREFIX)
.and_then(|rest| rest.strip_suffix(".service"))
.is_some_and(Job::valid_name)
}
#[test]
fn parse_accepts_the_catalogue_and_the_dated_form() {
for job in Job::CATALOGUE {
assert_eq!(Job::parse(&job.name()), Some(job), "{}", job.name());
}
assert_eq!(
Job::parse("generate-2026-09-03"),
Some(Job::Generate {
date: Some("2026-09-03".parse().unwrap())
})
);
assert_eq!(
Job::parse("generate-2026-09-03").unwrap().unit(),
"daily-epub-job@generate-2026-09-03.service"
);
assert_eq!(
Job::parse("generate").unwrap().takes_lock(),
Some("generate")
);
assert_eq!(Job::parse("features-prune").unwrap().takes_lock(), None);
assert!(Job::parse("generate-2026-09-03").unwrap().dangerous());
assert!(!Job::parse("dry-run").unwrap().dangerous());
}
#[test]
fn parse_rejects_traversal_uppercase_and_unknown_names() {
for name in [
"../x",
"Generate",
"GENERATE",
"generate ",
"",
"backup",
"generate-2026-9-3",
"generate-2026-13-01",
"generate-",
"generate-2026-09-03.service",
"dry_run",
"features-prune;rm",
] {
assert_eq!(Job::parse(name), None, "{name:?}");
}
}
#[test]
fn polkit_rule_is_present_and_matches_the_unit_names() {
assert!(
POLKIT_RULES.contains(POLKIT_UNIT_REGEX),
"the rule must carry the unit regex verbatim"
);
assert!(POLKIT_RULES.contains(r#"action.lookup("verb") == "start""#));
assert!(POLKIT_RULES.contains(r#"subject.user == "daily-epub""#));
assert!(POLKIT_RULES.contains("org.freedesktop.systemd1.manage-units"));
for job in Job::CATALOGUE {
assert!(polkit_regex_matches(&job.unit()), "{}", job.unit());
}
assert!(polkit_regex_matches(
&Job::parse("generate-2026-09-03").unwrap().unit()
));
for unit in [
"daily-epub-job@../x.service",
"daily-epub-job@.service",
"daily-epub-job@Generate.service",
"daily-epub.service",
"daily-epub-generate.service",
"daily-epub-job@generate.service.d",
] {
assert!(!polkit_regex_matches(unit), "{unit}");
}
}
#[test]
fn unit_files_carry_the_job_template_and_journal_group() {
assert!(JOB_UNIT.contains("Description=The Daily EPUB job %i"));
assert!(JOB_UNIT.contains(
"ExecStart=/usr/local/bin/daily-epub --config /etc/daily-epub/config.toml job run %i"
));
assert!(JOB_UNIT.contains("TimeoutStartSec=45min"));
assert!(JOB_UNIT.contains("ProtectSystem=strict"));
assert!(SERVER_UNIT.contains("SupplementaryGroups=systemd-journal"));
}
#[test]
fn unit_status_parses_systemctl_show_output() {
let status = parse_unit_status(
"ActiveState=inactive\nSubState=dead\nResult=exit-code\nExecMainStatus=1\n\
ExecMainStartTimestamp=Thu 2026-09-03 05:30:01 EDT\nExecMainExitTimestamp=\n",
);
assert_eq!(status.active_state, "inactive");
assert_eq!(status.sub_state, "dead");
assert_eq!(status.result, "exit-code");
assert_eq!(status.exit_status, Some(1));
assert_eq!(
status.started.as_deref(),
Some("Thu 2026-09-03 05:30:01 EDT")
);
assert_eq!(status.exited, None);
assert_eq!(parse_unit_status("garbage").active_state, "");
}
#[tokio::test]
async fn claim_reuses_the_requested_row_and_finish_records_the_run() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_and_migrate(&dir.path().join("jobs.db"))
.await
.unwrap();
let now: Timestamp = "2026-09-03T10:00:00Z".parse().unwrap();
let job = Job::Generate {
date: Some("2026-09-03".parse().unwrap()),
};
let requested = insert_requested(&db, &job, None, now).await.unwrap();
assert_eq!(
active_for_unit(&db, &job.unit()).await.unwrap(),
Some(requested)
);
let claimed = claim(&db, &job, now).await.unwrap();
assert_eq!(
claimed, requested,
"the requested row is claimed, not duplicated"
);
let row = get(&db, claimed).await.unwrap().unwrap();
assert_eq!(row.status, "running");
assert_eq!(row.started_at.as_deref(), Some("2026-09-03T10:00:00Z"));
assert!(row.is_active());
let run_id = db.start_run(job_date(&job), now).await.unwrap();
finish(
&db,
claimed,
Outcome::Ok,
"curation: done",
Some(run_id),
now,
)
.await
.unwrap();
let row = get(&db, claimed).await.unwrap().unwrap();
assert_eq!(row.status, "ok");
assert_eq!(row.run_id, Some(run_id));
assert_eq!(row.message.as_deref(), Some("curation: done"));
assert_eq!(row.finished_at.as_deref(), Some("2026-09-03T10:00:00Z"));
assert_eq!(active_for_unit(&db, &job.unit()).await.unwrap(), None);
// Nothing requested: `claim` inserts a row with no requester.
let fresh = claim(&db, &Job::FeaturesPrune, now).await.unwrap();
let row = get(&db, fresh).await.unwrap().unwrap();
assert_eq!(row.status, "running");
assert_eq!(row.requested_by, None);
assert_eq!(row.name, "features-prune");
assert_eq!(list(&db, 10).await.unwrap().len(), 2);
assert_eq!(list(&db, 10).await.unwrap()[0].id, fresh);
finish(
&db,
fresh,
Outcome::Failed,
"provider failed\n after retry",
None,
now,
)
.await
.unwrap();
assert_eq!(
get(&db, fresh).await.unwrap().unwrap().message.as_deref(),
Some("provider failed after retry"),
"job messages are always one line"
);
}
fn job_date(job: &Job) -> Date {
match job {
Job::Generate { date: Some(date) } => *date,
_ => panic!("expected a dated generate"),
}
}
}
+1
View File
@@ -23,6 +23,7 @@ pub mod extract;
pub mod html;
pub mod http;
pub mod images;
pub mod jobs;
pub mod lock;
pub mod miniflux;
pub mod pipeline;
+242 -22
View File
@@ -17,7 +17,7 @@ use daily_epub::db::Db;
use daily_epub::pipeline::{self, GenerateOptions, GenerateOutcome};
use daily_epub::report::{RunReport, VOYAGE_PROVIDER};
use daily_epub::types::{ArticleId, RatingEvent, Vote};
use daily_epub::{curate, http, lock, server, social};
use daily_epub::{curate, http, jobs, lock, server, social};
/// A personalized daily newspaper, delivered as an EPUB.
#[derive(Debug, Parser)]
@@ -61,6 +61,19 @@ enum Command {
/// Manage dashboard users without taking the pipeline run lock.
#[command(subcommand)]
Users(UsersCommand),
/// Operator jobs (what `daily-epub-job@<name>.service` runs).
#[command(subcommand)]
Job(JobCommand),
}
#[derive(Debug, Subcommand)]
enum JobCommand {
/// Run one catalogue job in-process and record it in the `jobs` table.
Run {
/// `generate`, `generate-YYYY-MM-DD`, `dry-run`, `profile-rebuild`,
/// `features-backfill`, `backfill-social` or `features-prune`.
name: String,
},
}
#[derive(Debug, Subcommand)]
@@ -328,7 +341,7 @@ async fn main() -> Result<()> {
}
Command::Profile(ProfileCommand::Rebuild) => {
let db = Db::open_and_migrate(&config.database_path).await?;
cmd_profile_rebuild(&config, &db).await?;
println!("{}", cmd_profile_rebuild(&config, &db).await?);
}
Command::Ratings(command) => {
let db = Db::open_and_migrate(&config.database_path).await?;
@@ -345,11 +358,11 @@ async fn main() -> Result<()> {
}
Command::Features(command) => {
let db = Db::open_and_migrate(&config.database_path).await?;
cmd_features(&config, &db, command).await?;
println!("{}", cmd_features(&config, &db, command).await?);
}
Command::BackfillSocial(args) => {
let db = Db::open_and_migrate(&config.database_path).await?;
cmd_backfill_social(&db, args.days).await?;
println!("{}", cmd_backfill_social(&db, args.days).await?);
}
Command::Db(DbCommand::Migrate) => {
let db = Db::open(&config.database_path).await?;
@@ -368,19 +381,44 @@ async fn main() -> Result<()> {
let db = Db::open_and_migrate(&config.database_path).await?;
cmd_users(&db, command).await?;
}
Command::Job(JobCommand::Run { name }) => {
let Some(job) = jobs::Job::parse(&name) else {
eprintln!("unknown job {name:?}; the catalogue is:");
for job in jobs::Job::CATALOGUE {
eprintln!(" {:<20} {}", job.name(), job.description());
}
eprintln!(
" {:<20} {}",
"generate-YYYY-MM-DD",
jobs::Job::Generate {
date: Some(jiff::civil::Date::default())
}
.description()
);
std::process::exit(2);
};
let db = Db::open_and_migrate(&config.database_path).await?;
cmd_job_run(&config, &db, &job).await?;
}
}
Ok(())
}
/// The commands that write the database and provider budgets and so hold the
/// run lock (§5): `generate`, `profile rebuild`, `features backfill`,
/// `backfill-social`. Everything else is read-only or its own writer.
/// `backfill-social`, and a `job run` of any of them. Everything else is
/// read-only or its own writer.
fn lock_holder(command: &Command) -> Option<&'static str> {
match command {
Command::Generate(_) => Some("generate"),
Command::Profile(ProfileCommand::Rebuild) => Some("profile rebuild"),
Command::Features(FeaturesCommand::Backfill(_)) => Some("features backfill"),
Command::BackfillSocial(_) => Some("backfill-social"),
// An unknown name takes no lock; the dispatch exits 2 before opening
// the database.
Command::Job(JobCommand::Run { name }) => {
jobs::Job::parse(name).and_then(|job| job.takes_lock())
}
Command::Serve
| Command::Ratings(_)
| Command::Explain(_)
@@ -603,7 +641,8 @@ fn print_lineup(issue: &daily_epub::types::Issue) {
// ---------------------------------------------------------------------------
/// `profile rebuild` runs on the editor when configured, else bulk (§14.3).
async fn cmd_profile_rebuild(config: &Config, db: &Db) -> Result<()> {
/// Returns the one-line summary the CLI prints and `job run` records.
async fn cmd_profile_rebuild(config: &Config, db: &Db) -> Result<String> {
use curate::llm::{Llms, provider_meters};
let profile = curate::profile::load_or_build(
db,
@@ -637,12 +676,11 @@ async fn cmd_profile_rebuild(config: &Config, db: &Db) -> Result<()> {
config.curation.feedback.verdicts_in_prompt,
)
.await?;
println!(
Ok(format!(
"taste profile rebuilt (version {}, {} chars)",
rebuilt.version,
rebuilt.text.len()
);
Ok(())
))
}
async fn resolve_rating_article(
@@ -765,7 +803,9 @@ async fn cmd_explain(db: &Db, args: ExplainArgs) -> Result<()> {
Ok(())
}
async fn cmd_features(config: &Config, db: &Db, command: FeaturesCommand) -> Result<()> {
/// `features backfill` / `features prune`; returns the final summary line
/// (progress lines are printed as they happen).
async fn cmd_features(config: &Config, db: &Db, command: FeaturesCommand) -> Result<String> {
match command {
FeaturesCommand::Backfill(args) => {
if !config.voyage.enabled {
@@ -788,8 +828,7 @@ async fn cmd_features(config: &Config, db: &Db, command: FeaturesCommand) -> Res
plan.cached
);
if plan.is_empty() {
println!("cache is warm; nothing to do");
return Ok(());
return Ok("cache is warm; nothing to do".into());
}
println!(
"estimate: ~{} tokens ≈ ${:.4} with {} at ${:.2}/M",
@@ -802,17 +841,16 @@ async fn cmd_features(config: &Config, db: &Db, command: FeaturesCommand) -> Res
&& !args.yes
&& !confirm("continue?")?
{
println!("aborted");
return Ok(());
return Ok("aborted".into());
}
let outcome = embedding::run_backfill(&service, &plan).await?;
println!(
Ok(format!(
"embedded {} articles and {} interests · {} tokens · ${:.4}",
outcome.articles_embedded,
outcome.interests_embedded,
outcome.tokens,
outcome.cost_usd
);
))
}
FeaturesCommand::Prune => {
let ranking = &config.curation.ranking;
@@ -823,17 +861,16 @@ async fn cmd_features(config: &Config, db: &Db, command: FeaturesCommand) -> Res
jiff::Timestamp::now(),
)
.await?;
println!(
Ok(format!(
"pruned {} embeddings older than {} days, {} candidate rows and {} assessments older than {} days",
pruned.embeddings,
ranking.embedding_retention_days,
pruned.telemetry,
pruned.assessments,
ranking.telemetry_retention_days
);
))
}
}
Ok(())
}
/// A y/N question on stdin; anything but a leading `y` is a no.
@@ -845,12 +882,94 @@ fn confirm(question: &str) -> Result<bool> {
Ok(answer.trim().to_lowercase().starts_with('y'))
}
async fn cmd_backfill_social(db: &Db, days: u32) -> Result<()> {
async fn cmd_backfill_social(db: &Db, days: u32) -> Result<String> {
let http = http::build_client(http::DEFAULT_TIMEOUT)?;
let enricher = social::SocialEnricher::new(http, db.clone());
let updated = enricher.backfill(days).await?;
println!("refreshed social scores for {updated} articles");
Ok(())
Ok(format!("refreshed social scores for {updated} articles"))
}
// ---------------------------------------------------------------------------
// `job run` (dashboard plan §14.2)
// ---------------------------------------------------------------------------
/// `job run <name>`: claim the newest `requested` row of this job (or insert
/// one when the unit was started by hand), run the mapped command in-process
/// with the same functions the plain subcommands use, and record `ok` /
/// `failed` with a one-line message and, for generate, the run id. A failure
/// propagates so the unit exits non-zero (`Result=exit-code`).
async fn cmd_job_run(config: &Config, db: &Db, job: &jobs::Job) -> Result<()> {
let id = jobs::claim(db, job, jiff::Timestamp::now())
.await
.context("claiming the jobs row")?;
tracing::info!(job = %job.name(), job_id = id, "job started");
let result = run_job(config, db, job).await;
let now = jiff::Timestamp::now();
match result {
Ok((message, run_id)) => {
jobs::finish(db, id, jobs::Outcome::Ok, &message, run_id, now)
.await
.context("recording the job outcome")?;
tracing::info!(job = %job.name(), job_id = id, %message, "job finished");
Ok(())
}
Err(error) => {
let message = format!("{error:#}");
jobs::finish(db, id, jobs::Outcome::Failed, &message, None, now)
.await
.context("recording the job failure")?;
tracing::error!(job = %job.name(), job_id = id, %message, "job failed");
Err(error)
}
}
}
/// The command each catalogue job maps to (§14.1), returning its one-line
/// message and the run id it produced.
async fn run_job(config: &Config, db: &Db, job: &jobs::Job) -> Result<(String, Option<i64>)> {
match job {
jobs::Job::Generate { date } => {
generate_job(config, db, date.map(|date| date.to_string()), false).await
}
jobs::Job::DryRun => generate_job(config, db, None, true).await,
jobs::Job::ProfileRebuild => Ok((cmd_profile_rebuild(config, db).await?, None)),
jobs::Job::FeaturesBackfill => {
let args = BackfillArgs {
days: 30,
rated_only: false,
all: false,
yes: true,
};
Ok((
cmd_features(config, db, FeaturesCommand::Backfill(args)).await?,
None,
))
}
jobs::Job::BackfillSocial => Ok((cmd_backfill_social(db, 7).await?, None)),
jobs::Job::FeaturesPrune => Ok((
cmd_features(config, db, FeaturesCommand::Prune).await?,
None,
)),
}
}
/// `generate [--date D] [--dry-run]` as a job: the `curation:` line is the
/// message, the run id links the job to its run.
async fn generate_job(
config: &Config,
db: &Db,
date: Option<String>,
dry_run: bool,
) -> Result<(String, Option<i64>)> {
let opts = GenerateOptions {
date,
dry_run,
..GenerateOptions::default()
};
let outcome = pipeline::generate(config, db, &opts).await?;
print_outcome(&outcome);
let [curation, ..] = outcome.report.info_block();
Ok((curation, Some(outcome.run_id)))
}
#[cfg(test)]
@@ -982,9 +1101,110 @@ mod tests {
vec!["db", "migrate"],
vec!["features", "prune"],
vec!["config", "check"],
vec!["job", "run", "features-prune"],
vec!["job", "run", "not-a-job"],
] {
assert_eq!(lock_holder(&parse(&args)), None, "{args:?}");
}
// `job run` takes the same lock as the command it maps to.
assert_eq!(
lock_holder(&parse(&["job", "run", "generate"])),
Some("generate")
);
assert_eq!(
lock_holder(&parse(&["job", "run", "generate-2026-09-03"])),
Some("generate")
);
assert_eq!(
lock_holder(&parse(&["job", "run", "dry-run"])),
Some("generate")
);
assert_eq!(
lock_holder(&parse(&["job", "run", "profile-rebuild"])),
Some("profile rebuild")
);
assert_eq!(
lock_holder(&parse(&["job", "run", "features-backfill"])),
Some("features backfill")
);
assert_eq!(
lock_holder(&parse(&["job", "run", "backfill-social"])),
Some("backfill-social")
);
}
#[test]
fn parses_job_run() {
match Cli::try_parse_from(["daily-epub", "job", "run", "features-prune"])
.unwrap()
.command
{
Command::Job(JobCommand::Run { name }) => assert_eq!(name, "features-prune"),
other => panic!("expected job run, got {other:?}"),
}
assert!(Cli::try_parse_from(["daily-epub", "job", "run"]).is_err());
assert!(Cli::try_parse_from(["daily-epub", "job"]).is_err());
}
/// Dashboard plan §17 "Jobs": `job run` flips the dashboard's `requested`
/// row to `running` and then `ok` with the command's message, in-process
/// and without systemd.
#[tokio::test]
async fn job_run_flips_requested_to_running_to_ok() {
use sqlx::Row as _;
let dir = tempfile::tempdir().unwrap();
let mut config = Config {
database_path: dir.path().join("jobs.db"),
..Config::default()
};
let db = Db::open_and_migrate(&config.database_path).await.unwrap();
let now = jiff::Timestamp::now();
let job = jobs::Job::FeaturesPrune;
let requested = jobs::insert_requested(&db, &job, None, now).await.unwrap();
assert_eq!(
jobs::get(&db, requested).await.unwrap().unwrap().status,
"requested"
);
cmd_job_run(&config, &db, &job).await.unwrap();
let row = jobs::get(&db, requested).await.unwrap().unwrap();
assert_eq!(row.status, "ok", "{row:?}");
assert!(row.started_at.is_some());
assert!(row.finished_at.is_some());
assert!(
row.message
.as_deref()
.unwrap_or_default()
.starts_with("pruned 0 embeddings"),
"{row:?}"
);
assert_eq!(row.run_id, None);
let rows: i64 = sqlx::query("SELECT COUNT(*) AS n FROM jobs")
.fetch_one(db.pool())
.await
.unwrap()
.get("n");
assert_eq!(rows, 1, "the requested row was claimed, not duplicated");
// A failing command records `failed` with the error and propagates it.
config.voyage.enabled = false;
let backfill = jobs::Job::FeaturesBackfill;
let error = cmd_job_run(&config, &db, &backfill).await.unwrap_err();
assert!(error.to_string().contains("voyage.enabled is false"));
let failed = jobs::list(&db, 1).await.unwrap().remove(0);
assert_eq!(failed.name, "features-backfill");
assert_eq!(failed.status, "failed");
assert_eq!(failed.requested_by, None, "started by hand: no requester");
assert!(
failed
.message
.as_deref()
.unwrap_or_default()
.contains("voyage.enabled is false"),
"{failed:?}"
);
}
#[test]
+27 -1
View File
@@ -101,6 +101,23 @@ impl AppState {
}
}
/// `new`, with a specific [`crate::web::JobRunner`]: `SystemdRunner` in
/// `serve`, a `MockRunner` in router tests (dashboard plan §14.4).
pub fn with_jobs(
db: Db,
config: Config,
config_path: Option<PathBuf>,
jobs: Arc<dyn crate::web::JobRunner>,
) -> Self {
let mut state = Self::new(db, config, config_path);
state.web = Arc::new(crate::web::WebState {
jobs,
started_at: Timestamp::now(),
config_mtime: std::sync::Mutex::new(None),
});
state
}
pub fn config(&self) -> Arc<Config> {
match self.config.read() {
Ok(config) => Arc::clone(&config),
@@ -199,7 +216,16 @@ pub async fn serve(
}
});
let app = router(AppState::new(db, config, config_path));
// The Jobs page starts `daily-epub-job@<name>.service` through systemd +
// polkit (§14); `server.jobs_enabled = false` swaps in the runner whose
// page says so.
let jobs: Arc<dyn crate::web::JobRunner> = if config.server.jobs_enabled {
Arc::new(crate::jobs::SystemdRunner::default())
} else {
tracing::info!("server.jobs_enabled is false; the Jobs page cannot start units");
Arc::new(crate::web::DisabledRunner)
};
let app = router(AppState::with_jobs(db, config, config_path, jobs));
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
+626 -1
View File
@@ -1,10 +1,635 @@
//! 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());
}
// TODO(step 5 merge): reload_if_changed — pick up a hand-edited config.toml
// before the unit starts (§4.2); step 5 adds the helper in web/mod.rs.
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())
}
}
+12 -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; }
@@ -107,3 +106,15 @@ details.explain { margin:1rem 0; }
.picks li { border-bottom:1px solid var(--rule); padding:.5rem 0; }
.budget meter { width:60%; max-width:14rem; height:.8rem; vertical-align:middle; margin-right:.5rem; }
.table-filter { margin:.5rem 0; padding:.3rem; width:20rem; max-width:100%; }
/* 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
@@ -57,3 +57,8 @@ document.querySelectorAll("table[data-filter]").forEach((table) => {
});
});
});
/* 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 %}