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:
@@ -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>,
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user