Web dashboard v2 step 3: dashboard restyle

Restyle every template under src/web/templates/dashboard/ plus the shared
partials _candidate_row.html, _signals_table.html and _pagination.html with
Tailwind utilities and the design system step 2 built, so the dashboard reads
as the sans-serif instrument-panel half of the same publication as the reader
pages: a page header on every page, stat tiles, compact tables with sticky
heads inside .scroll-x, tinted badges, one inline filter form per page,
settings groups with a sticky save bar, job cards, a scrolling journal block
and a rating widget that fits inside a table cell.

tailwind.css gains the dashboard vocabulary (.page-head/.page-desc/
.page-actions, .tiles/.tile*, .cell-wrap, .scroll-x.tall, .filters,
.pager/.tabs, .setting*/.save-bar, .disclosure, .sparklines/.spark-figure,
td .rating overrides, tr.superseded) plus a styled <meter> and a disabled
control state. Renames class="inline" to form-inline: Tailwind emits an
.inline display utility that was beating form.inline's layout.

Three small backend changes: job cards show the status and time of their last
run (attach_last_runs, no extra query, unit-tested); the Stats page passes
"stats" as its nav key so the admin nav no longer highlights Overview; and the
settings load-error banner drops its "! " prefix, with its assertion updated.

cargo test: 477 passed. npm run css:check clean.

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 21:30:09 +00:00
co-authored by Claude Fable 5.1
parent 29c66da865
commit a4c338a9f9
24 changed files with 665 additions and 259 deletions
+71 -2
View File
@@ -45,6 +45,12 @@ struct JobCard {
/// The `generate` card carries the date input for `generate-YYYY-MM-DD`.
dated: bool,
lock: Option<&'static str>,
/// Status of the most recent job started from this card, when there is one.
last_status: Option<String>,
/// When that job was requested, for the card's "last run" line.
last_requested: Option<String>,
/// Its id, so the card links straight to the job page.
last_id: Option<i64>,
}
#[derive(Debug, Clone)]
@@ -95,10 +101,29 @@ fn cards() -> Vec<JobCard> {
dangerous: job.dangerous(),
dated: matches!(job, Job::Generate { .. }),
lock: job.takes_lock(),
last_status: None,
last_requested: None,
last_id: None,
})
.collect()
}
/// `rows` is newest first, so the first row whose name is the card's name (or
/// `<name>-<date>` for the dated `generate` card) is that card's last run.
fn attach_last_runs(cards: &mut [JobCard], rows: &[JobLine]) {
for card in cards.iter_mut() {
let prefix = format!("{}-", card.name);
if let Some(row) = rows
.iter()
.find(|row| row.name == card.name || row.name.starts_with(&prefix))
{
card.last_status = Some(row.status.clone());
card.last_requested = Some(row.requested.clone());
card.last_id = Some(row.id);
}
}
}
fn job_line(row: &JobRow, config: &Config) -> JobLine {
JobLine {
id: row.id,
@@ -140,11 +165,14 @@ async fn jobs_template(
.date()
.to_string()
});
let jobs: Vec<JobLine> = rows.iter().map(|row| job_line(row, &config)).collect();
let mut cards = cards();
attach_last_runs(&mut cards, &jobs);
Ok(JobsTemplate {
page,
jobs_enabled: config.server.jobs_enabled,
cards: cards(),
jobs: rows.iter().map(|row| job_line(row, &config)).collect(),
cards,
jobs,
today,
})
}
@@ -352,6 +380,47 @@ mod tests {
use crate::web::MockRunner;
use crate::web::dashboard::tests::{assert_admin_only, get, login_cookie, response_text};
fn job_line_named(id: i64, name: &str, status: &str) -> JobLine {
JobLine {
id,
name: name.into(),
requested_by: "admin".into(),
requested: "2026-09-03 01:00".into(),
started: String::new(),
finished: String::new(),
duration: String::new(),
status: status.into(),
message: None,
run_id: None,
}
}
#[test]
fn cards_take_their_last_run_from_the_newest_matching_job() {
let mut cards = cards();
// Newest first, the way `jobs::list` returns them.
let rows = vec![
job_line_named(4, "generate-2026-09-03", "running"),
job_line_named(3, "profile-rebuild", "ok"),
job_line_named(2, "generate-2026-09-02", "ok"),
];
attach_last_runs(&mut cards, &rows);
let generate = cards.iter().find(|card| card.name == "generate").unwrap();
assert_eq!(generate.last_id, Some(4), "the dated job matches its card");
assert_eq!(generate.last_status.as_deref(), Some("running"));
let rebuild = cards
.iter()
.find(|card| card.name == "profile-rebuild")
.unwrap();
assert_eq!(rebuild.last_id, Some(3));
let never = cards
.iter()
.find(|card| card.last_id.is_none())
.expect("a catalogue job with no recorded run");
assert!(never.last_status.is_none());
}
async fn app_with_runner(
config: Config,
runner: Arc<dyn crate::web::JobRunner>,
+1 -1
View File
@@ -2495,7 +2495,7 @@ mod tests {
)
.await;
assert!(
page.contains("! config.toml on disk does not load:"),
page.contains("config.toml on disk does not load:"),
"{page}"
);
assert!(page.contains("value=\"31\""), "{page}");
+1 -1
View File
@@ -406,7 +406,7 @@ async fn stats(
})
.collect();
let mut page = Page::new("Stats", viewer, "dashboard");
let mut page = Page::new("Stats", viewer, "stats");
page.flash = take_flash(&session).await?;
Ok(Html(StatsTemplate {
page,