Admin dashboard information architecture and route map #115

Closed
opened 2026-08-17 15:22:41 +07:00 by sulthan · 1 comment
Owner

Part of #114

Question

What are the admin dashboard's pages, what lives on each, and what are their routes?

Settled going in: sub-routes with a nav row rather than one long stacked page, because Series
needs a filterable list plus a bookmarkable address (/admin/series?filter=no-cover) and a
per-Series detail page. Also settled: the surface leads with one summary line aggregating Lane
state and Series staleness.

To decide:

  • The route set and which of adminRoutes() (backend/internal/web/admin.go) they extend,
    including whether the existing GET /ui/admin/lanes fragment pattern generalises to each new
    block or whether only Lanes keeps a self-refreshing fragment.
  • Which blocks land on which page, and what the landing page is. Lanes and the summary belong
    together; whether Readers stays on the landing page or moves to its own route is open.
  • Exactly what the summary line says, computed only from facts available today
    (latest.Status plus series.latest_checked_at) — the poll-history ticket may extend it
    later, so it must be useful without history.
  • What a Series detail address is keyed by. series is keyed (site, series_id); the wire
    already derives site:series_id, so whether the URL carries that composite or something else
    needs settling.
  • Whether the reading UI links to admin at all beyond the existing /admin entry.
Part of #114 ## Question What are the admin dashboard's pages, what lives on each, and what are their routes? Settled going in: sub-routes with a nav row rather than one long stacked page, because Series needs a filterable list plus a bookmarkable address (`/admin/series?filter=no-cover`) and a per-Series detail page. Also settled: the surface leads with one summary line aggregating Lane state and Series staleness. To decide: - The route set and which of `adminRoutes()` (`backend/internal/web/admin.go`) they extend, including whether the existing `GET /ui/admin/lanes` fragment pattern generalises to each new block or whether only Lanes keeps a self-refreshing fragment. - Which blocks land on which page, and what the landing page is. Lanes and the summary belong together; whether Readers stays on the landing page or moves to its own route is open. - Exactly what the summary line says, computed only from facts available today (`latest.Status` plus `series.latest_checked_at`) — the poll-history ticket may extend it later, so it must be useful without history. - What a Series detail address is keyed by. `series` is keyed `(site, series_id)`; the wire already derives `site:series_id`, so whether the URL carries that composite or something else needs settling. - Whether the reading UI links to admin at all beyond the existing `/admin` entry.
sulthan added the wayfinder:grilling label 2026-08-17 15:22:41 +07:00
sulthan self-assigned this 2026-08-17 15:39:16 +07:00
Author
Owner

Answer

Four pages behind one nav row, plus the one existing fragment. Every new route joins
adminRoutes() (backend/internal/web/admin.go), so requireOwner and the gate test cover it
by construction rather than by remembering a check.

Route Kind Holds
GET /admin page summary line, library stats block, per-Site table (database facts only), nav row
GET /admin/lanes page the existing lanes block, still self-refreshing
GET /admin/readers page the existing readers roster and its two actions
GET /admin/series page filterable Series list; filter state lives in the query string
GET /admin/series/{key} page per-Series detail and intervention; key = site:series_id
GET /ui/admin/lanes fragment unchanged, and the only timer on the whole surface
POST /readers/{id}/revoke, POST /readers/{id}/clear-marks action unchanged addresses
POST /admin/series/{key}/<verb> action shape reserved for #119, #120, #121

Address conventions

  • Bookmarkable pages under /admin/...; self-refreshing fragments under /ui/admin/...;
    intervention posts to /admin/series/{key}/<verb> and answers with the swapped row or block,
    the shape renderRoster already uses.
  • The two existing reader actions keep their current top-level addresses. Renaming them for
    symmetry edits working templates for no gain.
  • Series detail is keyed by the composite site:series_id in one path segment, matching
    /ui/bookmarks/{key} and store.Get. A surrogate id would need a column and a migration to
    save nothing; : is legal unescaped in a path segment and no observed series_id carries /.

The page split rule

Landing shows library shape, read from the database. The Lanes page shows poller liveness,
read from memory.
One rule decides where any new figure goes, and nothing is rendered on two
pages to drift apart. So due / checked / ran / gap / refusing / asleep / browser stay on
/admin/lanes and never appear in the landing page's per-Site table.

Refresh

Only Lanes refreshes on a timer. Three reasons, and the first is measured: defaultRest = time.Hour (internal/latest/sites.go), so a Lane rests an hour between passes and
series.latest_checked_at moves at that granularity — a 30s timer would re-run a cross-Series
join ~120 times an hour to redraw the same rows. LaneStatus() by contrast copies a map out of
memory and touches no database, which is why its timer is free. And an auto-swap on an action
surface eats a half-open Cinder .confirm-row, or re-sorts the list between the press and the
confirm. Freshness on the new pages is per-action: each button answers with freshly rendered
markup, and force-poll (#119) is asynchronous anyway — the Lane notices the flag on its next
pass, so a timer would show "nothing yet" for up to an hour. A manual refresh button stays
available later as one hx-get attribute; it is not bought now.

The summary line

<verdict> · <N> series waiting · <M> unchecked over 24h

  • verdict: "All lanes healthy" when no laneRow.Attention is set, else " lanes need
    attention"; when lanesView.Rows is empty it says no Lane has reported since restart rather
    than "healthy" — the rule lanes.html already keeps, never drawing absent data as confident
    zeroes.
  • N = sum of LaneState.Due across reported Lanes (memory).
  • M = COUNT(*) over series where series_url <> '' and latest_checked_at older than 24h.
  • Computable entirely from today's facts, so it is useful before #117 exists and extendable after.
  • Accent: the existing .attention class. Never --ember (new chapter only), never --danger
    (destruction).

Landing stats block

One compact block of label — number rows under the summary line, hygiene numbers linking into
/admin/series?filter=… so a count and its entry point are the same control:

  • Library size: total Series, split manga / novel.
  • Readers: roster count.
  • Hygiene, per the whole library and again per Site: no cover (cover_address = ''), never
    checked (latest_checked_at = 0 with a series_url), stale over 24h, unpollable
    (series_url = ''), Latest Chapter Sighting-raised (latest_sighted_at > latest_checked_at).
  • Per-Site table: one row per series.site with those database facts.

All of it is one grouped pass over series — COUNT(…) FILTER (WHERE …) grouped by site, not
one query per figure. Note EligibleSeriesCount already exists but takes one Site per call and
returns one number; the landing page wants every Site at once, so this lands as a second method
on #116's read model rather than five calls.

Excluded deliberately: anything per-Reader (privacy boundary), anything over time (the map rules
out timeseries).

What this pushes onto other tickets

  • #117 gains a hard requirement: per-Site failure counts over the last 24h are wanted on the
    landing page's per-Site table, and are impossible today — nothing about a poll is persisted.
    Until #117 lands, that column is absent, not zero. Also relevant to #117's shape: only two
    of three failure kinds are visible to the Lane at all (a Site refusing, the sidecar
    unreachable); an adapter reading a wrong number (#79) is not, so a rendered "0 errors" would
    lie in exactly the case that motivated the dashboard.
  • #116 gains a second consumer: the grouped per-Site aggregate query above, alongside the
    Series list projection.
  • #122 prototypes the nav row and the density of a four-page admin surface, not a single
    stacked page.
  • Reading UI entry points stay as they are: the .Owner-gated Admin link in app.html is the
    only way in, and the nav row handles everything inside. No per-Series admin link from library
    cards — a second gated branch in the library templates for a hop the admin Series list already
    provides.
## Answer Four pages behind one nav row, plus the one existing fragment. Every new route joins `adminRoutes()` (`backend/internal/web/admin.go`), so `requireOwner` and the gate test cover it by construction rather than by remembering a check. | Route | Kind | Holds | |---|---|---| | `GET /admin` | page | summary line, library stats block, per-Site table (database facts only), nav row | | `GET /admin/lanes` | page | the existing `lanes` block, still self-refreshing | | `GET /admin/readers` | page | the existing `readers` roster and its two actions | | `GET /admin/series` | page | filterable Series list; filter state lives in the query string | | `GET /admin/series/{key}` | page | per-Series detail and intervention; `key = site:series_id` | | `GET /ui/admin/lanes` | fragment | unchanged, and the only timer on the whole surface | | `POST /readers/{id}/revoke`, `POST /readers/{id}/clear-marks` | action | unchanged addresses | | `POST /admin/series/{key}/<verb>` | action | shape reserved for #119, #120, #121 | ### Address conventions - Bookmarkable pages under `/admin/...`; self-refreshing fragments under `/ui/admin/...`; intervention posts to `/admin/series/{key}/<verb>` and answers with the swapped row or block, the shape `renderRoster` already uses. - The two existing reader actions keep their current top-level addresses. Renaming them for symmetry edits working templates for no gain. - Series detail is keyed by the composite `site:series_id` in one path segment, matching `/ui/bookmarks/{key}` and `store.Get`. A surrogate id would need a column and a migration to save nothing; `:` is legal unescaped in a path segment and no observed `series_id` carries `/`. ### The page split rule **Landing shows library shape, read from the database. The Lanes page shows poller liveness, read from memory.** One rule decides where any new figure goes, and nothing is rendered on two pages to drift apart. So due / checked / ran / gap / refusing / asleep / browser stay on `/admin/lanes` and never appear in the landing page's per-Site table. ### Refresh Only Lanes refreshes on a timer. Three reasons, and the first is measured: `defaultRest = time.Hour` (`internal/latest/sites.go`), so a Lane rests an hour between passes and `series.latest_checked_at` moves at that granularity — a 30s timer would re-run a cross-Series join ~120 times an hour to redraw the same rows. `LaneStatus()` by contrast copies a map out of memory and touches no database, which is why its timer is free. And an auto-swap on an action surface eats a half-open Cinder `.confirm-row`, or re-sorts the list between the press and the confirm. Freshness on the new pages is per-action: each button answers with freshly rendered markup, and force-poll (#119) is asynchronous anyway — the Lane notices the flag on its next pass, so a timer would show "nothing yet" for up to an hour. A manual refresh button stays available later as one `hx-get` attribute; it is not bought now. ### The summary line `<verdict> · <N> series waiting · <M> unchecked over 24h` - verdict: "All lanes healthy" when no `laneRow.Attention` is set, else "<k> lanes need attention"; when `lanesView.Rows` is empty it says no Lane has reported since restart rather than "healthy" — the rule `lanes.html` already keeps, never drawing absent data as confident zeroes. - `N` = sum of `LaneState.Due` across reported Lanes (memory). - `M` = `COUNT(*)` over `series` where `series_url <> ''` and `latest_checked_at` older than 24h. - Computable entirely from today's facts, so it is useful before #117 exists and extendable after. - Accent: the existing `.attention` class. Never `--ember` (new chapter only), never `--danger` (destruction). ### Landing stats block One compact block of `label — number` rows under the summary line, hygiene numbers linking into `/admin/series?filter=…` so a count and its entry point are the same control: - Library size: total Series, split manga / novel. - Readers: roster count. - Hygiene, per the whole library and again per Site: no cover (`cover_address = ''`), never checked (`latest_checked_at = 0` with a `series_url`), stale over 24h, unpollable (`series_url = ''`), Latest Chapter Sighting-raised (`latest_sighted_at > latest_checked_at`). - Per-Site table: one row per `series.site` with those database facts. All of it is one grouped pass over `series` — `COUNT(…) FILTER (WHERE …)` grouped by site, not one query per figure. Note `EligibleSeriesCount` already exists but takes one Site per call and returns one number; the landing page wants every Site at once, so this lands as a second method on #116's read model rather than five calls. Excluded deliberately: anything per-Reader (privacy boundary), anything over time (the map rules out timeseries). ### What this pushes onto other tickets - **#117 gains a hard requirement**: per-Site failure counts over the last 24h are wanted on the landing page's per-Site table, and are impossible today — nothing about a poll is persisted. Until #117 lands, that column is **absent**, not zero. Also relevant to #117's shape: only two of three failure kinds are visible to the Lane at all (a Site refusing, the sidecar unreachable); an adapter reading a wrong number (#79) is not, so a rendered "0 errors" would lie in exactly the case that motivated the dashboard. - **#116 gains a second consumer**: the grouped per-Site aggregate query above, alongside the Series list projection. - **#122** prototypes the nav row and the density of a four-page admin surface, not a single stacked page. - Reading UI entry points stay as they are: the `.Owner`-gated `Admin` link in `app.html` is the only way in, and the nav row handles everything inside. No per-Series admin link from library cards — a second gated branch in the library templates for a hop the admin Series list already provides.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sulthan/mangaBookmark#115