docs: public status pages design spec

This commit is contained in:
2026-08-24 13:32:24 +00:00
parent 2a660697c5
commit f1b6f90345
@@ -0,0 +1,286 @@
# Public status pages
Date: 2026-08-24
## Goal
Let an operator publish one or more public status pages from a Vantage
instance, at `<slug>.vantage.<tld>/status/<page-id>`, showing the state of any
monitors they choose, plus incidents and maintenance windows they author by
hand. The pages are completely public: no session, no token, no login.
Out of scope, deliberately:
- **Custom domains** (`status.customer.com`). Needs certificate provisioning and
a host-to-page lookup that bypasses `hostSlug` entirely. Its own sub-project.
- **Per-page themes.** `web/` is locked dark by design and a public page is not
the place to break that.
- **Subscriber notifications.** Email or webhook on incident updates is a
notification subsystem, and one already exists for monitors; wiring the two
together is a separate decision.
- **SLA reporting.** Uptime percentages are shown; contractual SLA calculation
with credits and exclusions is a different product.
## Current state
Everything needed to draw a status page already exists and is already scoped by
instance:
| Data | Where |
| --- | --- |
| Monitor identity and live state | `models.Monitor`, `Monitor.State` |
| Outage records | `models.Incident`, opened when a monitor flips down |
| Hourly uptime history | `models.Rollup` (`monitor_rollups`) |
| Sub-hour history | `models.MonitorSample`, TTL-expired |
| Instance from hostname | `auth.InstanceFromHost`, 60s cached |
Three things do not exist: any concept of a page, any operator-authored
incident, and any unauthenticated read path. The third is the constraint that
shapes the rest — every route under `/api` carries `auth.Middleware`,
`RequireScopes`, `RateLimitTokens` and `RequireActiveLicense` by virtue of where
it is mounted, and `AssertScopeMapComplete` fails boot on an `/api` route with
no scope entry.
## Approach
Two new collections hold the page and the authored incidents. A single
assembly function reads them alongside the existing monitor data and emits a
purpose-built public struct. The public route is mounted outside `/api`, is
cached in Redis, and is rate limited per client address.
The redaction boundary is the assembly function, and it is the security
property of this whole feature.
## Data model
Both collections carry `instance_id` and both must be added to
`services.ScopedCollections`, or their rows outlive a deleted instance.
### `status_pages`
One document per page. It is read whole, always, so its structure is embedded
rather than joined: one page is one Mongo read is one cache fill.
```
_id, instance_id
page_id // operator-chosen slug, [a-z0-9-], 3-40 chars
title, description, logo_url
published bool
banner { enabled, level, text }
sections [ { name, entries: [ { monitor_id, display_name } ] } ]
created_at, updated_at
```
Unique index on `(instance_id, page_id)`. The slug is operator-chosen rather
than random because it is a URL handed to customers and printed on support
pages; a random identifier would be unguessable and unmemorable in equal
measure.
`published` exists so a page can be composed before anyone sees it. An
unpublished page answers the same 404 as a page that does not exist — a
distinct 403 would confirm it exists.
Sections are page-local and unrelated to `Monitor.Group`, which is a display
label on the authenticated monitors list. One monitor may appear under "API" on
the customer page and "Edge" on the partner page, under two different display
names. That is the point of the override: a monitor's internal name is often
not a name you want published.
The banner is three fields on the page rather than a collection, because it is
one string with no lifecycle.
### `status_incidents`
Manual incidents and maintenance windows share one shape, because they share a
timeline, an impact and a set of affected components; splitting them into two
collections would duplicate all three.
```
_id, instance_id, incident_id
page_ids []string // which pages show it
kind "incident" | "maintenance"
title
impact // none | minor | major | critical
affected_monitors []string // monitor_ids
status // incident: investigating | identified | monitoring | resolved
// maintenance: scheduled | in_progress | completed
scheduled_start, scheduled_end // maintenance only
updates [ { at, status, body, author } ]
started_at, resolved_at, created_at, updated_at
```
Updates are embedded for the same reason sections are: they are few, and they
are never read apart from their incident.
`page_ids` is explicit rather than derived from `affected_monitors`. Deriving it
would be less to fill in, but adding a monitor to a page later would
retroactively republish old incidents to a new audience. An operator publishing
to customers chooses that audience.
### Auto-incidents are derived, never copied
The existing `incidents` collection remains the only writer for
monitor-detected outages. The public snapshot derives them at assembly time:
filter to the monitors on the page, last 90 days, render as display name, start,
end and duration.
`Incident.Cause` is dropped. It is where `dial tcp 10.0.0.5:5432: connect
refused` lives.
Copying auto-incidents into `status_incidents` would be a second writer for the
same fact, arriving by a different route with its own opportunity to disagree —
the same argument that keeps `RefreshWorkloadsCmd` from returning workloads
inline.
### Maintenance does not rewrite uptime
During a maintenance window, affected components render as "under maintenance"
rather than down. The uptime percentage and the history bar still come from the
rollups, unmodified.
Rollups are the durable record. Bending them so a page looks better is a lie
pointed the other way, and the operator who later asks "what was our actual
availability" gets an answer that was edited for publication.
## The redaction boundary
`services.BuildStatusSnapshot(instanceID, pageID)` is the only function that
reads `monitors`, `incidents`, `monitor_rollups` and `status_incidents` on
behalf of an anonymous caller, and it emits a purpose-built struct.
**`models.Monitor` is never marshalled to a public caller.** Target URL, host,
port, method, keyword, `state.message`, `state.cert_expiry_at` and
`channel_ids` all stay behind the boundary. A field added to `Monitor` next year
is private by default rather than published by accident.
What the snapshot contains, per entry: display name, current status, uptime
percentage over the last 90 days, and a 90-day history bar of one cell per day.
A cell is up, down, under maintenance, or no-data — `no-data` for days before
the monitor existed, which is a distinct thing from a day it was down. No
latency, no addresses, no failure text.
## Public read path
```
GET /public/status/:pageId
```
Mounted on the gin root, not under `apiGroup`. Putting it under `/api` would
require exempting it from authentication, scope enforcement, token rate
limiting and the licence gate — four holes, each one something a later change
can widen. Outside `/api` it needs none of them.
The instance is resolved from the request host through `auth.InstanceFromHost`.
A host with no instance label, an unknown slug, an unknown page and an
unpublished page all answer **404**, identically.
### The feature gate answers 200, not 403
Status pages are gated by a new `license.FeatureStatusPages = "status_pages"`,
on both the authoring routes and the public read.
The public side checks inline rather than through `RequireFeature`, which
aborts with a 403 JSON body. A public page needs to render an explanation:
```json
{ "available": false, "reason": "feature_unavailable", "title": "Acme Status" }
```
`reason` is `feature_unavailable` when the tier does not include the feature and
`licence_inactive` when the licence has lapsed. The title is included so the
page does not look broken; nothing else is.
**This is not only a server change.** The feature must be added to admin's
`plans` rows per `(deployment, tier)`, or every instance reads it as absent and
the feature ships dark.
### Cache
Redis key `vantage:status:<instance_id>:<page_id>` holds the assembled JSON with
a 30-second TTL. N visitors cost one Mongo read regardless of traffic.
Authoring writes delete the key, so an operator posting an incident update sees
it immediately rather than wondering for half a minute whether it saved.
Redis rather than Next ISR because with `replicaCount > 1` each `web` pod would
cache separately and two visitors would see different states during an incident.
### Rate limit
Per client address, one-minute fixed window, 120 requests, 429 with
`Retry-After` — the same shape as `RateLimitTokens`, including its most
important property: **when Redis is unavailable, allow rather than deny.** A
status page must survive the outage it exists to report.
### Trusted proxies
Nothing calls `r.SetTrustedProxies`, so gin trusts every proxy and
`c.ClientIP()` takes `X-Forwarded-For` verbatim. That is spoofable per request,
which makes a per-address limiter decorative.
This has not mattered so far because `ClientIP()` is only used for audit
strings. It matters now, so this work adds a trusted-proxy configuration and
sets it at boot. Without it the rate limit is theatre.
## Authoring API
Under `/api`, owner or admin, behind `RequireFeature("status_pages")`, every
mutation audited:
```
GET,POST /status-pages
GET,PUT,DELETE /status-pages/:pageId
GET,POST /status-pages/:pageId/incidents
PUT,DELETE /status-pages/:pageId/incidents/:incidentId
POST /status-pages/:pageId/incidents/:incidentId/updates
```
This adds a ninth scope resource, `status:read` and `status:write`. The entries
are required, not optional: `AssertScopeMapComplete` fails boot on an `/api`
route with no scope entry, which is exactly the safeguard working.
Handlers need `@…` annotations and `openapi.json` must be regenerated and
committed — `server-deploy.yml` runs `git diff --exit-code` against the
committed copy, so a handler whose annotation drifted fails CI.
## Frontend
`web/app/status/[pageId]/page.tsx`, **outside the `(app)` route group**, so it
inherits no sidebar, no session fetch and no auth redirect. Server-rendered
against the Go endpoint, with a client refresh every 60 seconds.
`web/next.config.ts` gains a `/public/:path*` rewrite so that client refresh
reaches the server.
The page stays dark, like the rest of `web/`, and carries no hex values — the
existing token palette covers every state it needs.
Authoring UI at `/status-pages` inside `(app)`, in the **Instance** sidebar
group. It is `adminOnly`, and since the whole group is, a member sees the group
disappear entirely rather than a labelled section with nothing under it.
## Testing
The snapshot tests are the ones that matter, because they are the redaction
boundary made executable:
- `BuildStatusSnapshot` output contains no target URL or host, no
`state.message`, no `incident.cause`, no `channel_ids`, no latency.
- A monitor on no page never appears in any page's snapshot.
- An unpublished page and an unknown page both 404.
- Feature absent and licence inactive both return 200 with `available: false`
and the matching `reason`.
- A cache hit performs no Mongo read; an authoring write invalidates the key.
- Slug validation: character set, length, uniqueness within an instance.
- Maintenance window renders the component as under maintenance while leaving
the uptime percentage untouched.
## Migration and rollout
No migration is needed — both collections are new and absent means empty. Index
builders follow the `EnsureWorkflowIndexes` precedent and warn rather than being
fatal: a missing index on a small collection degrades to a scan, which is no
reason to refuse to serve the fleet.
The feature ships dark until the `status_pages` feature is added to the plan
rows in admin.