Skip to content

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, not 401. 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 Deps func 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/stream is excluded from request accounting. classifyRoute returns "" for it, so a connection that lives as long as a dashboard tab is not recorded as one multi-minute request polluting the admin route'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 , never 0% — 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_found is reported apart from errors. 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 Healthchecker implementations make a real network call. The frame carries the probe's age so the table cannot silently look current.

Disclosure

  • /admin/config renders the effective config through config.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 -config file; 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/admin imports no metrics library. It receives plain funcs and projection structs.
  • Live counters and Prometheus are fed from one hook eachcache.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.MultiHandler already fans out to extra slog.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 classifyRoute is 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 EventSource reconnects 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.