Skip to content

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.

Admin status dashboard: version, uptime, request count, per-cache stats, per-source health, and per-route latency metrics

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 to dev)
  • Process uptime
  • Total HTTP request count since startup
  • Per-cache slot: backend name, entries, bytes ( if the backend doesn't implement cache.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, or other — 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

Configuration editor: curated server, logging, and image-pipeline fields, each tagged live or restart

/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

Caches page: per-slot backend, entry count, and byte totals with purge-all and single-key purge controls

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 for public-tier entries (/iiif/3/sample.jpg/full/max/0/default.jpg), or the same path suffixed |v1|full / |v1|substitute for gated tiers — see iiif-auth.md.
  • info: <host><url-path> for the public tier; suffixed |v1|full for gated identifiers.
  • origin: <source-name>:<identifier>, e.g. s3:photos/cat.tif (origin keys are not auth-tiered).

File browser

File browser: directory listing of the configured source with file names and sizes

/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

Per-image view: thumbnail, IIIF URLs for info.json/full/thumbnail, and an OpenSeadragon zoomable viewer

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:

Authorization page: session backend and allow/deny tally, profiles and rules tables, and a recent-decisions log

  • Session backend name (heap / filesystem / s3) and active session count, with per-profile breakdown when the backend supports it (heap and filesystem do; s3 reports the total only — a per-profile breakdown would require one GetObject per 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.MultiHandler already fans out to extra slog.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/config subset — 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/auth is read-only — no UI to mint test tokens, force-revoke a session, or trace probe responses. Hit the service routes directly with curl for now.

Limitations

  • Stats from S3-backed caches show (would require listing the bucket).
  • PurgeAll against 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).