From 72c9492223c29aad06407ba7bf6604278d77e3e9 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 25 Aug 2026 08:44:38 +0000 Subject: [PATCH] docs: status pages --- CLAUDE.md | 73 ++++++++++++++- docsite/docs/reference/troubleshooting.md | 21 +++++ docsite/docs/vantage/status-pages.md | 107 ++++++++++++++++++++++ docsite/sidebars.ts | 1 + 4 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 docsite/docs/vantage/status-pages.md diff --git a/CLAUDE.md b/CLAUDE.md index dbf878b..f5f1098 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -456,6 +456,76 @@ there are two copies — `agent/internal/grpc/pb` and `server/internal/grpc/pb`. A message added to one must be added to the other and to the `.proto`, in the same commit. +### Status pages + +Two collections: `status_pages` is the page itself — title, banner, published +flag, and an ordered list of sections each holding entries that pair a +`monitor_id` with a per-page display name. `status_incidents` holds both +operator-authored incidents and maintenance windows, sharing one document +shape because they share a timeline, an impact and a set of affected +components; each carries an explicit `page_ids` rather than deriving it from +`affected_monitors`, because adding a monitor to a page later must not +retroactively republish that monitor's old incidents to a new audience. + +**`services.assembleSnapshot` is the redaction boundary, and it is the only +one.** It takes a `snapshotInput` built from already-fetched +`models.Monitor`/`models.Rollup`/`models.Incident` documents and returns a +`StatusSnapshot` built entirely from a parallel, deliberately smaller +vocabulary (`PublicComponent`, `PublicIncident`, …) that has no field for a +target URL, host, port, expected status, keyword, failure message, +certificate expiry, latency, runner or notification channel — `models.Monitor` +itself never reaches an anonymous caller, only the handful of fields +`assembleSnapshot` chooses to copy out of it. Being a pure function of already- +fetched data (no DB calls inside it) is what makes the boundary testable +without a database, which is the only thing standing between an editor adding +a field to `PublicComponent` and that field being a hostname. + +Monitor-detected outages are **derived at read time, never copied**: each +snapshot assembly reads recent `incidents` for the page's monitors and folds +them into the timeline alongside the authored ones. There is no second +incidents table for automatic ones and no reconciliation between two records +of the same outage. A maintenance window in progress **repaints how a day is +drawn, never the uptime number** — `buildDays` computes each day's up/down +state and the 90-day percentage from rollups first, and +`applyMaintenanceRepaint` only overwrites today's display state afterward, so +a component that stayed up throughout a maintenance window still shows as up +in its history. + +The public route, `GET /public/status/:pageId`, is mounted on the gin **root**, +outside `/api`, on purpose: `/api` carries `auth.Middleware`, `RequireScopes`, +`RateLimitTokens` and `RequireActiveLicense` by virtue of where it is mounted, +and a public route living there would need four exemptions — each one a hole a +later change to any of those four could widen back open. A missing page, an +unpublished page, and a page on the wrong host all answer the same 404; +inventing a distinct code for "exists but unpublished" would itself leak that +the page exists. A lapsed licence or a tier lacking `status_pages` answers 200 +with `available:false` and a `reason`, never a 403 or a blank page — the +reader is a member of the public who can do nothing about either condition and +deserves an explanation, not a browser error. + +Assembled snapshots are cached in Redis for **30 seconds**, keyed per +instance and page, and every authoring write (`UpdateStatusPage`, +`DeleteStatusPage`, and every incident mutation) invalidates its page's entry +immediately rather than waiting out the TTL — an operator posting an update +mid-incident should not wonder for half a minute whether it saved. A cache +miss, on Redis being down or on any read error, degrades to reassembly rather +than an error: the status page has to survive the outage it exists to report. +The public endpoint itself is rate limited to **120 requests per minute per +client address**, answering 429 with `Retry-After`, on the same fixed-window +pattern as `RateLimitTokens`. + +**`TRUSTED_PROXIES` is load-bearing for that limiter, not cosmetic.** `main.go` +always calls `gin.SetTrustedProxies` with it; left unset, gin trusts no proxy +and `c.ClientIP()` falls back to the direct peer address — which, sat behind a +real reverse proxy, is the proxy's own address for every visitor. The rate +limiter then keys on one address for the whole fleet of readers, and the first +burst of legitimate traffic during an incident is what trips it. Set it to the +proxy's real address or CIDR, not merely a private range guess; the shipped +compose file and Helm chart default it to the RFC1918 ranges, which is right +for their own bundled reverse proxy but wrong the moment another one is +inserted in front. The same setting also decides the address recorded in +`audit_logs` and `console_sessions`. + ### Agent self-update `UpdateAgentCmd` carries a target version and Gitea base URL; the agent downloads and replaces itself. @@ -831,7 +901,7 @@ Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no ## MongoDB Collections -`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `api_tokens` · `migrations` +`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `api_tokens` · `status_pages` · `status_incidents` · `migrations` Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`. @@ -940,6 +1010,7 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a | `PROXY_ADVERTISE_HOST` | no | default `server`; the hostname guacd resolves the control plane by, handed to guacd as the relay's address. Wrong here and every console session fails at connect | | `PROXY_LISTEN_HOST` | no | default `0.0.0.0`; the interface the ephemeral relay listener binds | | `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard | +| `TRUSTED_PROXIES` | no | comma-separated CIDRs or addresses gin trusts for `X-Forwarded-For`. Empty means trust none: `c.ClientIP()` falls back to the direct peer address, which behind a real reverse proxy is that proxy's own address for every visitor — the public status page's per-address rate limit then keys on one address for the whole fleet of readers. Also the address recorded in `audit_logs` and `console_sessions`. Compose and the Helm chart default it to the RFC1918 ranges, right for their own bundled proxy and wrong the moment another one is inserted in front | | `POD_IP` | no | this pod's own address, set by the Helm chart from the downward API. **Takes precedence over `PROXY_ADVERTISE_HOST`** — a console relay listener belongs to one replica, and a Service address names all of them | | `VANTAGE_MIGRATE_ONLY` | no | run schema setup (migrations, index builders, default-step seeding) and exit without serving. `GRPC_HOST` is not required in this mode. Set by the Helm chart's pre-upgrade Job | | `VANTAGE_SKIP_MIGRATIONS` | no | serve without running schema setup, on the assumption a Job already did. Set by the chart's Deployment whenever `server.migrationJob.enabled`. Unset under Compose, where one process still migrates and then serves | diff --git a/docsite/docs/reference/troubleshooting.md b/docsite/docs/reference/troubleshooting.md index 93dba82..a539364 100644 --- a/docsite/docs/reference/troubleshooting.md +++ b/docsite/docs/reference/troubleshooting.md @@ -160,6 +160,27 @@ needs `host`, `port`, `from` and `to`, and Telegram needs both `token` and | Instance degraded despite a valid-looking licence | It expired more than a few days ago. Pasting a new one still works, which is how you recover | | Cannot enrol another server | The server allowance is reached. Raise it in HQ or remove one | +## A status page 404s or shows no data + +**404, and it should be published.** Check the **Published** toggle on the +page's editor — an unpublished page answers *not found* for everyone, +including you, with no session exemption. Also check the host: the public URL +is `.vantage./status/`, the same +per-instance subdomain everything else in Vantage uses. A wrong or missing +subdomain resolves to no instance at all, which is also a 404. + +**Loads, but shows an explanation instead of components.** This is not a +fault — it is the page working as designed. It means either the licence has +lapsed (a self-hosted instance past its grace period, or a cloud instance +between billing events) or the current tier does not include the **Status +pages** feature. Fix the licence or the plan and the same link starts serving +data again with no republish needed. + +**One component reads `Unknown`.** The monitor behind it was deleted while +still listed on the page. Nothing is checking it any more, so the page says so +rather than showing a stale up or down. Remove the component from the page, +or point it at a replacement monitor, in the page's editor. + ## HQ portal problems The portal is a hosted service, so problems with it are ours to fix rather than diff --git a/docsite/docs/vantage/status-pages.md b/docsite/docs/vantage/status-pages.md new file mode 100644 index 0000000..90f000f --- /dev/null +++ b/docsite/docs/vantage/status-pages.md @@ -0,0 +1,107 @@ +--- +id: status-pages +title: Status pages +sidebar_label: Status pages +--- + +A status page is a public page reporting a chosen set of monitors as up-front +components, with a 90-day history and an uptime percentage per component. It +needs no session and no token to read — anyone with the link can open it, +which is the point: it is what you hand a customer instead of an incident +email. + +Requires the **Status pages** licence feature. If the licence lapses, or the +tier does not include the feature, the page keeps serving — it renders an +explanation rather than data or a broken page, so a customer who follows an +old link never sees an error. + +## Creating a page + +From **Status pages**, choose a page id and a title. The id is 3–40 characters +of lowercase letters, digits and `-`, starting and ending with a letter or +digit. It becomes part of the public URL: + +``` +https://.vantage./status/ +``` + +**The page id cannot be changed after creation.** Once you have shared the +link, changing the id would break it, so pick something you would still be +happy with in a year — `platform`, `api`, a customer's own name for a +dedicated page. + +## Draft versus published + +A new page starts unpublished. Unpublished pages answer *not found* to +anyone who requests them, including you, from a browser without a session — +so you can build out the components and copy before announcing it. Toggle +**Published** when it is ready. Un-publishing later takes it back to *not +found* rather than deleting anything. + +## Sections and components + +A page is organised into **sections** — arbitrary groupings such as "API" or +"Region: EU" — each holding one or more **components**. A component is a +monitor plus a **display name** you choose for this page. + +The display name is never the monitor's own name unless you type it in. An +internal monitor name ("prod-db-primary-eu1") is rarely what you want a +customer reading; give it whatever name makes sense to them, and change it +for a different page without touching the monitor. + +If a monitor listed on a page is later deleted, its component still appears — +reading `Unknown` rather than up or down, because nothing is checking it any +more and claiming otherwise would be a false claim of health. + +## What a visitor sees + +- Component name, current state (up / down / under maintenance / unknown) and + a 90-day uptime percentage. +- A 90-day history bar per component. +- Any active incidents, upcoming maintenance, and a rolling history of both. +- An optional banner across the top of the page (info / warning / critical), + for anything you want said regardless of component state. + +A visitor never sees a target URL, host or port, the check's expected status +or keyword, latency, a certificate expiry date, failure text, or which +notification channel is attached. That is a deliberate boundary, not an +oversight: nothing that would tell a stranger how your infrastructure is +reachable is on this page. + +## Incidents and maintenance + +Two kinds of entries appear on a page's timeline: + +- **Automatic** — a monitor going down opens an incident on any page that + lists it, with no action from you. These appear the moment the monitor's + state changes and close the moment it recovers. +- **Authored** — an incident or maintenance window you create by hand, with + its own title, impact and a set of affected components you choose. You + post updates to it (Investigating → Identified → Monitoring → Resolved) as + the situation develops, and each update is timestamped and kept on the + page's history. + +An authored incident is attached to one or more pages explicitly when you +create it — it does not follow a monitor onto every page that monitor happens +to be listed on. + +### Scheduling maintenance + +A maintenance window has a scheduled start and end (the end must be after the +start) and moves through Scheduled → In progress → Completed. While a window +is in progress and its affected components are within the scheduled time, +those components are drawn as "under maintenance" instead of up or down. + +**Maintenance changes how a day is drawn, never the uptime number itself.** +The 90-day percentage is computed from what actually happened — a component +that stayed up throughout a maintenance window still shows as up in its +history, it is only the live status pill that reads "under maintenance" for +the duration. + +## Delay before an update appears + +A visitor's read of a page is cached for up to 30 seconds, so posting an +update or flipping Published does not necessarily change what a visitor sees +instantly — though most authoring actions invalidate that cache immediately, +so in practice it usually shows within a second or two. If a change genuinely +does not appear, reloading after 30 seconds always will. diff --git a/docsite/sidebars.ts b/docsite/sidebars.ts index e005805..925e5a9 100644 --- a/docsite/sidebars.ts +++ b/docsite/sidebars.ts @@ -29,6 +29,7 @@ const sidebars: SidebarsConfig = { "vantage/vulnerabilities", "vantage/workloads", "vantage/notification-channels", + "vantage/status-pages", "vantage/secrets", "vantage/browser-console", "vantage/audit-log",