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
@@ -221,3 +221,59 @@ implementer needs that are easy to get wrong:
`assessment_reuse_days`, `--rescore` ignores it, and `admit::hygiene` never treats it as a low
score. Because a recovered article's row carries the editor's model, reuse accepts rows whose
`model` is either configured model (`triage::reusable_models`).
## Web dashboard (2026-09-03)
Verified on the production host and against the implemented dependency graph on 2026-09-03:
- **Host authorization and units:** polkit 124 supports JavaScript rules. The installed rule must
grant user `daily-epub` only `org.freedesktop.systemd1.manage-units`, verb `start`, for units
matching `^daily-epub-job@[a-z0-9-]+\.service$`. `daily-epub.service` now needs
`SupplementaryGroups=systemd-journal` to read job logs and `/etc/daily-epub` in
`ReadWritePaths` to atomically replace `config.toml`. The job template deliberately omits
`MemoryDenyWriteExecute` because generate jobs can launch Node's JIT; the server retains it.
- **Settings writes:** direct `toml_edit 0.25` performs typed, comment/order-preserving updates.
A candidate file is loaded through `Config::load` before its permissions are copied and it is
renamed over the original. The process therefore needs write access to the containing
directory, not only the file. Every changed dotted key is written to `config_changes`.
- **Authentication stack:** `axum-login` is pinned to git revision
`151c72d7a1b4646830f86b4332e6bd6e34d719a7`, whose graph contains `tower-sessions 0.15`.
The local `SqliteSessionStore` uses this crate's existing sqlx 0.9 pool because the published
tower SQLx store is incompatible. `password-auth 1.0` supplies Argon2id PHC hashes;
`tower_governor 0.8` throttles login POSTs. Its smart IP extractor trusts forwarded headers,
so production must bind to loopback and accept traffic only from the configured reverse proxy.
- **Test environment:** router tests use `tower::ServiceExt::oneshot`, temp SQLite databases and
`MockRunner`; they do not bind or invoke systemd. This sandbox forbids loopback listeners, so
the four `curate::llm::tests::anthropic_*` tests, the three OpenAI tests using the same fake
listener, `extract::tests::relative_urls_resolve_against_the_url_we_landed_on`, the five
listener-based `server::tests::*`, and `tests/m7_server.rs` are filtered only for sandbox runs.
The complete suite is expected to run outside the sandbox.
Implementation-time decisions recorded while landing dashboard steps 17:
- `/files/*` remains public when Basic auth is not configured, preserving existing OPDS
acquisition behavior. With Basic auth configured, either valid Basic credentials or any valid
web session authorizes a download. This intentionally resolves the plan's conflicting request
to redirect unauthenticated downloads in favor of its compatibility acceptance criterion.
- Final reports are attached to an issue after `finish_run`, when publish timing and status are
complete. Issue snapshots omit article bodies and rehydrate them from `articles`; old rows use
the reduced fallback renderer. The CLI password prompt uses `rpassword` after it became
available to the orchestrator.
- Flash handlers extract the exact tower session installed by the auth layer through
`Extension<Session>`. Dashboard `down` forms persist the established `not_for_me` label.
Ratings-page feed credit includes rating decay because that is what `signals::feed_rates`
actually uses; the stored prompt verdict count is inferred from the prompt text because
`TasteProfile.verdicts` is not persisted.
- Dynamic list SQL is assembled only from fixed fragments and allow-listed sort/filter names,
wrapped in sqlx 0.9's `AssertSqlSafe`; all user values remain bound parameters. Funnel bars
count rows that reached each stage because a row stores the stage where it stopped. SVG/meter
attributes replace inline styles under the CSP. Article pages omit the nominal extract method
because database reconstruction currently hard-codes it and would display misleading data.
- Shipped providers cannot be removed: deleting one would cause `Config::default()` to restore
it. They remain editable and may be unreferenced; custom providers are removable. An absent
setting already equal to its submitted default stays absent. Settings that are captured while
building the server, session, or throttle layers carry restart notices.
- A job start reloads a hand-edited config before inserting and starting the unit, retaining the
last-good config if reload fails. The offline lifecycle test uses `features-prune`; generate
run-id linkage is covered separately. CLI duration statistics retain finished dry runs for
byte-identical output, while dashboard run series and overview sparklines exclude them.
@@ -0,0 +1,96 @@
# Step 7 handoff — docs, users page, and polish
## Landed
- Added the admin-only, read-only `/dashboard/users` page with username, role, enabled/disabled
status, created time, last login, and unexpired session count. The page directs edits to the
existing `daily-epub users` CLI.
- Expanded the README with the public/user/admin boundary, user lifecycle commands, complete
route table, systemd+polkit Jobs setup, comment-preserving Settings behavior, environment
locks, shipped-provider removal limitation, public `/files/*` behavior without Basic auth,
and the `X-Forwarded-For`/loopback trust boundary. Updated `[server]` comments in
`config.example.toml` to match.
- Added the dated Web dashboard section to the implementation notes: polkit 124 and its narrow
rule, service-unit changes, the `toml_edit` write path, the pinned auth/session/password/
throttle stack, sandbox test caveats, and the implementation-time decisions from steps 16.
- Added `docs/runbooks/web-dashboard-rollout.md`, with the plan's nine production rollout steps,
commands, permissions, public-files compatibility note, and smoke checks.
- Completed layout polish: Atom discovery and favicon remain in `<head>`, Users remains in the
admin nav, both navs now wrap cleanly, narrow cards/forms/rating controls collapse, dark-mode
form controls use the site colors, focus states are visible, and all wide tables (including
the nested signals table and new users table) sit in `.scroll-x`.
- Unknown routes and template-render failures now use the site-layout 404/500 pages. Issue
download buttons show human-readable B/KB/MB/GB sizes.
- Removed stale diff3 ancestor markers from the merged CSS/JavaScript and restored the missing
table-filter closure; `node --check src/web/static/app.js` passes.
- Added the users guard/content test, file-size tests, site-layout error test, and one
fixture-backed router smoke test that renders every dashboard route template.
## Acceptance criteria walk-through
1. **Public issue and Atom boundary — met.** `PublicIssue` cannot carry generated/body fields;
existing fixture tests assert titles/authors/sources/comment links/metadata are present and
private Brief/summary/why/body/comment/World text is absent. The feed parses as XML and uses
the same public view.
2. **Complete signed-in issue — met.** Existing router tests cover Brief, summaries, why lines,
article bodies/discussion, World and Behind pages, reduced pre-snapshot fallback, and
existence-gated downloads. All three artifact slots share the same loader; sizes are now
human-readable.
3. **Admin-only ratings — met.** Whole-router role guards and rating tests cover anonymous,
user, and admin outcomes, attributed `source = 'dashboard'` events, clear events, and the
unchanged HMAC path.
4. **Article history/explainability — met.** The articles list/detail routes expose the latest
stage plus full run history, reasons, signals, assessments/facets, utility/cluster/admission,
editor why, issue appearances, neighbours, embedding metadata, ratings, and text explain;
allow-listed query tests and fixture rendering pass.
5. **Run history — met.** Run list/detail renders the cumulative funnel and reasons, admission,
preference state, timings, provider costs, warnings, feeds, near misses, config diff, and
candidate table; seeded counts, sorts, filters, links, and sparklines are tested.
6. **Ratings contributions/history — met.** Current and events tabs cover decay, decayed feed
credit, neighbour weight, prompt/rebuild membership, last-run use, append-only edits/clear,
annotations, filters, and superseded history with hand-checked tests.
7. **Settings — met.** The schema/default/help/env/secret tests cover every config leaf. Writer
tests cover typed changes, comments/order/mode preservation, validation before rename,
history, providers, and reload-on-mtime. The README records why shipped providers cannot be
removed.
8. **Profile — met.** Save/restore/version tests cover the atomic editor and parsed preview;
the page shows OPML interests, prompt, learned adjustments/staleness, and the rebuild job.
9. **Jobs — met.** Fixed-name parsing, polkit/unit files, MockRunner starts/status/logs, duplicate
refusal, failure persistence, lifecycle/run linkage, journal tail, reload-before-start, and
overview links are present and tested. Pipeline work remains in the separate job unit.
10. **Session/security stack — met.** The pinned axum-login/tower-sessions store,
password-auth hashes, cookie properties, session invalidation, origin checks, login throttle,
CSP/security headers, safe redirect checks, and secret redaction have focused tests.
11. **Existing routes — met.** HMAC, OPDS, files, health, and reports remain wired; issue reports
are populated. The reviewed compatibility decision keeps files public only when Basic auth
is absent, while configured Basic auth or a valid session still gates them.
12. **Test coverage — met, with sandbox execution caveat.** All new router tests use `oneshot`;
no new test binds or invokes systemd. The filtered full run passed every runnable target.
The orchestrator should run the complete suite outside this listener-restricted sandbox.
## Prior handoff follow-ups
- Step 1's `rpassword` and direct `toml_edit 0.25` follow-ups were already resolved by the
orchestrator; its public-files deviation is now documented.
- Step 2's raw download sizes are resolved here.
- Step 3's Jobs/ratings targets and overview sparklines, Step 4's settings anchors and profile
rebuild action, and Steps 56's config reload plus combined unit changes are all present after
the merged steps and were included in the smoke/audit pass.
- Nothing remains open against §20. Production deployment itself is intentionally left to the
operator following the new runbook. Plan §21's product deferrals remain intentional: per-user
personalization, OPML editing, body FTS, SSE logs, passkeys, public why lines, old snapshot
backfill, and charts beyond the current sparklines.
## Verification
- `cargo fmt`: pass.
- `cargo clippy --all-targets -- -D warnings`: pass.
- `node --check src/web/static/app.js`: pass.
- Focused new router/size/error tests: pass.
- Raw `cargo test`: **426 passed, 13 failed** before Cargo stopped at the library target; every
failure was `PermissionDenied` while binding a loopback listener (the four documented
Anthropic tests, three OpenAI tests using the same helper, the extractor test, and five server
tests).
- `cargo test` with those 13 listener tests plus the two `tests/m7_server.rs` TCP tests filtered:
**460 passed, 0 failed, 15 filtered out** across library, binary, integration, and doc targets.
+137
View File
@@ -0,0 +1,137 @@
# Runbook — web dashboard rollout
**Written:** 2026-09-03 for the production host. Run these commands from the checked-out
`web-dashboard` tree as an operator with `sudo`. The existing curation-v2 migration should
already be complete.
## 1. Build and install
```sh
cargo test
cargo build --release
sudo install -m0755 target/release/daily-epub /usr/local/bin/daily-epub
daily-epub --help
```
Confirm the help includes both `users` and `job`. Keep the previous binary available until the
smoke tests below pass.
## 2. Install the job unit and polkit rule
```sh
sudo install -m0644 systemd/daily-epub-job@.service /etc/systemd/system/
sudo install -m0644 systemd/50-daily-epub.rules /etc/polkit-1/rules.d/
sudo install -m0644 systemd/daily-epub.service /etc/systemd/system/
sudo systemctl daemon-reload
```
The server unit must contain both:
```ini
SupplementaryGroups=systemd-journal
ReadWritePaths=/home/thallada/bookorbit/books/daily-epub /var/lib/daily-epub/xtc /etc/daily-epub
```
Keep the two publish paths aligned with `[publish]`. The polkit rule permits user `daily-epub`
to start only `daily-epub-job@[a-z0-9-]+.service`; it does not grant stop, restart, or arbitrary
unit management.
## 3. Verify writable paths and ownership
Settings saves create a temporary file and rename it into `/etc/daily-epub`, so the directory
and config file must both belong to the service account:
```sh
sudo chown daily-epub:daily-epub /etc/daily-epub /etc/daily-epub/config.toml
sudo chmod 0750 /etc/daily-epub
sudo chmod 0640 /etc/daily-epub/config.toml
sudo install -d -m0750 -o daily-epub -g daily-epub /var/lib/daily-epub/data
sudo test -f /var/lib/daily-epub/data/profile.md
sudo -u daily-epub test -w /var/lib/daily-epub/data/profile.md
```
Set `profile_path = "/var/lib/daily-epub/data/profile.md"`; `StateDirectory=daily-epub` makes
that location writable. Also re-check the configured publish directories as `daily-epub`.
## 4. Review the new server settings
All keys have defaults, so additions are optional unless production needs overrides:
```toml
[server]
bind = "127.0.0.1:3499"
public_url = "https://daily.hallada.net"
session_days = 30
login_attempts = 10
login_window_minutes = 15
jobs_enabled = true
journal_lines = 300
```
Keep the bind address on loopback: the login throttle trusts `X-Forwarded-For`, so only the
reverse proxy should be able to reach the listener. Keep secrets in `/etc/daily-epub/env`, not
TOML. If `server.basic_auth_user` and `server.basic_auth_pass` are omitted, the existing
`/opds/*` and `/files/*` routes remain public; this is intentional so OPDS acquisition links
continue to work. When Basic auth is configured, signed-in users can still download files with
their session.
## 5. Restart, migrate, and verify public pages
```sh
sudo systemctl restart daily-epub.service
sudo systemctl --no-pager --full status daily-epub.service
curl -fsS https://daily.hallada.net/ | grep -F 'The Daily EPUB'
curl -fsS https://daily.hallada.net/feed.xml >/tmp/daily-epub-feed.xml
python3 -c 'import xml.etree.ElementTree as E; E.parse("/tmp/daily-epub-feed.xml")'
```
Migration `0004_web.sql` applies automatically at start. Anonymous issue pages and the Atom
feed must show the source-link index without summaries, article bodies, comments, the Brief, or
World Briefing text.
## 6. Bootstrap the administrator
```sh
sudo -u daily-epub /usr/local/bin/daily-epub \
--config /etc/daily-epub/config.toml users add tyler --admin
sudo -u daily-epub /usr/local/bin/daily-epub \
--config /etc/daily-epub/config.toml users list
```
The first command prompts twice without echo. Log in at `https://daily.hallada.net/login`, open
`/dashboard`, and confirm `/dashboard/users` shows the admin and an open session. A non-admin
account should receive the site's 403 page for every dashboard route.
## 7. Smoke test a job
Start `features-prune` from `/dashboard/jobs`, open its job detail, and watch the status and log
tail. On the host, confirm the same unit:
```sh
systemctl status daily-epub-job@features-prune.service
journalctl -u daily-epub-job@features-prune.service -n 100 --no-pager
```
If the page reports a permission failure, inspect `journalctl -u polkit` and verify the installed
rule and exact unit name. Do not broaden the rule to arbitrary units.
## 8. Smoke test an in-place settings write
In `/dashboard/settings`, change `curation.ranking.utility_protected` by one and save. Confirm
the next view shows the new value, comments and ordering remain in the file, and the attributed
change appears at `/dashboard/settings/history`:
```sh
sudo -u daily-epub grep -n 'utility_protected' /etc/daily-epub/config.toml
```
Change the value back through the page and confirm the second history row. Environment-overridden
fields should be locked, and no API key, HMAC secret, or Basic-auth password should be displayed.
## 9. Choose the history window
Candidate and assessment history is pruned after
`curation.ranking.telemetry_retention_days` (180 by default). Raise it now if the dashboard
should retain a longer article/run history, then reload `/dashboard/settings` and verify the
effective value. This changes future pruning only; it cannot restore rows already deleted.