web: owner-only /admin with the Reader roster and Poll Lane status (#102)

The only operational surface was /healthz and a fold-out roster inside the
owner's own reading page. This gives the owner a page: two sections of facts on
the same measured sheet, no cards.

- admin.go holds every route that reaches past the acting Reader, listed once
  in adminRoutes() and wrapped in requireOwner at registration - a missing gate
  is visible in the route list rather than hidden inside a handler. A non-owner
  gets 404, the same answer revoke already gave.
- Lane figures arrive through the LaneReporter seam, so the page reads the
  running poller rather than a table. newRouter converts a nil *Poller to a nil
  interface: a typed nil would make the page claim a poller exists.
- The roster moves out of the reading page and gains the Sighting counters, the
  blocked verdict and Clear marks. Clearing restores a privilege, so it is a
  plain ghost button; --danger stays with revocation.
- --patina is the page's one accent, held at the weight of the other action
  accents. Neither --ember (new chapter) nor --danger (destruction) is borrowed
  for system health.
This commit is contained in:
2026-08-16 14:17:30 +07:00
parent d555f54529
commit e69da2a99b
8 changed files with 430 additions and 87 deletions
+36
View File
@@ -0,0 +1,36 @@
{{/* Poll Lane status: one row per Site, refreshing itself so a run can be
watched rather than sampled by reloading. The refresh is one attribute on
the fragment root and the endpoint answers with this same fragment, so the
swap replaces the element that asked for it.
Every figure here is read out of the running poller, never out of a table:
a Site absent from Rows has not completed a pass since the last restart,
which the empty state must say — zeroes would read as a stopped Lane. */}}
{{define "lanes"}}
<section class="lanes" id="lanes" aria-live="polite"
hx-get="/ui/admin/lanes" hx-trigger="every 30s" hx-swap="outerHTML">
<h2>Poll Lanes</h2>
{{if .Rows}}
<ul class="lanelist">
{{range .Rows}}
<li{{if .Attention}} class="attention"{{end}}>
<span class="lane-site">{{.Site}}</span>
<span class="lane-fact">{{.Due}} due</span>
<span class="lane-fact">ran {{.Ran}}</span>
<span class="lane-fact">gap {{.Gap}}</span>
{{if .Clamped}}<span class="lane-mark">gap at floor</span>{{end}}
{{if .Refusing}}<span class="lane-mark">refusing</span>{{end}}
{{if .BrowserLost}}<span class="lane-mark">no browser</span>{{end}}
</li>
{{end}}
</ul>
{{else}}
<p class="setup-copy">No data yet — no Lane has completed a pass since the
backend started.</p>
{{end}}
<p class="setup-copy lane-browser">Browser sidecar:
{{if not .BrowserConfigured}}not configured — comix, kagane and novelfull
pages are not fetched through it{{else if .BrowserReachable}}reachable
{{else}}unreachable{{end}}.</p>
</section>
{{end}}