Web dashboard step 7: docs, users page, polish

Users page, README and config.example updates, implementation notes, the
rollout runbook, site-layout 404/500 pages, human-readable download sizes,
dark-mode and narrow-screen polish, and a smoke test over every dashboard
route; also removes zdiff3 ancestor markers left by earlier merges.

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:19:08 +00:00
co-authored by Claude Fable 5.1
parent 10cbe9ed79
commit eb960f1b57
16 changed files with 599 additions and 50 deletions
+41
View File
@@ -1185,4 +1185,45 @@ pub(crate) mod tests {
assert!(body.contains("2 runs · max $0.11"), "{body}");
assert!(!body.contains("style=\""), "no inline styles under the CSP");
}
#[tokio::test]
async fn every_dashboard_route_template_renders_with_fixture_data() {
let seed = seed().await;
sqlx::query(
"INSERT INTO jobs
(id, name, unit, requested_at, started_at, finished_at, status, message, run_id)
VALUES (99, 'features-prune', 'daily-epub-job@features-prune.service',
'2026-09-02T06:00:00Z', '2026-09-02T06:00:01Z',
'2026-09-02T06:00:02Z', 'ok', 'pruned fixture rows', ?)",
)
.bind(seed.run_id)
.execute(seed.db.pool())
.await
.unwrap();
let app = app_with_users(&seed.db).await;
let admin = login_cookie(&app, "admin", "correct horse battery").await;
let routes = vec![
"/dashboard".to_string(),
"/dashboard/runs".to_string(),
format!("/dashboard/runs/{}", seed.run_id),
"/dashboard/articles".to_string(),
"/dashboard/articles/1".to_string(),
"/dashboard/ratings".to_string(),
"/dashboard/ratings?tab=events".to_string(),
"/dashboard/profile".to_string(),
"/dashboard/stats?days=14".to_string(),
"/dashboard/settings".to_string(),
"/dashboard/settings/history".to_string(),
"/dashboard/jobs".to_string(),
"/dashboard/jobs/99".to_string(),
"/dashboard/users".to_string(),
];
for uri in routes {
let response = get(&app, &uri, Some(&admin)).await;
assert_eq!(response.status(), StatusCode::OK, "{uri}");
let body = response_text(response).await;
assert!(body.contains("<!doctype html>"), "{uri}: {body}");
assert!(body.contains("The Daily EPUB"), "{uri}: {body}");
}
}
}
+72 -2
View File
@@ -1,10 +1,80 @@
//! Dashboard: users pages. Filled in by web dashboard plan step 7.
//! Dashboard: the read-only Users page (`/dashboard/users`, plan §6.1).
use askama::Template;
use axum::Router;
use axum::extract::{Extension, State};
use axum::routing::get;
use axum_login::tower_sessions::Session;
use crate::server::AppState;
use crate::web::session::{AuthSession, Viewer};
use crate::web::{Html, Page, WebError, format_time, take_flash};
#[derive(Debug)]
struct UserLine {
username: String,
role: String,
disabled: bool,
created: String,
last_login: String,
open_sessions: i64,
}
#[derive(Template)]
#[template(path = "dashboard/users.html")]
struct UsersTemplate {
page: Page,
users: Vec<UserLine>,
}
/// Routes contributed by this page group (merged by `dashboard::router`).
pub fn routes() -> Router<AppState> {
Router::new()
Router::new().route("/dashboard/users", get(index))
}
async fn index(
State(state): State<AppState>,
auth: AuthSession,
Extension(session): Extension<Session>,
) -> Result<Html<UsersTemplate>, WebError> {
let viewer = auth.user().await.map(Viewer::from);
let config = state.config();
let users = crate::web::users::list(&state.db)
.await
.map_err(WebError::Internal)?
.into_iter()
.map(|row| UserLine {
username: row.user.username,
role: row.user.role.to_string(),
disabled: row.user.disabled,
created: format_time(row.user.created_at, &config),
last_login: row
.user
.last_login_at
.map(|at| format_time(at, &config))
.unwrap_or_else(|| "never".into()),
open_sessions: row.open_sessions,
})
.collect();
let mut page = Page::new("Users", viewer, "dashboard");
page.flash = take_flash(&session).await?;
Ok(Html(UsersTemplate { page, users }))
}
#[cfg(test)]
mod tests {
use crate::web::dashboard::tests::{app_with_users, assert_admin_only, seed};
#[tokio::test]
async fn users_page_is_admin_only_and_lists_accounts_and_sessions() {
let seed = seed().await;
let app = app_with_users(&seed.db).await;
let body = assert_admin_only(&app, "/dashboard/users").await;
assert!(body.contains("<h1>Users</h1>"), "{body}");
assert!(body.contains("reader"), "{body}");
assert!(body.contains("admin"), "{body}");
assert!(body.contains("Open sessions"), "{body}");
assert!(body.contains("daily-epub users"), "{body}");
}
}
+28 -1
View File
@@ -25,6 +25,7 @@ pub struct Download {
pub label: String,
pub href: String,
pub size_bytes: u64,
pub size: String,
}
#[derive(Debug, Clone)]
@@ -192,9 +193,26 @@ fn download(
label: label.to_string(),
href: format!("/files/{kind}/{}", crate::web::encode_component(name)),
size_bytes: metadata.len(),
size: format_file_size(metadata.len()),
})
}
fn format_file_size(bytes: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
const GB: f64 = MB * 1024.0;
let bytes_float = bytes as f64;
if bytes_float >= GB {
format!("{:.1} GB", bytes_float / GB)
} else if bytes_float >= MB {
format!("{:.1} MB", bytes_float / MB)
} else if bytes_float >= KB {
format!("{:.1} KB", bytes_float / KB)
} else {
format!("{bytes} B")
}
}
#[derive(Debug)]
struct FullEntry {
title: String,
@@ -1072,7 +1090,7 @@ mod tests {
Edition::Standard,
"epub",
));
std::fs::write(&standard, b"epub").unwrap();
std::fs::write(&standard, vec![0; 2 * 1024]).unwrap();
let mut config = crate::config::Config::default();
config.publish.epub_dir = epub_dir;
let app = crate::server::router(crate::server::AppState::new(db, config, None));
@@ -1089,10 +1107,19 @@ mod tests {
.unwrap();
let issue = response_text(issue).await;
assert!(issue.contains("Download EPUB"));
assert!(issue.contains("2.0 KB"));
assert!(!issue.contains("2048 bytes"));
assert!(!issue.contains("Download X4 EPUB"));
assert!(!issue.contains("Download XTC"));
}
#[test]
fn file_sizes_are_human_readable() {
assert_eq!(format_file_size(42), "42 B");
assert_eq!(format_file_size(1536), "1.5 KB");
assert_eq!(format_file_size(5 * 1024 * 1024), "5.0 MB");
}
#[tokio::test]
async fn rating_post_supports_json_forms_attribution_fallback_and_clear() {
let (_dir, db, source) = seeded_issue(true).await;
+54 -14
View File
@@ -244,7 +244,11 @@ impl<T: Template> IntoResponse for Html<T> {
.into_response(),
Err(error) => {
tracing::error!(%error, "rendering web template failed");
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
error_page_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Server error",
"The request could not be completed.",
)
}
}
}
@@ -276,6 +280,22 @@ struct ErrorTemplate {
message: String,
}
fn error_page_response(status: StatusCode, heading: &str, message: &str) -> Response {
let rendered = ErrorTemplate {
page: Page::new(heading, None, ""),
heading: heading.to_string(),
message: message.to_string(),
}
.render()
.unwrap_or_else(|_| message.to_string());
(
status,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
rendered,
)
.into_response()
}
impl IntoResponse for WebError {
fn into_response(self) -> Response {
if let Self::Unauthenticated { next } = self {
@@ -317,19 +337,7 @@ impl IntoResponse for WebError {
}
Self::Unauthenticated { .. } => unreachable!(),
};
let rendered = ErrorTemplate {
page: Page::new(heading, None, ""),
heading: heading.to_string(),
message: message.to_string(),
}
.render()
.unwrap_or_else(|_| message.to_string());
(
status,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
rendered,
)
.into_response()
error_page_response(status, heading, message)
}
}
@@ -475,6 +483,7 @@ pub fn router(config: &crate::config::Config) -> axum::Router<crate::server::App
.merge(account)
.merge(full_issues)
.merge(dashboard)
.fallback(|| async { WebError::NotFound })
}
async fn map_forbidden(request: Request, next: Next) -> Response {
@@ -1000,4 +1009,35 @@ mod tests {
.unwrap();
assert_eq!(cached.status(), StatusCode::NOT_MODIFIED);
}
#[tokio::test]
async fn not_found_and_server_error_pages_use_the_site_layout() {
let (_dir, state) = test_state(Config::default()).await;
let app = router(state);
let missing = app
.oneshot(
Request::builder()
.uri("/no-such-page")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
let missing = response_text(missing).await;
assert!(missing.contains("<!doctype html>"), "{missing}");
assert!(missing.contains("The Daily EPUB"), "{missing}");
assert!(missing.contains("That page does not exist"), "{missing}");
let failed = WebError::Internal(anyhow::anyhow!("fixture failure")).into_response();
assert_eq!(failed.status(), StatusCode::INTERNAL_SERVER_ERROR);
let failed = response_text(failed).await;
assert!(failed.contains("<!doctype html>"), "{failed}");
assert!(failed.contains("The Daily EPUB"), "{failed}");
assert!(
failed.contains("The request could not be completed"),
"{failed}"
);
assert!(!failed.contains("fixture failure"), "{failed}");
}
}
+21 -3
View File
@@ -39,7 +39,7 @@ label { display:grid; gap:.25rem; }
input,textarea,button { font:inherit; padding:.45rem; }
.error { color:var(--down); }
.dashboard { max-width:1200px; margin:2rem auto; font-family:system-ui,sans-serif; }
.scroll-x { overflow-x:auto; }
.scroll-x { max-width:100%; overflow-x:auto; overscroll-behavior-inline:contain; }
table { width:100%; border-collapse:collapse; font:0.9rem/1.35 system-ui,sans-serif; }
th,td { border-bottom:1px solid var(--rule); padding:.35rem .5rem; text-align:left; }
thead { position:sticky; top:0; background:var(--bg); }
@@ -106,7 +106,6 @@ 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%; }
||||||| 849231e
/* step 5: settings */
.settings .card { border:1px solid var(--rule); padding:.75rem 1rem; margin:1rem 0; }
.settings .card h2 { margin:.2rem 0 .6rem; font-size:1.1rem; font-family:ui-monospace,monospace; }
@@ -124,7 +123,6 @@ details.explain { margin:1rem 0; }
.settings .add-provider { max-width:36rem; }
.history pre { margin:0; white-space:pre-wrap; font-size:.8rem; }
@media (max-width:40rem) { .setting { display:block; } }
||||||| d36f203
/* step 6: jobs and stats */
.sparklines { display:grid; grid-template-columns:repeat(auto-fit,minmax(16rem,1fr)); gap:1rem 1.5rem; margin:1rem 0; }
.spark-figure { margin:0; min-width:0; } .spark-figure figcaption { font-size:.9rem; margin-bottom:.2rem; }
@@ -137,3 +135,23 @@ details.explain { margin:1rem 0; }
.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; }
/* step 7: dark-mode and narrow-screen polish */
.primary,.admin { display:flex; flex-wrap:wrap; justify-content:center; gap:.35rem 1rem; }
.masthead { line-height:1.15; overflow-wrap:anywhere; }
.scroll-x > table { min-width:max-content; }
input,textarea,select,button { max-width:100%; border-color:var(--rule); background:var(--bg); color:var(--fg); }
button { border-style:solid; }
input:focus-visible,textarea:focus-visible,select:focus-visible,button:focus-visible,a:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
code,pre,.message { overflow-wrap:anywhere; }
.prev-next a { overflow-wrap:anywhere; }
@media (max-width:40rem) {
body { padding-inline:.75rem; }
.masthead { margin-top:.75rem; padding-inline:.25rem; }
.primary,.admin { gap:.25rem .75rem; line-height:1.35; }
.dashboard,.reading { margin-block:1.25rem; }
.cards { grid-template-columns:minmax(0,1fr); }
.card { padding:.65rem .75rem; }
.prev-next { display:flex; flex-wrap:wrap; justify-content:space-between; }
.rating { align-items:stretch; }
.rating-prompt,.rating-note { flex-basis:100%; }
}
+1 -1
View File
@@ -56,7 +56,7 @@ document.querySelectorAll("table[data-filter]").forEach((table) => {
row.hidden = needle !== "" && !row.textContent.toLowerCase().includes(needle);
});
});
||||||| 849231e
});
/* step 5: settings — "reset to default" fills the field with its default */
document.addEventListener("click", (event) => {
const button = event.target.closest("button[data-reset]");
+2 -2
View File
@@ -1,6 +1,6 @@
{% if signals.empty %}<p class="muted">No signals recorded for this row (hygiene exclusion or thin telemetry).</p>{% else %}<div class="signals-body">
<table class="signals"><thead><tr><th>signal</th><th class="num">raw</th><th class="num">norm</th><th class="num">weight</th><th>present</th></tr></thead>
<tbody>{% for line in signals.lines %}<tr{% if !line.present %} class="muted"{% endif %}><td>{{ line.name }}</td><td class="num">{{ line.raw }}</td><td class="num">{{ line.norm }}</td><td class="num">{{ line.weight }}</td><td>{% if line.present %}yes{% else %}absent{% endif %}</td></tr>{% endfor %}</tbody></table>
<div class="scroll-x"><table class="signals"><thead><tr><th>signal</th><th class="num">raw</th><th class="num">norm</th><th class="num">weight</th><th>present</th></tr></thead>
<tbody>{% for line in signals.lines %}<tr{% if !line.present %} class="muted"{% endif %}><td>{{ line.name }}</td><td class="num">{{ line.raw }}</td><td class="num">{{ line.norm }}</td><td class="num">{{ line.weight }}</td><td>{% if line.present %}yes{% else %}absent{% endif %}</td></tr>{% endfor %}</tbody></table></div>
<p class="muted">Preliminary blend {{ signals.blend }}{% if let Some(cos) = signals.top1_cos %} · interest top-1 cosine {{ cos }}{% endif %}{% if signals.exploration %} · <span class="badge">exploration</span>{% endif %}{% if signals.auto_include %} · <span class="badge">auto-include</span>{% endif %}</p>
{% if !signals.top_interests.is_empty() %}<p><strong>Top interests</strong></p><ul>{% for interest in signals.top_interests %}<li>{{ interest.name }} <span class="muted">· z {{ interest.z }} · cos {{ interest.cos }}</span></li>{% endfor %}</ul>{% endif %}
{% if !signals.neighbours.is_empty() %}<p><strong>Nearest rated neighbours</strong></p><ul>{% for neighbour in signals.neighbours %}<li><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span> <a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a> <span class="muted">· cos {{ neighbour.cos }}</span></li>{% endfor %}</ul>{% endif %}
+15
View File
@@ -0,0 +1,15 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard">
<h1>Users</h1>
<p class="muted">Accounts are read-only here. Create, change, disable, enable, or sign out users with the <code>daily-epub users</code> CLI on the server.</p>
<div class="scroll-x"><table data-filter>
<thead><tr><th>Username</th><th>Role</th><th>Status</th><th>Created</th><th>Last login</th><th class="num">Open sessions</th></tr></thead>
<tbody>{% for user in users %}<tr>
<td>{{ user.username }}</td>
<td><span class="badge">{{ user.role }}</span></td>
<td>{% if user.disabled %}<span class="badge down">disabled</span>{% else %}<span class="badge loved">enabled</span>{% endif %}</td>
<td>{{ user.created }}</td>
<td>{{ user.last_login }}</td>
<td class="num">{{ user.open_sessions }}</td>
</tr>{% endfor %}{% if users.is_empty() %}<tr><td colspan="6">No users. Bootstrap an admin with <code>daily-epub users add &lt;username&gt; --admin</code>.</td></tr>{% endif %}</tbody>
</table></div>
</section>{% endblock %}
+1 -1
View File
@@ -4,7 +4,7 @@
<hr class="rule">
<h1 class="kicker">The Brief</h1>
<div class="editorial">{{ front_page_html|safe }}</div>
{% if !downloads.is_empty() %}<p class="downloads">{% for download in downloads %}<a class="button" href="{{ download.href }}">Download {{ download.label }} <small>({{ download.size_bytes }} bytes)</small></a>{% endfor %}</p>{% endif %}
{% if !downloads.is_empty() %}<p class="downloads">{% for download in downloads %}<a class="button" href="{{ download.href }}">Download {{ download.label }} <small>({{ download.size }})</small></a>{% endfor %}</p>{% endif %}
<hr class="rule">
<h1>In This Issue</h1>
{% for section in sections %}<section><h2>{{ section.name }}</h2><ul class="index-list">{% for entry in section.entries %}<li class="index-entry">
+1 -1
View File
@@ -2,6 +2,6 @@
{% if empty %}<h1>No issue yet</h1><p>The first issue has not been published.</p>{% else %}
<p class="dateline">{{ issue.display_date }} · No. {{ issue.issue_number }}</p><p class="stats">{{ issue.stats_line }}</p>
<p class="strap">A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.</p>
{% if !downloads.is_empty() %}<p class="downloads">{% for download in downloads %}<a href="{{ download.href }}">{{ download.label }} ({{ download.size_bytes }} bytes)</a>{% endfor %}</p>{% endif %}
{% if !downloads.is_empty() %}<p class="downloads">{% for download in downloads %}<a href="{{ download.href }}">{{ download.label }} ({{ download.size }})</a>{% endfor %}</p>{% endif %}
{% for section in issue.sections %}<section><h2>{{ section.name }}</h2>{% for entry in section.entries %}<article{% if entry.is_lead %} class="lead"{% endif %}><h3><a href="{{ entry.url }}">{{ entry.title }}</a></h3><p class="byline">{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }} ({{ entry.domain }}) · {{ entry.reading_minutes }} min · {{ entry.word_count }} words</p>{% if !entry.comment_links.is_empty() %}<p class="comments">{% for link in entry.comment_links %}<a rel="noopener" target="_blank" href="{{ link.url }}">{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}</a>{% endfor %}</p>{% endif %}</article>{% endfor %}</section>{% endfor %}
<p><a href="/issues">Browse the archive</a></p>{% endif %}</article>{% endblock %}
+2 -2
View File
@@ -10,8 +10,8 @@
</head>
<body>
<header class="masthead"><a href="/">The Daily EPUB</a></header>
<nav class="primary"><a href="/">Latest</a> · <a href="/issues">Archive</a> · <a href="/feed.xml">Feed</a> · {% match page.viewer %}{% when Some with (viewer) %}<a href="/account">{{ viewer.username }}</a>{% when None %}<a href="/login">Sign in</a>{% endmatch %}</nav>
{% if page.is_admin() %}<nav class="admin"><a href="/dashboard">Overview</a> · <a href="/dashboard/runs">Runs</a> · <a href="/dashboard/articles">Articles</a> · <a href="/dashboard/ratings">Ratings</a> · <a href="/dashboard/profile">Profile</a> · <a href="/dashboard/stats">Stats</a> · <a href="/dashboard/jobs">Jobs</a> · <a href="/dashboard/settings">Settings</a> · <a href="/dashboard/users">Users</a></nav>{% endif %}
<nav class="primary" aria-label="Site"><a href="/">Latest</a><a href="/issues">Archive</a><a href="/feed.xml">Feed</a>{% match page.viewer %}{% when Some with (viewer) %}<a href="/account">{{ viewer.username }}</a>{% when None %}<a href="/login">Sign in</a>{% endmatch %}</nav>
{% if page.is_admin() %}<nav class="admin" aria-label="Dashboard"><a href="/dashboard">Overview</a><a href="/dashboard/runs">Runs</a><a href="/dashboard/articles">Articles</a><a href="/dashboard/ratings">Ratings</a><a href="/dashboard/profile">Profile</a><a href="/dashboard/stats">Stats</a><a href="/dashboard/jobs">Jobs</a><a href="/dashboard/settings">Settings</a><a href="/dashboard/users">Users</a></nav>{% endif %}
{% match page.flash %}{% when Some with (flash) %}<div class="flash {{ flash.kind }}" role="status">{{ flash.text }}</div>{% when None %}{% endmatch %}
<main>{% block content %}{% endblock %}</main>
<footer>daily-epub {{ page.version }}</footer>