Admin UI¶
Operator UI mounted at /admin/. The durable contract — routes, SSE
frame shape, per-process semantics, disclosure decisions — lives in
specs/admin.md; this page is the how-to.
Pages: status dashboard, configuration,
caches, authorization, file browser, and a per-image view. Server-rendered
HTML with a sidebar layout; the only client-side JavaScript is the
dashboard's live-update loop (no framework, no build step). Styling is a
single stylesheet embedded at internal/admin/static/style.css.
Auth¶
HTTP Basic, gated by env vars:
IIIRIS_ADMIN_USER=admin
IIIRIS_ADMIN_PASS=<password>
When either is empty, /admin/* returns 404 (treated as
not-mounted). Secure by default — operators opt in by setting credentials.
There is intentionally no YAML equivalent for these credentials; env vars are the right surface for secrets.
Endpoints¶
| Method | Path | Purpose |
|---|---|---|
| GET | /admin/ |
Status dashboard |
| GET | /admin/stats/stream |
Server-Sent Events stream of dashboard stats (1s cadence; source health every 10s) |
| GET | /admin/config |
Config editor (curated subset) + read-only effective configuration |
| POST | /admin/config |
Validate, persist, and hot-apply a config edit (form: one field per curated key) |
| GET | /admin/cache |
Per-cache stats + purge controls |
| POST | /admin/cache/purge |
Form: cache=<render\|info\|origin>, key=<cache key>. Deletes one entry. |
| POST | /admin/cache/purge-all |
Form: cache=<render\|info\|origin>. Calls Purger.PurgeAll. Returns 501 if the cache backend isn't a Purger. |
| GET | /admin/auth |
IIIF Auth subsystem: profiles, rules, active session count, recent decisions |
| GET | /admin/browse |
Source root listing |
| GET | /admin/browse/{path...} |
List a sub-directory in the source |
| GET | /admin/view/{path...} |
View a single image: thumbnail, IIIF URLs, OpenSeadragon zoomable viewer |
| GET | /admin/static/... |
Vendored static assets (OpenSeadragon JS + sprite images) |
What the dashboard shows¶
- Version (set via
-ldflags="-X main.Version=..."; defaults todev) - Process uptime
- Total HTTP request count since startup
- Per-cache slot: backend name, entries, bytes (
—if the backend doesn't implementcache.Stater) - Per-source health: name, backend, healthy/unhealthy, last error
- Per-route metrics: requests, errors, bytes served, latency p50 / p95 / p99
over the most recent 1024 requests per route, plus a p95 sparkline of the
last 60 live samples. Routes are classified as
health,admin,iiif/3 info,iiif/3 image,iiif/2 info,iiif/2 image, orother— coarse-grained on purpose so the table doesn't explode for a server with many identifiers.
Live updates¶
The dashboard updates itself in place — no page refresh. On load it opens
a Server-Sent Events
connection to GET /admin/stats/stream, which emits a JSON snapshot once
per second: uptime, request count, cache stats, and per-route metrics.
Source health is more expensive to probe (an S3 Healthchecker makes a
real network call), so it rides the stream only every tenth tick (~10s).
A "live / reconnecting…" indicator next to the page title reflects the
connection state; EventSource reconnects automatically if the stream
drops. The per-route p95 sparkline is built client-side from the streamed
samples and resets when the page is reloaded (60 points max).
The stream is fanned out from one process-wide tick loop, not one per
connection: a snapshot copies and sorts up to 1024 latency samples per route
and queries every configured cache, so building it per viewer scaled that
cost with the number of open dashboards for byte-identical output. Ten tabs
now cost what one does. The loop runs only while at least one dashboard is
attached, so an idle iiiris does no stats work. Concurrent stream clients are
capped at 32; past that the endpoint answers 503 rather than degrading
every existing viewer — a predictable refusal beats existing dashboards
flapping. The 1-second cadence and that cap are both fixed, deliberately: they
are not configuration. A client on a slow link drops frames rather than
stalling delivery for everyone else — each frame is a complete snapshot, so
the next one fully corrects the display.
Everything on the dashboard is per-process. Requests, req/s, in-flight, cache hit rates and recent errors describe the replica that answered, and the page is labelled with that instance (hostname + pid) so two refreshes landing on different replicas can't be mistaken for numbers jumping backwards. Cache entries and bytes are the exception: for a shared backend (Redis, S3, a shared filesystem) they describe state common to the fleet. Cross-replica aggregation is what the Prometheus endpoint is for — the dashboard labels rather than aggregates.
Render queue¶
max_concurrent bounds in-flight renders; the dashboard shows how many calls
are blocked waiting for a slot and the p95 wait. Depth is what separates
"renders are slow" from "renders are queued" — a slow decode and a fast decode
waiting behind three others produce identical latency, and the remedies are
opposite: one wants a cheaper decode path, the other a higher max_concurrent
or more replicas.
Only genuinely blocked callers count. An acquire that finds a free slot takes a non-blocking fast path and never registers, so a busy-but-unconstrained server reads zero rather than looking permanently queued.
Source open outcomes¶
The sources table counts Open results per source: opens, not found,
and errors, cumulative for the process.
not found is deliberately a separate column rather than part of an error
rate. A request for an identifier that doesn't exist is a client error; folding
it into source health would make a well-behaved server look broken under
scanning traffic.
Health itself is probed every 10 seconds rather than every tick, because some backends (S3) make a real network call per probe. The table says how long ago it was probed — a health table that silently ages is worse than one that admits its own staleness.
Recent errors¶
The dashboard retains the last 50 responses with a 4xx or 5xx status: time, route, and status. It is a "what just went wrong" panel, not a log — there is no persistence and no filtering; the request log and, if configured, Sentry own history.
Rows carry the route class (iiif/3 image), never the raw request path.
The raw path is the more useful field and that is why it is withheld: a IIIF
path embeds the identifier, and an identifier can be a signed URL or carry a
token in its query, so retaining it in memory and showing it to every admin
credential holder would be a real disclosure.
This is a settled decision, not a default awaiting configuration — class-only was chosen over both the raw path and a query-stripped variant. To find which specific identifier failed, use the request log.
Cache hit rate¶
Each cache tier shows a hit rate alongside its footprint. Hits and misses are
the only operations in the denominator — puts, deletes and errors are not
lookups, and counting them would make filling a cache look like decay. A tier
nobody has queried yet reads — rather than 0%, because a cache with no
lookups is not a cache that is missing everything.
The counters are fed from the same cache.OpObserver hook that feeds
Prometheus, so the two views cannot drift from each other or from what the
cache actually did. They are always on, independent of whether metrics are
enabled — the operator without Prometheus is the one who needs the number
most.
The stream endpoint is deliberately excluded from the request counter and
per-route stats (classifyRoute returns "" for it) — its connection
stays open for the lifetime of the dashboard tab, and recording that as a
single multi-minute request would distort the admin route's latency
quantiles. The other admin pages (/admin/config, /admin/cache,
/admin/auth, /admin/browse) remain plain server-rendered HTML and need
a refresh to update.
Configuration editor¶
/admin/config is a structured editor over a curated subset of config
fields, plus the full effective configuration rendered read-only as YAML
below it. Secret-bearing fields are masked as *** in that YAML view —
the same redaction iiirisd -print-config applies, so the page is safe to
screenshot or screen-share. Masking is display-only: an edit saved from the
form writes the real values back to the -config file untouched. The
editable subset:
| Section | Fields | Apply |
|---|---|---|
| Server | addr, read_timeout, write_timeout, shutdown_timeout |
restart |
| Logging | log.level |
live |
| Logging | log.format |
restart |
| Image pipeline | image.max_pixel_area, image.max_concurrent |
live |
| Image pipeline | image.max_output_area, image.max_output_width, image.max_output_height, image.tile_size |
restart |
Everything else (caches, sources, hook, auth) is not exposed in the form; those fields are preserved verbatim when the file is rewritten — edit the YAML directly and restart to change them.
Live vs. restart. Three fields hot-reload — log.level rides a
slog.LevelVar; image.max_pixel_area (the source-area guard) and
image.max_concurrent (the pipeline's concurrency semaphore, which is
resized in place — it gates renders and info.json probes through separate
same-sized semaphores so neither path starves the other) have no other
coupling. The image output caps and
tile_size are restart-only on purpose: each is paired with an
info.json advertisement (maxArea / maxWidth / maxHeight / tiles),
and hot-reloading the pipeline guard without the advertisement would make
info.json lie about what the server accepts. A save reports which changed
fields still need a restart.
Saving. A save validates every field; on any error nothing is written
and the form re-renders with per-field messages. On success the whole
config is written back to the -config file — atomically (temp file +
rename) and in fully-expanded, normalized YAML (comments and original
field order are not preserved, consistent with how the page already
displays config). The live fields are applied immediately; restart-only
fields are on disk and take effect on the next start.
When the editor is read-only. The form is editable only when iiirisd
was started with -config <path> — there must be a file to persist edits
to. In zero-config mode the fields render disabled with an explanatory
note. A field whose value is currently set by an environment variable
(IIIRIS_ADDR, IIIRIS_LOG_LEVEL, IIIRIS_LOG_FORMAT,
IIIRIS_MAX_PIXEL_AREA) is individually locked: the env var shadows the
file at startup, so editing the file value would have no effect — change
the env var instead.
The HTTP Basic gate on /admin/* is the only protection on this write
surface; there is no separate CSRF token (the same posture as the cache
purge endpoints).
Caches¶
The /admin/cache page lists each cache slot (render / info / origin) with
its backend, entry count, and byte total, plus per-slot purge all and a
purge a single key form.
Cache keys (for /admin/cache/purge)¶
render: the IIIF URL path forpublic-tier entries (/iiif/3/sample.jpg/full/max/0/default.jpg), or the same path suffixed|v1|full/|v1|substitutefor gated tiers — seeiiif-auth.md.info:<host><url-path>for thepublictier; suffixed|v1|fullfor gated identifiers.origin:<source-name>:<identifier>, e.g.s3:photos/cat.tif(origin keys are not auth-tiered).
File browser¶
/admin/browse lists the configured default source as a directory tree.
Clicking a sub-directory descends; clicking a file opens /admin/view/...
which shows:
- A thumbnail rendered through iiiris itself (
/iiif/3/{path}/full/!300,300/0/default.jpg) - The full IIIF URLs for
info.json, the full image, and the thumbnail (clickable to copy / inspect) - An OpenSeadragon zoomable viewer pointed at the IIIF info.json — pan/zoom of the full-resolution image with tile-based loading
Browsing is available when the configured default source implements
source.Lister. Today the filesystem and s3 backends qualify (S3
uses ListObjectsV2 with Delimiter="/" for directory-style listings);
HTTP shows "this backend does not support browsing." source.AsLister
finds the capability through the Cached OriginCache wrapper.
OpenSeadragon (5.0.1) is vendored under internal/admin/static/openseadragon/
and served by iiirisd itself at /admin/static/openseadragon/.... No
external CDN is contacted; the viewer works in air-gapped deployments.
IIIF Authorization vs. admin auth¶
The /admin/* HTTP Basic protection is separate from the IIIF
Authorization Flow 2.0 services that may be mounted at
/iiif/auth/.... The admin Basic-auth gate protects operator endpoints
on this server; IIIF auth gates access to specific images for end users
of the IIIF API. They share no credentials, no session store, and no
configuration — set them up independently. The IIIF auth subsystem is
documented in iiif-auth.md.
The /admin/auth page surfaces the IIIF Auth subsystem in read-only
form:
- Session backend name (
heap/filesystem/s3) and active session count, with per-profile breakdown when the backend supports it (heapandfilesystemdo;s3reports the total only — a per-profile breakdown would require oneGetObjectper session and is intentionally skipped). The total includes expired-but-not-yet- evicted records on the S3 backend (where Sweep is a no-op and lifecycle policy handles long-term cleanup). - Last-24h allow/deny tally, drawn from a bounded ring buffer
(
auth.DecisionLog) sized at 256 most-recent decisions. Anything older than the buffer's capacity rolls off. - Profiles table: name, pattern, access-service backend, the first
English label, the credential source (
inline/htpasswd: <path>/—), and the configured substitute size. - Rules table: match expression → profile name, in evaluation order.
- Recent decisions table: most-recent-first, latest 64 events from
the ring buffer. Each row shows time, identifier, matched profile
(empty = public), verdict (
allow/deny/deny → substitute/deny → redirect), and HTTP status.
The page is read-only; mutation (purging sessions, evicting tokens) is not exposed today. Restart iiirisd to clear the heap session store; for filesystem/S3, delete records on disk / in the bucket.
What the admin UI does not do (yet)¶
- Logs viewer — the mechanism is cheap (
observability.MultiHandleralready fans out to extraslog.Handlers), but a live log tail hands request paths, client IPs and potentially URL-embedded tokens to every admin credential holder. Deliberately deferred as an operator decision, not an oversight. - Live updates on pages other than the dashboard (config / cache / auth / browse still need a refresh)
- Editing config fields outside the curated
/admin/configsubset — caches, sources, hook, and auth are not in the form; edit the YAML directly and restart - Per-identifier metrics (only per-route classification today)
- IIIF auth interactive controls:
/admin/authis read-only — no UI to mint test tokens, force-revoke a session, or trace probe responses. Hit the service routes directly withcurlfor now.
Limitations¶
- Stats from S3-backed caches show
—(would require listing the bucket). PurgeAllagainst an S3 cache returns 501 — use bucket lifecycle or the AWS CLI.- The file browser only works for sources that implement
source.Lister(filesystem and S3; not HTTP).