- The display-name placeholder showed the monitor's own name, reading as "leave
this blank and we will use it". The server deliberately does the opposite: a
blank `display_name` publishes the raw monitor id, because publishing an
internal name has to be a decision. The placeholder now says "Public name
(required)" and Save is refused until every component has one, so nobody adds
five monitors and discovers five UUIDs on their public page. The server
fallback is unchanged.
- `deleteStatusPage` and `deleteStatusIncident` existed in the api client and
were wired to nothing, and the page address is immutable — delete was the
only correction for a typo and there was no way to reach it. The editor
header gains a typed-confirmation Delete page, and each incident row a
confirmed delete, both on the existing ConfirmDialog.
- The create modal's address hint had lost its em dash and read as a broken
sentence.
The SSR fetch set `Host` to the visitor's hostname. `Host` is a forbidden
header name and undici discards it silently, so the Go server saw
`server:8080`, `hostSlug` returned "", `InstanceFromHost` returned false and
every public status page 404'd on every deployment. The feature did not work.
- `web/` now forwards the visitor's host as `X-Forwarded-Host`, and their
address on `X-Forwarded-For` — without the latter gin sees a request from the
Next pod with no XFF and every visitor of every page shares one 120/min
bucket, tripped by exactly the traffic an outage produces.
- `publicStatusInstance` honours `X-Forwarded-Host` only when `c.RemoteIP()` is
in `TRUSTED_PROXIES`. It is a tenant selector, so an untrusted peer must not
be able to name one; `RemoteIP()` rather than `ClientIP()` because the latter
is reconstructed from the very headers being judged. `TrustedProxies()` moves
from main.go into the api package so the variable keeps one parser.
- A host naming no slug on a non-cloud deployment resolves the sole instance,
the way bootstrap does. A self-hosted install at vantage.acme.com or an IP
has no slug and could never serve a status page; more than one instance is a
404 rather than a guess, and an unknown-but-well-formed slug stays a 404.
- `InstanceFromHost` gains an explicit-host variant rather than a second copy
of the slug rules, and now caches negative lookups: an unknown host cost a
Mongo query per anonymous request, which is also a timing oracle separating
"no such instance" from "instance exists, page does not".
- The handler's `@Router` annotation is dropped. openapi.json declares one
server of `/api`, so it published `/api/public/status/{pageId}` — a path that
does not exist. The real address is described in prose instead.
/api-keys shares a raw string prefix with /api, and the proxies in front
of this app do not all match by path segment. Nginx Proxy Manager routes
/api straight to the Go server with a prefix location, so /api-keys never
reached Next at all — it reached a control plane with no such route and
came back as a JSON 404. Traefik's PathPrefix has the same shape of
matcher, which puts the Helm ingress at risk whenever ingress.api.enabled
is on.
The page is /tokens now, which cannot collide with anything, and which
matches the /api/tokens the REST API already publishes. The sidebar still
says API Keys — the label is for the reader, the path is for the router.
A permanent redirect covers anyone who bookmarked the old path today.
Fixing the proxy config instead would have left the trap set for the next
deployment, and for whatever sits in front of it.
The page was hand-rolling its loading spinner, error line and empty
paragraph while the rest of the console routes these through
AsyncBoundary with a TableSkeleton and an EmptyState — the same drift
Async.tsx was written to end. It also passed className="p-0" where Card
takes padding={false}.
The admin-only scope switch becomes the vulnerabilities page's pill
filter rather than a loose checkbox: seeing everyone's keys is a filter
over the list, not a preference, and someone who has learned one control
has now learned both.
Scopes collapse to one chip per resource with an r/rw qualifier. Sixteen
badges made the row taller than everything around it and still had to be
read one at a time.
In the create dialog Copy is now the primary action and Done the quiet
one, because the value is unrecoverable once the dialog closes, and the
result panel repeats the role, scopes and expiry that were just granted.
The token management card sat on /settings, which is owner|admin
throughout, so it hid a capability every member already had: the API has
never required a role to mint or revoke your own key. It is now the
/api-keys page, reachable at every role, with the instance-wide lifetime
cap left behind on /settings because that is policy rather than one
person's credentials — and that split is what lets the page be ungated.
The sidebar gains groups: Fleet, Access, Automation, Instance, each with a
small-caps heading and a rule above it. Grouping is by what the operator
is doing rather than by which service answers, so SSH keys, secrets and
API keys sit together as credentials. A group whose every item is
admin-only disappears whole for a member; a labelled section with nothing
under it reads as a failure rather than a restriction.
The UI says keys while the collection, prefix and routes still say tokens.
Renaming a published endpoint to match a nav label would break every
script already written against it.
A card in the Access group beside Members and single sign-on rather than a
new nav entry — /settings/instance was folded back in for exactly this
reason. The plaintext is shown once in a well block and never again.
Tokens outside a newly tightened lifetime policy are flagged rather than
broken, because the policy governs issuance, not existing credentials.
Create, list and revoke, with no update: editing what a credential already
deployed in CI can do, with no record of what it could do before, is worse
than requiring a rotation. Revoking a token that is not yours answers
not-found, since a 403 confirms it exists.
The audit actor stays the human and names the credential alongside, so a
person clicking and their CI job are told apart.
The page rendered a map of eleven event types to labels and seven to colours.
The server emits forty-seven. Everything unmapped fell through to its raw
string, so "Key Assigned" in green sat above "workflow.schedule_updated" in
grey — the same kind of fact in two formats, which made the column look like it
carried a meaning it did not.
Presentation is now derived rather than enumerated. Event types are named
<category>.<action> by every call site, so the category becomes a chip, the
action is humanised, and the tone comes from the verb. A type added to the
server tomorrow gets a sensible label and colour with no second list to update;
the override table holds only the dozen the rule reads badly for. Every row is
one treatment, and colour never carries meaning alone — the sentence beside it
says the same thing in words.
Paging and filtering are server-side, unlike the fleet lists that answer with
everything and slice in the browser. audit_retention_days is a licensed
entitlement measured in months, and this log is read to answer questions about
the past, so a browser filtering the most recent page would report "no results"
for events that exist. GET /api/audit now takes q, category, limit and skip and
answers {events, total} — a short page is not evidence of the end of the log,
which is why the total is counted rather than inferred.
audit_logs had no indexes at all: every read was a collection scan with an
in-memory sort over an append-only collection. Adds (instance_id, created_at)
and warns rather than failing, matching EnsureSecretIndexes.
Two bugs found by running the deriver over all forty-seven real types rather
than eyeballing it: the tone rules matched only past-tense verbs, leaving
auth_provider.delete drawn as neutral beside key.deleted in red; and
"unaccepted" matched "accepted", so withdrawing an acceptance read as the same
caution as granting one.