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:
@@ -30,6 +30,7 @@ Gemini (or anything OpenAI-compatible) is a config line plus an API key.
|
||||
|
||||
- Full design: [`docs/plans/2026-08-15-the-daily-epub.md`](docs/plans/2026-08-15-the-daily-epub.md)
|
||||
- Implementation decisions: [`docs/plans/2026-08-15-implementation-notes.md`](docs/plans/2026-08-15-implementation-notes.md)
|
||||
- Web dashboard rollout: [`docs/runbooks/web-dashboard-rollout.md`](docs/runbooks/web-dashboard-rollout.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -114,7 +115,7 @@ sudo install -m0755 target/release/daily-epub /usr/local/bin/
|
||||
|
||||
```
|
||||
daily-epub generate [--date YYYY-MM-DD] [--dry-run] [--out DIR] [--max-articles N] [--skip-llm] [--skip-embeddings] [--rescore]
|
||||
daily-epub serve # rating endpoints + OPDS catalog + downloads
|
||||
daily-epub serve # public site, private dashboard, OPDS, ratings, downloads
|
||||
daily-epub profile rebuild # regenerate learned profile adjustments
|
||||
daily-epub ratings list [--days 90] [--label loved|good|down|cleared]
|
||||
daily-epub ratings set --article 42 --label loved --note "excellent"
|
||||
@@ -134,6 +135,7 @@ daily-epub users disable USER
|
||||
daily-epub users enable USER
|
||||
daily-epub users list
|
||||
daily-epub users logout USER # revoke all of USER's sessions
|
||||
daily-epub job run NAME # systemd job-unit entry point; normally not run by hand
|
||||
```
|
||||
|
||||
`--dry-run` does everything except deliver: it still ingests, persists entries and
|
||||
@@ -184,19 +186,58 @@ start with `!`. It exits non-zero only on a validation error — a missing key o
|
||||
file is a warning, since the run degrades rather than fails — and never opens
|
||||
the database or takes the lock, so it is safe to run next to a live `generate`.
|
||||
|
||||
## Web site and dashboard
|
||||
|
||||
The server is both the public newspaper index and the private operator UI. An
|
||||
anonymous visitor sees only titles, authors, sources, metadata and outbound
|
||||
comment links; generated and scraped text stays private. A signed-in `user`
|
||||
sees complete issues and article chapters and can download artifacts. An
|
||||
`admin` can additionally rate articles and use every `/dashboard/*` page,
|
||||
including settings and jobs. Personalization is shared across accounts for now.
|
||||
|
||||
Accounts are deliberately managed on the host, not in the browser. Usernames
|
||||
are case-insensitive and passwords must be 12–1024 characters. Bootstrap with
|
||||
`daily-epub users add <username> --admin`; use `users passwd`, `role`,
|
||||
`disable`/`enable`, `list`, and `logout` for later administration. Password
|
||||
changes and disabling a user revoke that user's sessions. `/dashboard/users`
|
||||
is a read-only view of roles, status, login times, and open sessions.
|
||||
|
||||
The Jobs page starts only the fixed job catalogue as
|
||||
`daily-epub-job@<name>.service`; the web server never runs the pipeline inside
|
||||
its own process. Install `systemd/daily-epub-job@.service` and the narrowly
|
||||
scoped `systemd/50-daily-epub.rules` polkit rule, and put the server user in
|
||||
`systemd-journal` so the page can show status and its configured journal tail.
|
||||
Set `server.jobs_enabled = false` to make starts unavailable.
|
||||
|
||||
The Settings page derives its fields from `Config`, rewrites `config.toml` in
|
||||
place with `toml_edit`, preserves comments/order and file permissions, validates
|
||||
before an atomic rename, and records attributed history. It re-reads hand edits
|
||||
on the next view. `DAILY_EPUB_*` overrides appear locked, and secrets are shown
|
||||
only as present or absent. Shipped providers (`deepseek`, `anthropic`,
|
||||
`gemini`) cannot be removed because defaults would restore them; leave one
|
||||
unreferenced or edit it. Custom providers can be removed after no `[llm]` role
|
||||
references them. The service needs `/etc/daily-epub` in `ReadWritePaths` for
|
||||
these writes.
|
||||
|
||||
### Web routes
|
||||
|
||||
| Route | Access | Purpose |
|
||||
|---|---|---|
|
||||
| `GET /` | Public | Latest issue as a source-link-only index. |
|
||||
| `GET /issues` | Public | Issue archive. |
|
||||
| `GET /issues/{date}` | Public | One source-link-only issue index. |
|
||||
| `GET /feed.xml` | Public | Atom feed carrying the same public issue content. |
|
||||
| `GET /robots.txt`, `/static/{file}` | Public | Crawler policy and embedded site assets. |
|
||||
| `GET/POST /login`, `POST /logout`, `GET /account` | Session | Sign in, sign out, and account management. |
|
||||
| `GET /dashboard` | Admin | Private operator dashboard. |
|
||||
| `GET /files/epub/{name}`, `/files/xtc/{name}` | Session or Basic auth | Published downloads; existing OPDS clients continue to use Basic auth. |
|
||||
| `GET /opds/daily.xml`, `/healthz`, `/issues.json`, `/r/...` | Existing policy | OPDS, health, reports, and signed rating links. |
|
||||
| `GET /`, `/issues`, `/issues/{date}`, `/feed.xml` | Public | Latest issue, archive, stripped issue index, and equivalent Atom feed. Signed-in issue views expand to the complete issue. |
|
||||
| `GET /issues/{date}/articles/{id}`, `/world`, `/behind` | User or admin | Private article, World Briefing, and Behind the paper chapters. |
|
||||
| `GET /robots.txt`, `/static/{file}` | Public | Crawler policy and embedded CSS, JavaScript, and favicon. |
|
||||
| `GET/POST /login`, `POST /logout` | Public/session | Sign in and out; login attempts are throttled per client IP. |
|
||||
| `GET /account`, `POST /account/password`, `/account/logout-all` | User or admin | Change the current password or revoke sessions. |
|
||||
| `POST /rate` | Admin | Append an attributed dashboard rating event. |
|
||||
| `GET /dashboard` | Admin | Run, budget, rating, job, and config overview. |
|
||||
| `GET /dashboard/runs[/{id}]`, `/articles[/{id}]`, `/ratings`, `/stats` | Admin | Pipeline history, article explanations, rating contributions/history, and evaluation stats. |
|
||||
| `GET/POST /dashboard/profile`, `POST /dashboard/profile/restore` | Admin | Edit `profile.md`, inspect prompts/adjustments, and restore a version. |
|
||||
| `GET/POST /dashboard/settings`, `POST /dashboard/settings/providers`, `GET /dashboard/settings/history` | Admin | Edit validated configuration and inspect its audit log. |
|
||||
| `GET /dashboard/jobs`, `GET /dashboard/jobs/{id}`, `POST /dashboard/jobs/{name}` | Admin | Start fixed systemd jobs and inspect status and logs. |
|
||||
| `GET /dashboard/users` | Admin | Read-only users and open-session list; edits use the CLI. |
|
||||
| `GET /files/epub/{name}`, `/files/xtc/{name}` | Public if Basic auth is unset; otherwise session or Basic auth | Published downloads. Keeping them public when Basic auth is absent preserves existing OPDS acquisition links. |
|
||||
| `GET /opds`, `/opds/`, `/opds/daily.xml` | Existing optional Basic auth | OPDS acquisition feed. |
|
||||
| `GET /r/...`, `/healthz`, `/issues.json` | Existing policy | HMAC rating links, health, and issue reports. |
|
||||
|
||||
---
|
||||
|
||||
@@ -387,15 +428,21 @@ sudo setfacl -m u:daily-epub:rwx /srv/bookorbit/libraries/daily-epub # or chow
|
||||
|
||||
# units
|
||||
sudo install -m0644 systemd/daily-epub.service systemd/daily-epub-generate.service \
|
||||
systemd/daily-epub-generate.timer /etc/systemd/system/
|
||||
systemd/daily-epub-generate.timer systemd/daily-epub-job@.service \
|
||||
/etc/systemd/system/
|
||||
sudo install -m0644 systemd/50-daily-epub.rules /etc/polkit-1/rules.d/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now daily-epub.service daily-epub-generate.timer
|
||||
```
|
||||
|
||||
> **Keep `ReadWritePaths` in sync.** Both units run under `ProtectSystem=strict`
|
||||
> and list the publish directories explicitly:
|
||||
> **Keep `ReadWritePaths` in sync.** The units run under `ProtectSystem=strict`
|
||||
> and list the publish directories explicitly; the server additionally lists
|
||||
> `/etc/daily-epub` so Settings can replace `config.toml`:
|
||||
> ```
|
||||
> # generate and job units
|
||||
> ReadWritePaths=/home/thallada/bookorbit/books/daily-epub /var/lib/daily-epub/xtc
|
||||
> # server unit
|
||||
> ReadWritePaths=/home/thallada/bookorbit/books/daily-epub /var/lib/daily-epub/xtc /etc/daily-epub
|
||||
> ```
|
||||
> If you change `publish.epub_dir` or `publish.xtc_dir` in the config, change
|
||||
> these lines too and `systemctl daemon-reload`, or publishing fails with
|
||||
@@ -727,5 +774,6 @@ From spec §7, plus what implementation turned up:
|
||||
`triage:` / `assess:` log lines (`… 3 rejected (2 recovered on gemini)`), the
|
||||
` · N rejected` suffix on the `curation:` line, or
|
||||
`sqlite3 /var/lib/daily-epub/daily-epub.db "select stage, count(*) from article_assessments where kind = 'provider_rejected' group by stage"`.
|
||||
- **One reader, one issue per day.** There is no multi-user support and no
|
||||
weekly/retrospective edition (spec §6).
|
||||
- **One shared reader profile, one issue per day.** Multiple login accounts and
|
||||
`user`/`admin` roles are supported, but they share one personalization model;
|
||||
there is no per-user issue or weekly/retrospective edition (spec §6).
|
||||
|
||||
+9
-8
@@ -208,14 +208,15 @@ format = "xtch" # xtc (1-bit) | xtch (grayscale)
|
||||
settings = "/etc/daily-epub/xtc-settings.json"
|
||||
|
||||
[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
|
||||
bind = "127.0.0.1:3499" # listen address; keep loopback when trusting proxy IP headers
|
||||
public_url = "https://daily.hallada.net" # base URL for EPUB rating links and same-origin checks
|
||||
session_days = 30 # sliding lifetime for web login sessions
|
||||
login_attempts = 10 # login POSTs allowed per client IP in one throttle window
|
||||
login_window_minutes = 15 # length of the login throttle window
|
||||
jobs_enabled = true # let the dashboard start daily-epub-job@<name>.service
|
||||
journal_lines = 300 # job-page journal tail; valid range 10..=5000
|
||||
# hmac_secret via DAILY_EPUB_SERVER__HMAC_SECRET env (32+ random bytes)
|
||||
# Optional Basic auth for the XTC OPDS feed and file downloads:
|
||||
# Optional Basic auth for /opds/* and /files/*; without it those existing
|
||||
# routes remain public. A signed-in web user can download without Basic auth.
|
||||
# basic_auth_user = "daily"
|
||||
# basic_auth_pass = "..."
|
||||
|
||||
@@ -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 1–7:
|
||||
|
||||
- `/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 1–6.
|
||||
- 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 5–6'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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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%; }
|
||||
}
|
||||
|
||||
@@ -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]");
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 <username> --admin</code>.</td></tr>{% endif %}</tbody>
|
||||
</table></div>
|
||||
</section>{% endblock %}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user