Admin UI¶
The operator UI served under /admin/: a live status dashboard, a
config viewer/editor, cache inspection and purge controls, an IIIF
Auth view, and a source browser with an image viewer. Server-rendered
HTML with one Server-Sent Events stream for the dashboard's live
numbers. No JavaScript build step, no external assets, no runtime
dependency beyond the Go standard library and what the rest of iiiris
already links.
Operator reference: docs/admin.md. This spec is the
contract; that document is the how-to.
Surface¶
Routes¶
| Method | Path | Purpose |
|---|---|---|
GET |
/admin/ |
Status dashboard |
GET |
/admin/stats/stream |
SSE stream of dashboard stats |
GET |
/admin/config |
Curated config editor + read-only effective YAML |
POST |
/admin/config |
Validate, persist, hot-apply a config edit |
GET |
/admin/cache |
Per-tier cache stats + purge controls |
POST |
/admin/cache/purge |
Purge one key (cache=, key=) |
POST |
/admin/cache/purge-all |
Purge one tier (cache=) |
GET |
/admin/auth |
Auth profiles, rules, sessions, recent decisions |
GET |
/admin/browse[/{path...}] |
Directory listing over the default source |
GET |
/admin/view/{path...} |
Thumbnail, IIIF URLs, deep-zoom viewer |
GET |
/admin/static/... |
Bundled CSS, logo, favicon, OpenSeadragon |
Authentication¶
HTTP Basic, from IIIRIS_ADMIN_USER and IIIRIS_ADMIN_PASS. There
are no config-file equivalents; admin credentials are env-only.
Go surface¶
admin.New(deps Deps, creds Credentials) http.Handler is the whole
public API. Deps is a struct of plain funcs and interface values
supplied by cmd/iiirisd; every observability field is optional and a
nil one degrades that panel rather than failing the page.
Projection types (RouteStat, SourceStatus, AuthInfo,
ErrorEvent, QueueStat, SourceCounter) mirror types owned by
internal/server, internal/source, internal/image and
internal/auth. They exist so internal/admin imports none of those
packages; cmd/iiirisd adapts across the boundary.
SSE frame¶
One text/event-stream connection, unnamed data: events, one JSON
object per frame:
uptime, requests, inflight, sourcesage scalars
caches[] slot, backend, entries, bytes, hitrate, reads
routes[] route, requests, errors, bytes, p50, p95, p99
errors[] time, route, status
sources[] name, backend, healthy, error (omitted on most frames)
queue{} depth, p50, p95, p99, total (omitted when unavailable)
auth{} allowed24h, denied24h (omitted when auth is off)
Keys are lowercase. Every frame is a complete snapshot, never a
delta. Response headers set Cache-Control: no-cache and
X-Accel-Buffering: no (nginx must not buffer a stream).
Contracts¶
Availability¶
- Credentials unset ⇒
404, not401. The namespace is treated as not mounted. An operator opts in by setting both env vars; a scanner cannot learn that an admin UI exists. - Every panel degrades independently. A nil
Depsfunc hides its panel; it never fails the page or the stream.
The stats stream¶
- One snapshot per tick, process-wide — not one per connection. Building a snapshot sorts up to 1024 latency samples per route and queries every cache; per-connection ticking multiplied that by the number of open tabs to produce identical frames.
- The tick loop runs only while at least one client is subscribed. An iiiris with no dashboard open does no stats work.
- At most 32 concurrent stream clients; beyond that the endpoint
answers
503. Subscription is decided before the SSE header is written, because once a stream body starts there is no way to say no. Rejecting is deliberate — existing viewers are never degraded to admit a new one. - Slow clients drop frames, they do not stall the loop. Sends are non-blocking over a 1-deep buffer. Safe because frames are whole snapshots: a dropped frame is fully corrected by the next.
- Each connection builds its own first frame, including source health, so a fresh tab paints immediately rather than waiting out an interval.
- 1-second cadence, 32-client cap, 10-tick source probe interval are fixed. Not configuration — see Out of scope.
/admin/stats/streamis excluded from request accounting.classifyRoutereturns""for it, so a connection that lives as long as a dashboard tab is not recorded as one multi-minute request polluting theadminroute's quantiles.
What the numbers mean¶
- Every counter is per-process. Requests, rate, in-flight, cache hit rates, queue depth, source outcomes and recent errors describe the replica that answered. The page names that instance (hostname + pid).
- Cache entries and bytes are the exception — for a shared backend (Redis, S3, shared filesystem) they describe fleet-wide state.
- The dashboard labels, it never aggregates. Cross-replica
aggregation belongs to Prometheus
(
metrics.md); presenting one replica's numbers as the fleet's would be worse than showing nothing. - Cache hit rate counts lookups only. Hits + misses is the
denominator; puts, deletes and errors are excluded, or filling a
cache would read as decay. No lookups yet renders
—, never0%— a cache nobody queried is not a cache missing everything. - Queue depth counts blocked callers only. An acquire that finds a free slot takes a non-blocking fast path and never registers, so an unconstrained server reads zero instead of looking permanently queued. The decrement is deferred: a caller whose context cancels while queued gives the count back.
- Source
not_foundis reported apart fromerrors. Requesting an identifier that doesn't exist is a client error; folding it into source health would make a healthy server look broken under scanning traffic. - Source health is probed every 10th tick, because some
Healthcheckerimplementations make a real network call. The frame carries the probe's age so the table cannot silently look current.
Disclosure¶
/admin/configrenders the effective config throughconfig.Redacted(). All secret-bearing fields are masked, so the page is safe to screenshot or screen-share.- Redaction is display-only. The curated editor's field values are
not redacted, because a save marshals the whole config back to the
-configfile; masking there would write***over real secrets. The curated field list contains no secrets, which is what makes that safe. - The recent-errors ring stores the route class, never the raw request path. A IIIF path embeds the identifier, which may be a signed URL or carry a token. The ring holds 50 entries, in memory, with no persistence.
Instrumentation boundary¶
internal/adminimports no metrics library. It receives plain funcs and projection structs.- Live counters and Prometheus are fed from one hook each —
cache.OpObserver,source.OpenObserver,image.Observer,server.Deps.ObserveRequest. Two sinks, one call site, so the admin view and the metrics endpoint cannot drift from each other or from what actually happened. - The live counters are always wired, independent of whether metrics are enabled: the operator without Prometheus needs them most.
- Admin holds the unwrapped caches. The observer wrappers do not
forward
Stater/Purger, which the cache and dashboard pages need.
Out of scope¶
- Live log tail. The mechanism is trivial —
observability.MultiHandleralready fans out to extraslog.Handlers — but it would hand request paths, client IPs and potentially URL-embedded tokens to every admin credential holder. Deliberately withheld; reopening it is an operator's decision. - Raw request paths in the errors ring. Rejected along with a query-stripped variant. The request log is where you learn which identifier failed.
- Configurable stream cadence or client cap. The shared broadcaster made cost O(1) in open tabs, removing the reason to slow the stream down; a config field would be a permanent compatibility surface for a knob with no use case.
- Per-identifier statistics. Unbounded cardinality — the same
reason
classifyRouteis coarse. - Server-side latency history or retained rate windows. Rates are client-side deltas over monotonic counters. Keeping windows server-side is the road to a worse Prometheus.
- Cross-replica aggregation. Prometheus's job.
- Per-page SSE streams. One multiplexed stream per page; browsers cap concurrent connections per origin, and the cache page's numbers are already on the dashboard.
- WebSockets or polling. The feed is one-directional and
EventSourcereconnects natively; a WebSocket would add a dependency for a channel that never needs client→server messages, and polling would put a request per second per tab into the very counters it displays. - Mutation on the auth page. Read-only; no session purge or token eviction.
- Config editing outside the curated subset. Caches, sources, hooks and auth are not in the form.