Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a0a1953f6 | ||
|
|
3e4ccc9720 | ||
|
|
e5947489e4 | ||
|
|
0a7a10aeed | ||
|
|
28b813ba64 | ||
|
|
68160dc681 | ||
|
|
32e7420d89 | ||
|
|
7e1d67dba4 | ||
|
|
da1dc90ac5 | ||
|
|
72c9492223 | ||
|
|
fa67d839cd | ||
|
|
1452928b75 | ||
|
|
bf10023f35 | ||
|
|
f4f41e400b | ||
|
|
3abbdc41d6 | ||
|
|
6ba54f690c | ||
|
|
a3c6b2a305 | ||
|
|
21a2d077d8 | ||
|
|
6263c7e16f | ||
|
|
161835802d | ||
|
|
6f998ff506 | ||
|
|
d192589790 | ||
|
|
21c2bb2646 | ||
|
|
9c0bbd13dd | ||
|
|
1c15961309 | ||
|
|
383b763a66 | ||
|
|
3e99a9df33 | ||
|
|
f1b6f90345 | ||
|
|
2a660697c5 | ||
|
|
0c08dda635 | ||
|
|
22b99ff895 | ||
|
|
2fab784ba7 | ||
|
|
83cdf92575 | ||
|
|
aa1c8e4aa1 | ||
|
|
ac61015cc0 |
@@ -140,6 +140,11 @@ jobs:
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.web.host=vantage.example.com \
|
||||
--set server.env.grpcHost=agents.example.com:443
|
||||
refuses "an ingress that leaves /api unrouted" \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.web.host=vantage.example.com \
|
||||
--set ingress.grpc.enabled=false \
|
||||
--set ingress.api.enabled=false
|
||||
refuses "gRPC ingress while grpcHost is still in-cluster" \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.web.host=vantage.example.com \
|
||||
|
||||
@@ -456,6 +456,95 @@ 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.
|
||||
|
||||
**The instance is resolved from `X-Forwarded-Host`, not `Host`.** The public
|
||||
page is server-rendered by `web/`, and the SSR fetch cannot set `Host` at all:
|
||||
it is a forbidden header name and undici drops it silently, so the Go server
|
||||
saw `server:8080` and every status page 404'd on every deployment. `web/`
|
||||
forwards the visitor's host in `X-Forwarded-Host` (and their address in
|
||||
`X-Forwarded-For`, or the whole deployment shares one rate-limit bucket), and
|
||||
`publicStatusInstance` honours that header **only when `c.RemoteIP()` is in
|
||||
`TRUSTED_PROXIES`** — it selects a tenant, so an untrusted peer must not be
|
||||
able to name one. It uses `RemoteIP()` and not `ClientIP()` deliberately: the
|
||||
latter is reconstructed from the very headers being judged.
|
||||
|
||||
**A host naming no slug falls back to the sole instance on a non-cloud
|
||||
deployment.** `hostSlug` requires `<slug>.vantage.<tld>`; a self-hosted install
|
||||
at `vantage.acme.com` or an IP has no slug and would otherwise 404 forever. It
|
||||
has exactly one instance, resolved with the same count-then-read bootstrap
|
||||
uses, cached alongside the slug lookups. More than one instance is a 404, not a
|
||||
guess. A host that *does* name a slug which does not exist stays a 404 —
|
||||
falling back there would serve one tenant's page on another's address.
|
||||
|
||||
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.
|
||||
@@ -467,9 +556,9 @@ only as sha256 — the same shape as `servers.agent_token_hash` and the ESO read
|
||||
token, and for the same reason: nothing downstream ever needs the plaintext
|
||||
back. It belongs to the user who created it, and its role can never exceed
|
||||
theirs; see the `api_tokens` note under MongoDB Collections for how that stays
|
||||
true across a demotion rather than only at issuance. Scopes are eight
|
||||
true across a demotion rather than only at issuance. Scopes are nine
|
||||
resources — `servers`, `keys`, `secrets`, `workflows`, `monitors`, `vulns`,
|
||||
`workloads`, `settings` — each split into `:read` and `:write`, with `:write`
|
||||
`workloads`, `status`, `settings` — each split into `:read` and `:write`, with `:write`
|
||||
satisfying a `:read` requirement on the same resource so a caller does not have
|
||||
to hold both. Any signed-in member may mint and revoke their **own** tokens —
|
||||
there is no `RequireRole` on `POST /tokens` or `DELETE /tokens/:id` — because
|
||||
@@ -748,6 +837,10 @@ workloads GET /workloads · GET /servers/:id/workloads
|
||||
POST /servers/:id/workloads/refresh
|
||||
POST /servers/:id/workloads/:wid/action (owner|admin)
|
||||
GET /servers/:id/workloads/:wid/logs (owner|admin)
|
||||
status-pages GET,POST /status-pages · GET,PUT,DELETE /status-pages/:pageId (owner|admin)
|
||||
GET,POST /status-pages/:pageId/incidents
|
||||
PUT,DELETE /status-pages/:pageId/incidents/:incidentId
|
||||
POST /status-pages/:pageId/incidents/:incidentId/updates
|
||||
audit GET /audit
|
||||
agent GET /agent/latest-version
|
||||
settings GET,PUT /settings · POST /settings/secrets-token (owner|admin)
|
||||
@@ -831,7 +924,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/`.
|
||||
|
||||
@@ -856,6 +949,8 @@ Admin's own database is separate and holds `accounts` · `admin_instances` · `l
|
||||
|
||||
`plans` is keyed on `(deployment, tier)` — six rows, two deployments times three tiers — and holds base allowances only. **Every Paddle price ID lives in `catalogue`**, one row per priceable component (`base`, `limit`, `feature`), because a metered plan is priced by several prices and one map on a plan row cannot express that. `entitlements` holds one row per instance with `desired` beside `granted`: the checkout is built from `desired`, a licence is only ever signed from `granted`, and an abandoned checkout therefore leaves a `desired` that reached nothing. The two Free plans have **no catalogue rows at all**, which is what keeps Free outside Paddle.
|
||||
|
||||
**No tier bundles a feature.** `console`, `oidc`, `vuln_scanning` and `status_pages` are each a per-customer priceable add-on: every plan row carries an empty `base_features`, and the grant comes from a `catalogue` row the customer buys. Adding a fifth feature therefore means one more `KindFeature` row per paid plan in `SeedCatalogue` and one entry in `adminsite/lib/features.ts` — that map is what the customer's grant list, the staff configurator and the purchase form all enumerate, so a feature missing from it exists in the licence and is invisible in the portal. `SeedCatalogue` upserts on `(kind, deployment, tier, feature_key)`, so a new row reaches an existing database on the next admin boot with no migration; `SeedPlans` is `$setOnInsert` on the whole document and would not, which is the other reason bundling into a tier is the harder path.
|
||||
|
||||
### Migrations
|
||||
|
||||
`services.RunMigrations()` runs at boot, recording markers in `migrations`:
|
||||
@@ -940,6 +1035,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 |
|
||||
@@ -971,7 +1067,7 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
|
||||
|
||||
**`ingress.web.host` is normally a wildcard.** `*.vantage.example.com` is the per-tenant instance namespace — `APP_ROOT_LABEL` resolves the instance from the label. A Kubernetes wildcard host matches **exactly one** label, so it does not match the apex, and here that is correct rather than a gap: `vantage.hostxtra.co.uk` is the marketing site (`site/`, in `docker-compose.site.yml`), which this chart does not deploy. `extraHosts` is for a genuine second name; adding the apex to it would put the control plane on the marketing host. Every host in the list gets identical paths.
|
||||
|
||||
**`ingress.api.enabled` routes `/api` and `/auth` straight to the server.** Both arrangements work — without it `web` proxies those prefixes onward itself (`web/next.config.ts`) — but edge routing is one hop shorter and matches what the Nginx Proxy Manager in front of the Docker deployment already does, so leaving it off makes the request path a different shape on Kubernetes than in production. It stays **off by default** because it only helps where the server is reachable on the same host and certificate as `web`; turning it on blindly moves the whole API onto a route that may not be provisioned. Traefik derives router priority from rule length, so `PathPrefix(/api)` outranks the catch-all `/` with no priority annotation needed.
|
||||
**`ingress.api.enabled` routes `/api`, `/auth`, `/public`, `/install*` and `/update*` straight to the server, and it is not optional.** It defaults to **true** and the chart refuses to render with it off, because `web` proxies nothing: with those prefixes unrouted the UI loads and every request it makes 404s against Next. The value survives only for an installation whose own terminator sits in front of this ingress and routes them there instead. Traefik derives router priority from rule length, so `PathPrefix(/api)` outranks the catch-all `/` with no priority annotation needed.
|
||||
|
||||
**The gRPC route needs its own Service.** The server terminates no TLS; it speaks plain h2c and always has, with TLS terminated by whatever sits in front. Traefik will not use h2c to a backend unless the *Service* says so, and that annotation applies to every port on the Service — so annotating the shared two-port `<release>-server` would force h2c on its HTTP port too.
|
||||
|
||||
@@ -981,6 +1077,8 @@ TLS is `ingress.tls.secretName` / `grpcSecretName` (pre-existing certificates) *
|
||||
|
||||
---
|
||||
|
||||
**Neither compose file ships a reverse proxy, and both now need one.** `web:3000` serves the UI only; a request to `/api` there is a Next 404. Route `/api`, `/auth`, `/public`, `/install`, `/install.ps1`, `/update`, `/update.ps1` to `server:8080` and everything else to `web:3000` — on vantage.hostxtra.co.uk that is the Nginx Proxy Manager already in front, and it is what a self-hosted install has to configure before the UI works at all.
|
||||
|
||||
`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds five more — `site` (3003), `sitesvc` (8082), `admin` (8083), `adminsite` (3004) and `docsite` (3005) — and is only used on vantage.hostxtra.co.uk.
|
||||
|
||||
`docsite` is the odd one: a **static** build served by `nginx:alpine-slim`, not a Node runtime, and it listens on `80` rather than `3000`. It is reached at **`vantage.hostxtra.co.uk/docs`** — a path on the marketing host, routed by its own Nginx Proxy Manager location, which must sort **above** the catch-all forwarding to `site:3003` or Next answers the 404. A path and not a subdomain because `*.vantage.hostxtra.co.uk` is the per-tenant instance namespace and `APP_ROOT_LABEL` would read a `docs.` label as a tenant slug. NPM forwards the **full** path upstream — it does not strip `/docs` — so `DOCS_BASE_URL`, the proxy location and the directory the image copies the build into (`/usr/share/nginx/html/docs`) must all agree. When they do not, the HTML loads and every asset 404s.
|
||||
@@ -1139,7 +1237,7 @@ git push origin main # server + web deploy
|
||||
| `REGISTRY_USER` | Secret | Gitea username. Must own `RELEASE_TOKEN`, or basic auth is rejected |
|
||||
| ~~`REGISTRY_PASSWORD`~~ | — | **Not used.** Named here historically; no workflow reads it. Referencing an unset secret yields an empty password and a `401 Failed to authenticate user` that looks like a token scope problem. Use `RELEASE_TOKEN` |
|
||||
| `DOCKER_HOST` | Variable | registry host used for image tags |
|
||||
| `API_URL` | **not** a CI variable | `web` reads it at **runtime**, from the container environment — `next.config.ts` is evaluated when `server.js` boots in standalone mode, and the rewrites it feeds are server-side, never browser-side. Default `http://localhost:8080`; compose sets `http://server:8080`. `NEXT_PUBLIC_API_URL` is still honoured as a fallback for existing deployments. |
|
||||
| ~~`API_URL`~~ | — | **Gone.** `web` proxies nothing and holds no address for the control plane. `/api`, `/auth`, `/public`, `/install*` and `/update*` must be routed to `server:8080` by the reverse proxy in front of both; everything else goes to `web:3000`. One variable that could name the wrong host was one request path too many — pointed at the marketing site, `/public/status/…` answered a Next 404 indistinguishable from a status page that does not exist. |
|
||||
| `SITE_API_URL` | Variable | **browser-reachable** sitesvc URL, baked into the `site` image. Required — if empty, both forms report "not connected" and submit nowhere. Must also be in sitesvc's `SITE_ORIGIN`. |
|
||||
| `SITE_CONTACT_EMAIL` | Variable | optional; address shown when a form is misconfigured |
|
||||
| `SITE_URL` | Variable | browser URL of the marketing site, baked into `adminsite` so `/login` can point at `/start`. **Signup has no page in `adminsite` at all** — one signup form, on `site/`. Empty renders no link rather than one that 404s. |
|
||||
|
||||
@@ -67,8 +67,10 @@ func (r CatalogueRow) Priced(env string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// SeedCatalogue inserts the twenty rows the four PAID plans need: a base, a
|
||||
// server limit, and one row per feature key.
|
||||
// SeedCatalogue inserts the twenty-four rows the four PAID plans need: a base, a
|
||||
// server limit, and one row per feature key. The count is deliberate — it moves
|
||||
// whenever shared/license gains a feature, and this comment is how the next
|
||||
// person knows the number was chosen rather than drifted.
|
||||
//
|
||||
// The two Free plans get no rows at all, and that absence is what keeps Free
|
||||
// outside Paddle: with nothing to price, no checkout can be built for it. Do not
|
||||
@@ -86,6 +88,7 @@ func SeedCatalogue(ctx context.Context) error {
|
||||
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureConsole},
|
||||
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureOIDC},
|
||||
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureVulnScanning},
|
||||
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureStatusPages},
|
||||
}
|
||||
for _, r := range rows {
|
||||
filter := bson.M{
|
||||
|
||||
@@ -10,12 +10,14 @@ export const FEATURE_LABEL: Record<string, string> = {
|
||||
console: "Browser console",
|
||||
oidc: "Single sign-on",
|
||||
vuln_scanning: "Vulnerability scanning",
|
||||
status_pages: "Status pages",
|
||||
};
|
||||
|
||||
export const FEATURE_DESC: Record<string, string> = {
|
||||
console: "In-browser SSH, RDP and VNC sessions",
|
||||
oidc: "OIDC sign-in for your whole team",
|
||||
vuln_scanning: "Package inventory matched against distribution security advisories",
|
||||
status_pages: "Public status pages for your customers, built from your monitors",
|
||||
};
|
||||
|
||||
export function featureLabel(key: string): string {
|
||||
|
||||
@@ -18,6 +18,10 @@ const (
|
||||
TypeTCP = "tcp"
|
||||
TypeICMP = "icmp"
|
||||
TypeTLS = "tls"
|
||||
|
||||
// UserAgent identifies Vantage monitor traffic so a WAF rule can single it
|
||||
// out. Match on a prefix, not equality: the version moves.
|
||||
UserAgent = "Vantage-Monitor/1.0 (+https://vantage.hostxtra.co.uk)"
|
||||
)
|
||||
|
||||
|
||||
@@ -84,6 +88,7 @@ func runHTTP(ctx context.Context, s Spec) Result {
|
||||
if err != nil {
|
||||
return Result{Message: err.Error()}
|
||||
}
|
||||
req.Header.Set("User-Agent", UserAgent)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: err.Error()}
|
||||
|
||||
@@ -2,5 +2,5 @@ apiVersion: v2
|
||||
name: vantage
|
||||
description: Helm chart for the Vantage stack (Redis, MongoDB, guacd, server, web)
|
||||
type: application
|
||||
version: 1.0.8
|
||||
version: 1.1.0
|
||||
appVersion: "1.0.8"
|
||||
|
||||
@@ -41,12 +41,9 @@ Ingress (Traefik):
|
||||
{{- range .Values.ingress.web.extraHosts }}
|
||||
https://{{ . }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.api.enabled }}
|
||||
{{ join ", " .Values.ingress.api.paths }} go straight to the server; everything else to web.
|
||||
{{- else }}
|
||||
Everything goes to web, which proxies /api and /auth onward. Set
|
||||
ingress.api.enabled=true to route them at the edge instead.
|
||||
{{- end }}
|
||||
{{ join ", " .Values.ingress.api.paths }} go to the server; everything else to web.
|
||||
web proxies nothing, so those paths must be routed here or by a terminator
|
||||
in front of this ingress.
|
||||
{{- if .Values.ingress.grpc.enabled }}
|
||||
- Agents: {{ .Values.ingress.grpc.host }} (gRPC, h2c behind TLS)
|
||||
Agents dial server.env.grpcHost, currently {{ tpl .Values.server.env.grpcHost . }}.
|
||||
|
||||
@@ -72,6 +72,8 @@ both read it.
|
||||
value: {{ .Values.server.env.proxyAdvertiseHost | quote }}
|
||||
- name: PROXY_LISTEN_HOST
|
||||
value: {{ .Values.server.env.proxyListenHost | quote }}
|
||||
- name: TRUSTED_PROXIES
|
||||
value: {{ .Values.server.env.trustedProxies | quote }}
|
||||
{{- if eq .Values.server.env.deploymentType "cloud" }}
|
||||
- name: VANTAGE_DEPLOYMENT
|
||||
value: "cloud"
|
||||
|
||||
@@ -2,16 +2,14 @@
|
||||
{{/*
|
||||
Two hostnames, because the two audiences arrive over different protocols.
|
||||
|
||||
Browsers reach the web host. What answers there depends on the path: with
|
||||
ingress.api.enabled, /api and /auth go straight to the server and everything
|
||||
else goes to `web`. Without it, everything goes to `web`, which proxies those
|
||||
prefixes onward itself (web/next.config.ts).
|
||||
Browsers reach the web host, and the path decides what answers: /api, /auth,
|
||||
/public, /install* and /update* go to the server, everything else to `web`.
|
||||
|
||||
Both work. Routing at the edge is one hop shorter and is what the Nginx Proxy
|
||||
Manager deployment in front of the Docker install already does, so leaving it
|
||||
off changes the shape of the request path between the two deployments. It is
|
||||
still off by default, because turning it on where `web` is the only thing with
|
||||
a public certificate would strand /api behind a route nobody can reach.
|
||||
That split is not optional and ingress.api.enabled defaults to true. `web`
|
||||
proxies nothing — it holds no address for the server at all — so with these
|
||||
paths absent the UI loads and every request it makes 404s against Next. The
|
||||
setting remains a value only so an installation terminating in front of this
|
||||
ingress can route the prefixes itself; it must be routed somewhere.
|
||||
|
||||
The web host is normally a wildcard — `*.vantage.example.com` — because that is
|
||||
the per-tenant instance namespace; APP_ROOT_LABEL resolves the instance from the
|
||||
@@ -35,6 +33,9 @@ its HTTP port too.
|
||||
{{- if and .Values.ingress.api.enabled (not $apiPaths) }}
|
||||
{{- fail "ingress.api.enabled requires at least one path in ingress.api.paths" }}
|
||||
{{- end }}
|
||||
{{- if not .Values.ingress.api.enabled }}
|
||||
{{- fail "ingress.api.enabled=false leaves /api, /auth and /public unrouted: web proxies nothing. Route those prefixes to the server at your own terminator, or leave this enabled." }}
|
||||
{{- end }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
|
||||
@@ -38,12 +38,10 @@ spec:
|
||||
image: "{{ .Values.web.image.repository }}:{{ .Values.web.image.tag }}"
|
||||
ports:
|
||||
- containerPort: {{ .Values.web.service.port }}
|
||||
env:
|
||||
- name: API_URL
|
||||
value: {{ tpl .Values.web.env.apiUrl . | quote }}
|
||||
# /healthz is served by this Next process; /api is rewritten to the
|
||||
# server, so a probe there would report the backend's health and keep
|
||||
# passing while this pod was wedged.
|
||||
# /healthz is served by this Next process. /api never reaches this
|
||||
# pod at all — the ingress routes it to the server — so there is no
|
||||
# backend address to configure and no probe here that could report
|
||||
# the backend's health by accident.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
|
||||
@@ -63,6 +63,7 @@ server:
|
||||
appRootLabel: vantage
|
||||
proxyAdvertiseHost: "{{ .Release.Name }}-server"
|
||||
proxyListenHost: "0.0.0.0"
|
||||
trustedProxies: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||
persistence:
|
||||
enabled: false
|
||||
size: 1Gi
|
||||
@@ -78,8 +79,6 @@ web:
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 3000
|
||||
env:
|
||||
apiUrl: "http://{{ .Release.Name }}-server:8080"
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
@@ -89,11 +88,14 @@ ingress:
|
||||
web:
|
||||
host: ""
|
||||
extraHosts: []
|
||||
# Not optional: web proxies nothing, so these prefixes reach the server
|
||||
# only through this ingress. Turning it off serves the UI with a dead API.
|
||||
api:
|
||||
enabled: false
|
||||
enabled: true
|
||||
paths:
|
||||
- /api/
|
||||
- /auth/
|
||||
- /public/
|
||||
- /update
|
||||
- /install
|
||||
- /update.ps1
|
||||
|
||||
@@ -47,6 +47,7 @@ services:
|
||||
KEY_ENCRYPTION_KEY: ${KEY_ENCRYPTION_KEY:-}
|
||||
GUACD_ADDR: guacd:4822
|
||||
PROXY_ADVERTISE_HOST: server
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
@@ -59,8 +60,10 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3000:3000
|
||||
environment:
|
||||
API_URL: ${API_URL:-http://server:8080}
|
||||
# No API_URL: web proxies nothing. The reverse proxy in front of this
|
||||
# deployment must route /api, /auth, /public, /install*, /update* to
|
||||
# server:8080 and everything else to web:3000. Reaching web:3000
|
||||
# directly serves the UI and every API call 404s.
|
||||
depends_on:
|
||||
- server
|
||||
volumes:
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
<title>Vantage Status Pages</title>
|
||||
<style>
|
||||
:root{
|
||||
color-scheme: dark;
|
||||
/* Vantage web/ dark tokens, copied verbatim from web/app/globals.css.
|
||||
This mockup commits to one theme because web/ does. */
|
||||
--ground:#071628; --panel:#0d2138; --panel-2:#102842; --well:#04101f;
|
||||
--ink:#e4ecf6; --ink-2:#9fb3ca; --ink-3:#71879f;
|
||||
--rule:#1e3855; --rule-soft:#172c44;
|
||||
--accent:#5b9be8; --accent-hover:#7fb2f0; --accent-ink:#04101f;
|
||||
--up:#4fb484; --pend:#d6a63f; --down:#e2705a; --logo:#7fb2f0;
|
||||
--shadow:0 1px 0 rgba(0,0,0,.35), 0 20px 44px -26px rgba(0,0,0,.85);
|
||||
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--mono: ui-monospace, "Cascadia Mono", "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
--r:4px;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--ground);color:var(--ink);font-family:var(--sans);-webkit-font-smoothing:antialiased;line-height:1.5}
|
||||
a{color:inherit}
|
||||
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
|
||||
.page{max-width:1180px;margin:0 auto;padding:48px 24px 96px;display:flex;flex-direction:column;gap:56px}
|
||||
|
||||
.lede h1{font-size:1.6rem;font-weight:800;letter-spacing:-.035em;text-wrap:balance}
|
||||
.lede p{color:var(--ink-2);font-size:.9rem;max-width:65ch;margin-top:8px}
|
||||
|
||||
.cap{font-family:var(--mono);font-size:.68rem;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-2)}
|
||||
|
||||
.board{display:flex;flex-direction:column;gap:10px}
|
||||
.board__head{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap}
|
||||
.board__route{font-family:var(--mono);font-size:.7rem;color:var(--ink-3)}
|
||||
.frame{border:1px solid var(--rule);border-radius:var(--r);background:var(--ground);box-shadow:var(--shadow);overflow:hidden}
|
||||
|
||||
/* address strip — shows the URL scheme being approved */
|
||||
.addr{display:flex;align-items:center;gap:10px;background:var(--well);border-bottom:1px solid var(--rule);padding:9px 14px}
|
||||
.addr__dots{display:flex;gap:5px}
|
||||
.addr__dots i{width:8px;height:8px;border-radius:999px;background:var(--rule);display:block}
|
||||
.addr__url{font-family:var(--mono);font-size:.72rem;color:var(--ink-2);overflow-x:auto;white-space:nowrap}
|
||||
.addr__url b{color:var(--ink);font-weight:600}
|
||||
.addr__tag{margin-left:auto;font-family:var(--mono);font-size:.62rem;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-3);border:1px solid var(--rule);border-radius:999px;padding:2px 8px;white-space:nowrap}
|
||||
|
||||
/* ---------- public status page ---------- */
|
||||
.pub{padding:40px 28px 32px}
|
||||
.pub__inner{max-width:720px;margin:0 auto;display:flex;flex-direction:column;gap:28px}
|
||||
.pub__head{display:flex;align-items:center;gap:14px}
|
||||
.mark{width:38px;height:38px;border-radius:var(--r);background:var(--panel-2);border:1px solid var(--rule);display:grid;place-items:center;color:var(--logo);font-family:var(--mono);font-weight:700;font-size:.85rem;flex-shrink:0}
|
||||
.pub__head h2{font-size:1.35rem;font-weight:800;letter-spacing:-.03em}
|
||||
.pub__head p{color:var(--ink-2);font-size:.85rem;margin-top:2px}
|
||||
|
||||
.overall{display:flex;align-items:center;gap:11px;border:1px solid;border-radius:var(--r);padding:14px 16px;font-weight:600;font-size:.95rem}
|
||||
.overall--down{background:rgba(226,112,90,.10);border-color:rgba(226,112,90,.30);color:var(--down)}
|
||||
.glyph{width:16px;height:16px;flex-shrink:0}
|
||||
|
||||
.banner{border:1px solid var(--rule);background:var(--panel);border-radius:var(--r);padding:12px 14px;font-size:.85rem;color:var(--ink-2);display:flex;gap:10px}
|
||||
.banner b{color:var(--ink);font-weight:600}
|
||||
|
||||
.group{display:flex;flex-direction:column;gap:10px}
|
||||
.group > .cap{padding-left:2px}
|
||||
|
||||
.card{border:1px solid var(--rule);background:var(--panel);border-radius:var(--r)}
|
||||
.rows > * + *{border-top:1px solid var(--rule-soft)}
|
||||
|
||||
.comp{padding:16px}
|
||||
.comp__top{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:10px}
|
||||
.comp__name{font-weight:600;font-size:.92rem}
|
||||
.state{display:inline-flex;align-items:center;gap:7px;font-size:.78rem;color:var(--ink-2);white-space:nowrap}
|
||||
.dot{width:7px;height:7px;border-radius:999px;display:block;flex-shrink:0}
|
||||
.dot--up{background:var(--up)} .dot--down{background:var(--down)}
|
||||
.dot--maint{background:var(--accent)} .dot--pend{background:var(--pend)}
|
||||
.dot--none{background:var(--rule)}
|
||||
|
||||
.bar{display:flex;gap:2px;overflow-x:auto;padding-bottom:2px}
|
||||
.bar span{height:26px;width:3px;border-radius:999px;flex:0 0 auto;background:var(--rule)}
|
||||
.bar .up{background:var(--up)} .bar .down{background:var(--down)}
|
||||
.bar .maint{background:var(--accent)} .bar .none{background:var(--rule)}
|
||||
.scale{display:flex;justify-content:space-between;margin-top:7px;font-size:.7rem;color:var(--ink-3)}
|
||||
.scale b{color:var(--ink-2);font-weight:600;font-variant-numeric:tabular-nums}
|
||||
|
||||
.inc{padding:14px 16px}
|
||||
.inc__top{display:flex;align-items:baseline;justify-content:space-between;gap:14px}
|
||||
.inc__title{font-weight:600;font-size:.92rem}
|
||||
.inc__meta{font-size:.75rem;color:var(--ink-3);margin-top:3px}
|
||||
.inc__affects{font-size:.75rem;color:var(--ink-2);margin-top:5px}
|
||||
.pill{font-family:var(--mono);font-size:.62rem;text-transform:uppercase;letter-spacing:.1em;border-radius:999px;padding:3px 9px;border:1px solid;white-space:nowrap}
|
||||
.pill--inv{color:var(--down);border-color:rgba(226,112,90,.35);background:rgba(226,112,90,.10)}
|
||||
.pill--mon{color:var(--pend);border-color:rgba(214,166,63,.35);background:rgba(214,166,63,.10)}
|
||||
.pill--res{color:var(--up);border-color:rgba(79,180,132,.35);background:rgba(79,180,132,.10)}
|
||||
.pill--sch{color:var(--accent);border-color:rgba(91,155,232,.35);background:rgba(91,155,232,.10)}
|
||||
.pill--draft{color:var(--ink-2);border-color:var(--rule);background:var(--panel-2)}
|
||||
.pill--live{color:var(--up);border-color:rgba(79,180,132,.35);background:rgba(79,180,132,.10)}
|
||||
|
||||
.timeline{margin-top:12px;border-left:1px solid var(--rule);padding-left:14px;display:flex;flex-direction:column;gap:12px}
|
||||
.tl__head{display:flex;align-items:baseline;gap:9px}
|
||||
.tl__st{font-family:var(--mono);font-size:.62rem;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-2)}
|
||||
.tl__at{font-size:.7rem;color:var(--ink-3);font-variant-numeric:tabular-nums}
|
||||
.tl__body{font-size:.85rem;margin-top:3px;color:var(--ink)}
|
||||
|
||||
.pub__foot{text-align:center;font-size:.72rem;color:var(--ink-3);padding-top:6px}
|
||||
|
||||
/* ---------- editor ---------- */
|
||||
.app{display:grid;grid-template-columns:236px 1fr;min-height:660px}
|
||||
.side{background:var(--panel);border-right:1px solid var(--rule);display:flex;flex-direction:column}
|
||||
.side__brand{height:64px;display:flex;align-items:center;gap:12px;padding:0 20px;border-bottom:1px solid var(--rule);flex-shrink:0}
|
||||
.side__brand .mark{width:32px;height:32px;font-size:.78rem}
|
||||
.side__brand b{font-size:1rem;font-weight:800;letter-spacing:-.035em;display:block;line-height:1.2}
|
||||
.side__nav{padding:16px 12px;display:flex;flex-direction:column;gap:16px}
|
||||
.navgrp + .navgrp{border-top:1px solid var(--rule);padding-top:16px}
|
||||
.navgrp > .cap{padding:0 12px 6px}
|
||||
.navgrp ul{list-style:none;display:flex;flex-direction:column;gap:4px}
|
||||
.navgrp a{position:relative;display:flex;align-items:center;gap:12px;border-radius:var(--r);padding:9px 12px;font-size:.85rem;font-weight:500;color:var(--ink-2);text-decoration:none}
|
||||
.navgrp a:hover{background:var(--panel-2);color:var(--ink)}
|
||||
.navgrp a.on{background:var(--panel-2);color:var(--ink);font-weight:600}
|
||||
.navgrp a.on::before{content:"";position:absolute;left:0;top:4px;bottom:4px;width:2px;border-radius:999px;background:var(--accent)}
|
||||
.navgrp svg{width:16px;height:16px;flex-shrink:0;opacity:.9}
|
||||
|
||||
.main{padding:26px 28px 36px;display:flex;flex-direction:column;gap:22px;min-width:0}
|
||||
.back{font-size:.78rem;color:var(--ink-2);text-decoration:none;display:inline-flex;gap:6px;align-items:center}
|
||||
.back:hover{color:var(--ink)}
|
||||
.phead{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;flex-wrap:wrap}
|
||||
.phead h2{font-size:1.3rem;font-weight:800;letter-spacing:-.03em}
|
||||
.record{display:flex;align-items:center;gap:8px;margin-top:6px}
|
||||
.record code{font-family:var(--mono);font-size:.72rem;color:var(--ink-2);background:var(--well);border:1px solid var(--rule);border-radius:var(--r);padding:3px 8px}
|
||||
.copy{background:none;border:0;color:var(--ink-3);cursor:pointer;font-size:.72rem;font-family:var(--mono)}
|
||||
.copy:hover{color:var(--accent)}
|
||||
.acts{display:flex;gap:9px;flex-wrap:wrap}
|
||||
.btn{font-size:.82rem;font-weight:600;border-radius:var(--r);padding:8px 14px;border:1px solid var(--rule);background:var(--panel);color:var(--ink);cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:7px}
|
||||
.btn:hover{background:var(--panel-2)}
|
||||
.btn--p{background:var(--accent);border-color:var(--accent);color:var(--accent-ink)}
|
||||
.btn--p:hover{background:var(--accent-hover)}
|
||||
|
||||
.panel{border:1px solid var(--rule);background:var(--panel);border-radius:var(--r)}
|
||||
.panel__head{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:13px 16px;border-bottom:1px solid var(--rule)}
|
||||
.panel__head h3{font-size:.95rem;font-weight:700}
|
||||
.panel__head p{font-size:.76rem;color:var(--ink-3);margin-top:2px}
|
||||
.panel__body{padding:16px;display:flex;flex-direction:column;gap:16px}
|
||||
|
||||
.fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px}
|
||||
.field{display:flex;flex-direction:column;gap:6px;min-width:0}
|
||||
.field > label{font-size:.76rem;font-weight:600;color:var(--ink-2)}
|
||||
.field .hint{font-size:.72rem;color:var(--ink-3)}
|
||||
.in{background:var(--well);border:1px solid var(--rule);border-radius:var(--r);padding:8px 11px;font:inherit;font-size:.85rem;color:var(--ink);width:100%}
|
||||
.in::placeholder{color:var(--ink-3)}
|
||||
.in:focus{outline:2px solid var(--accent);outline-offset:-1px;border-color:var(--accent)}
|
||||
.in--mono{font-family:var(--mono);font-size:.8rem}
|
||||
|
||||
.toggle{display:flex;align-items:center;justify-content:space-between;gap:16px;background:var(--panel-2);border:1px solid var(--rule);border-radius:var(--r);padding:12px 14px}
|
||||
.toggle p{font-size:.76rem;color:var(--ink-3);margin-top:3px;max-width:52ch}
|
||||
.toggle b{font-size:.85rem}
|
||||
.sw{width:38px;height:21px;border-radius:999px;background:var(--up);border:0;position:relative;cursor:pointer;flex-shrink:0}
|
||||
.sw::after{content:"";position:absolute;top:2px;left:19px;width:17px;height:17px;border-radius:999px;background:var(--accent-ink)}
|
||||
.sw[aria-checked="false"]{background:var(--rule)}
|
||||
.sw[aria-checked="false"]::after{left:2px;background:var(--ink-3)}
|
||||
|
||||
.sect{border:1px solid var(--rule);border-radius:var(--r);background:var(--panel-2)}
|
||||
.sect__head{display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid var(--rule)}
|
||||
.sect__head .in{max-width:220px}
|
||||
.sect__head .rm{margin-left:auto}
|
||||
.rm{background:none;border:0;color:var(--ink-3);font-size:.75rem;cursor:pointer;font-family:var(--mono)}
|
||||
.rm:hover{color:var(--down)}
|
||||
.entry{display:grid;grid-template-columns:1fr 1fr auto;gap:12px;align-items:center;padding:11px 12px}
|
||||
.entry + .entry{border-top:1px solid var(--rule-soft)}
|
||||
.entry__mon{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||
.entry__mon b{font-size:.84rem;font-weight:600}
|
||||
.entry__mon span{font-family:var(--mono);font-size:.68rem;color:var(--ink-3)}
|
||||
.adds{display:flex;gap:9px;flex-wrap:wrap;padding:0 12px 12px}
|
||||
|
||||
.inc-row{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:13px 14px}
|
||||
.inc-row + .inc-row{border-top:1px solid var(--rule-soft)}
|
||||
.inc-row__l{min-width:0}
|
||||
.inc-row__l b{font-size:.88rem;font-weight:600;display:block}
|
||||
.inc-row__l span{font-size:.74rem;color:var(--ink-3)}
|
||||
.inc-row__r{display:flex;align-items:center;gap:9px;flex-shrink:0}
|
||||
|
||||
.notes{border-top:1px solid var(--rule);padding-top:14px;display:flex;flex-direction:column;gap:7px}
|
||||
.notes li{font-size:.82rem;color:var(--ink-2);display:flex;gap:10px;list-style:none}
|
||||
.notes li b{color:var(--ink);font-weight:600}
|
||||
.notes .k{font-family:var(--mono);font-size:.66rem;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-3);flex:0 0 76px;padding-top:2px}
|
||||
|
||||
@media (max-width:820px){
|
||||
.app{grid-template-columns:1fr}
|
||||
.side{display:none}
|
||||
.entry{grid-template-columns:1fr}
|
||||
.page{padding:32px 16px 64px}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<header class="lede">
|
||||
<p class="cap" style="margin-bottom:10px">Vantage · status pages · mockup for approval</p>
|
||||
<h1>Two screens: what the public sees, and what the operator edits</h1>
|
||||
<p>Drawn with the real <code style="font-family:var(--mono);font-size:.85em">web/</code> dark tokens and the existing sidebar idioms, so what gets approved here is what gets built. The public page is shown mid-incident rather than all-green, because that is the state it exists for.</p>
|
||||
</header>
|
||||
|
||||
<!-- ================= PUBLIC ================= -->
|
||||
<section class="board">
|
||||
<div class="board__head">
|
||||
<p class="cap">1 · Public status page</p>
|
||||
<p class="board__route">web/app/status/[pageId]/page.tsx · no auth, no sidebar</p>
|
||||
</div>
|
||||
|
||||
<div class="frame">
|
||||
<div class="addr">
|
||||
<span class="addr__dots"><i></i><i></i><i></i></span>
|
||||
<span class="addr__url">https://acme.vantage.example.com<b>/status/api</b></span>
|
||||
<span class="addr__tag">signed out</span>
|
||||
</div>
|
||||
|
||||
<div class="pub">
|
||||
<div class="pub__inner">
|
||||
|
||||
<div class="pub__head">
|
||||
<div class="mark">AC</div>
|
||||
<div>
|
||||
<h2>Acme Platform Status</h2>
|
||||
<p>Live availability for the Acme API and dashboard.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overall overall--down">
|
||||
<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="6.4"/><path d="M8 4.8v3.6M8 11.1h.01" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Service disruption
|
||||
</div>
|
||||
|
||||
<div class="banner">
|
||||
<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="var(--accent)" stroke-width="1.6" aria-hidden="true" style="margin-top:2px">
|
||||
<circle cx="8" cy="8" r="6.4"/><path d="M8 7.4v3.8M8 5.1h.01" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span><b>Europe region only.</b> US and APAC are unaffected. Follow this page for updates.</span>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="cap">Active</p>
|
||||
<div class="card">
|
||||
<article class="inc">
|
||||
<div class="inc__top">
|
||||
<div>
|
||||
<p class="inc__title">Elevated error rates on database writes</p>
|
||||
<p class="inc__meta">Started 24 Aug 2026, 09:12 UTC</p>
|
||||
</div>
|
||||
<span class="pill pill--mon">monitoring</span>
|
||||
</div>
|
||||
<p class="inc__affects">Affects Primary database, Public API</p>
|
||||
<div class="timeline">
|
||||
<div>
|
||||
<div class="tl__head"><span class="tl__st">monitoring</span><span class="tl__at">11:40 UTC</span></div>
|
||||
<p class="tl__body">Failover completed. Write latency is back to normal and we are watching for recurrence before calling this resolved.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tl__head"><span class="tl__st">identified</span><span class="tl__at">09:48 UTC</span></div>
|
||||
<p class="tl__body">A failing disk on the primary database node is causing write timeouts. Failover to the standby node is in progress.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tl__head"><span class="tl__st">investigating</span><span class="tl__at">09:15 UTC</span></div>
|
||||
<p class="tl__body">We are investigating a rise in write errors affecting the API.</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="cap">Scheduled maintenance</p>
|
||||
<div class="card">
|
||||
<article class="inc">
|
||||
<div class="inc__top">
|
||||
<div>
|
||||
<p class="inc__title">Object storage capacity upgrade</p>
|
||||
<p class="inc__meta">31 Aug 2026, 02:00 – 04:00 UTC</p>
|
||||
</div>
|
||||
<span class="pill pill--sch">scheduled</span>
|
||||
</div>
|
||||
<p class="inc__affects">Affects Object storage</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="cap">API</p>
|
||||
<div class="card rows">
|
||||
<div class="comp" data-bar="api" data-state="down">
|
||||
<div class="comp__top">
|
||||
<span class="comp__name">Public API</span>
|
||||
<span class="state"><i class="dot dot--down"></i>Down</span>
|
||||
</div>
|
||||
<div class="bar"></div>
|
||||
<div class="scale"><span>90 days ago</span><span><b>99.81%</b> uptime</span><span>Today</span></div>
|
||||
</div>
|
||||
<div class="comp" data-bar="hooks" data-state="up">
|
||||
<div class="comp__top">
|
||||
<span class="comp__name">Webhook delivery</span>
|
||||
<span class="state"><i class="dot dot--up"></i>Operational</span>
|
||||
</div>
|
||||
<div class="bar"></div>
|
||||
<div class="scale"><span>90 days ago</span><span><b>99.99%</b> uptime</span><span>Today</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="cap">Web</p>
|
||||
<div class="card rows">
|
||||
<div class="comp" data-bar="dash" data-state="up">
|
||||
<div class="comp__top">
|
||||
<span class="comp__name">Dashboard</span>
|
||||
<span class="state"><i class="dot dot--up"></i>Operational</span>
|
||||
</div>
|
||||
<div class="bar"></div>
|
||||
<div class="scale"><span>90 days ago</span><span><b>99.97%</b> uptime</span><span>Today</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="cap">Data</p>
|
||||
<div class="card rows">
|
||||
<div class="comp" data-bar="db" data-state="down">
|
||||
<div class="comp__top">
|
||||
<span class="comp__name">Primary database</span>
|
||||
<span class="state"><i class="dot dot--down"></i>Down</span>
|
||||
</div>
|
||||
<div class="bar"></div>
|
||||
<div class="scale"><span>90 days ago</span><span><b>99.62%</b> uptime</span><span>Today</span></div>
|
||||
</div>
|
||||
<div class="comp" data-bar="obj" data-state="maint">
|
||||
<div class="comp__top">
|
||||
<span class="comp__name">Object storage</span>
|
||||
<span class="state"><i class="dot dot--maint"></i>Maintenance</span>
|
||||
</div>
|
||||
<div class="bar"></div>
|
||||
<div class="scale"><span>90 days ago</span><span><b>99.94%</b> uptime</span><span>Today</span></div>
|
||||
</div>
|
||||
<div class="comp" data-bar="new" data-state="up">
|
||||
<div class="comp__top">
|
||||
<span class="comp__name">Search index</span>
|
||||
<span class="state"><i class="dot dot--up"></i>Operational</span>
|
||||
</div>
|
||||
<div class="bar"></div>
|
||||
<div class="scale"><span>90 days ago</span><span><b>100.00%</b> uptime</span><span>Today</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="cap">Past incidents</p>
|
||||
<div class="card rows">
|
||||
<article class="inc">
|
||||
<div class="inc__top">
|
||||
<div>
|
||||
<p class="inc__title">Public API unavailable</p>
|
||||
<p class="inc__meta">2 Aug 2026, 14:02 UTC — resolved 14:19 UTC</p>
|
||||
</div>
|
||||
<span class="pill pill--res">resolved</span>
|
||||
</div>
|
||||
<p class="inc__affects">Affects Public API</p>
|
||||
</article>
|
||||
<article class="inc">
|
||||
<div class="inc__top">
|
||||
<div>
|
||||
<p class="inc__title">Slow dashboard loads in Europe</p>
|
||||
<p class="inc__meta">17 Jul 2026, 08:30 UTC — resolved 10:05 UTC</p>
|
||||
</div>
|
||||
<span class="pill pill--res">resolved</span>
|
||||
</div>
|
||||
<p class="inc__affects">Affects Dashboard</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="pub__foot">Updated 24 Aug 2026, 11:58 UTC · refreshes every 60 seconds</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="notes">
|
||||
<li><span class="k">Redacted</span><span>No target URL, host, port or failure text anywhere on this page. <b>Search index</b> shows the no-data tail as grey cells rather than claiming 100% for days before it existed.</span></li>
|
||||
<li><span class="k">Maintenance</span><span><b>Object storage</b> reads as Maintenance, not Down — but its uptime figure is untouched. The window changes how it is drawn, never what the numbers say.</span></li>
|
||||
<li><span class="k">Colour</span><span>Every state carries a word and a shape as well as a hue. The page is readable with colour vision differences and in greyscale print.</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- ================= EDITOR ================= -->
|
||||
<section class="board">
|
||||
<div class="board__head">
|
||||
<p class="cap">2 · Status page editor</p>
|
||||
<p class="board__route">web/app/(app)/status-pages/[pageId]/page.tsx · owner or admin</p>
|
||||
</div>
|
||||
|
||||
<div class="frame">
|
||||
<div class="app">
|
||||
<aside class="side">
|
||||
<div class="side__brand">
|
||||
<div class="mark">V</div>
|
||||
<div>
|
||||
<b>Vantage</b>
|
||||
<span class="cap">Acme Ltd</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="side__nav">
|
||||
<div class="navgrp">
|
||||
<p class="cap">Fleet</p>
|
||||
<ul>
|
||||
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="12" height="4" rx="1"/><rect x="2" y="9" width="12" height="4" rx="1"/></svg>Servers</a></li>
|
||||
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="2.5" width="11" height="11" rx="1.5"/><path d="M6 6h4v4H6z"/></svg>Workloads</a></li>
|
||||
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M1.5 8.5h3l2-4 3 7 2-3h3"/></svg>Monitors</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navgrp">
|
||||
<p class="cap">Access</p>
|
||||
<ul>
|
||||
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="5.5" cy="8" r="3"/><path d="M8.5 8h6M12 8v2.5"/></svg>SSH Keys</a></li>
|
||||
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="7" width="10" height="6.5" rx="1.5"/><path d="M5.5 7V5a2.5 2.5 0 015 0v2"/></svg>Secrets</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navgrp">
|
||||
<p class="cap">Instance</p>
|
||||
<ul>
|
||||
<li><a href="#" class="on"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M4.5 10.5v-2M8 10.5v-4M11.5 10.5v-3" stroke-linecap="round"/></svg>Status Pages</a></li>
|
||||
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3h10v10H3z"/><path d="M5.5 6.5h5M5.5 9.5h3"/></svg>Audit Log</a></li>
|
||||
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.2"/><path d="M8 1.8v1.6M8 12.6v1.6M14.2 8h-1.6M3.4 8H1.8"/></svg>Settings</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="main">
|
||||
<a class="back" href="#">
|
||||
<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M9.5 3.5L5 8l4.5 4.5"/></svg>
|
||||
All status pages
|
||||
</a>
|
||||
|
||||
<div class="phead">
|
||||
<div>
|
||||
<h2>Acme Platform Status</h2>
|
||||
<div class="record">
|
||||
<code>acme.vantage.example.com/status/api</code>
|
||||
<button class="copy" type="button">copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="acts">
|
||||
<a class="btn" href="#">
|
||||
<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M6.5 3.5h6v6M12.5 3.5L7 9"/><path d="M11 10.5v2h-8v-8h2"/></svg>
|
||||
View page
|
||||
</a>
|
||||
<button class="btn btn--p" type="button">Save changes</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel__head">
|
||||
<div>
|
||||
<h3>Details</h3>
|
||||
<p>What visitors see at the top of the page.</p>
|
||||
</div>
|
||||
<span class="pill pill--live">published</span>
|
||||
</div>
|
||||
<div class="panel__body">
|
||||
<div class="toggle">
|
||||
<div>
|
||||
<b>Published</b>
|
||||
<p>Anyone with the link can read this page. Unpublished pages return not found, so you can compose before announcing.</p>
|
||||
</div>
|
||||
<button class="sw" type="button" role="switch" aria-checked="true" aria-label="Published"></button>
|
||||
</div>
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<label for="f-title">Title</label>
|
||||
<input class="in" id="f-title" value="Acme Platform Status">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-id">Page address</label>
|
||||
<input class="in in--mono" id="f-id" value="api" disabled>
|
||||
<span class="hint">Fixed once created — the link is already out there.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-desc">Description</label>
|
||||
<input class="in" id="f-desc" value="Live availability for the Acme API and dashboard.">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-logo">Logo URL</label>
|
||||
<input class="in in--mono" id="f-logo" placeholder="https://acme.example.com/logo.svg">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-ban">Notice</label>
|
||||
<input class="in" id="f-ban" value="Europe region only. US and APAC are unaffected. Follow this page for updates.">
|
||||
<span class="hint">Shown above everything else. Clear it to remove the notice.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel__head">
|
||||
<div>
|
||||
<h3>Components</h3>
|
||||
<p>Monitors grouped for the public page. Grouping here is separate from the groups on Monitors.</p>
|
||||
</div>
|
||||
<button class="btn" type="button">Add section</button>
|
||||
</div>
|
||||
<div class="panel__body">
|
||||
|
||||
<div class="sect">
|
||||
<div class="sect__head">
|
||||
<input class="in" value="API" aria-label="Section name">
|
||||
<button class="rm" type="button">remove section</button>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<div class="entry__mon">
|
||||
<b>prod-api-eu-health</b>
|
||||
<span>http · every 30s</span>
|
||||
</div>
|
||||
<input class="in" value="Public API" aria-label="Public name for prod-api-eu-health">
|
||||
<button class="rm" type="button">remove</button>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<div class="entry__mon">
|
||||
<b>hooks-dispatch-probe</b>
|
||||
<span>http · every 60s</span>
|
||||
</div>
|
||||
<input class="in" value="Webhook delivery" aria-label="Public name for hooks-dispatch-probe">
|
||||
<button class="rm" type="button">remove</button>
|
||||
</div>
|
||||
<div class="adds"><button class="btn" type="button">Add monitor</button></div>
|
||||
</div>
|
||||
|
||||
<div class="sect">
|
||||
<div class="sect__head">
|
||||
<input class="in" value="Data" aria-label="Section name">
|
||||
<button class="rm" type="button">remove section</button>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<div class="entry__mon">
|
||||
<b>pg-primary-10-0-0-5</b>
|
||||
<span>tcp · every 30s</span>
|
||||
</div>
|
||||
<input class="in" value="Primary database" aria-label="Public name for pg-primary-10-0-0-5">
|
||||
<button class="rm" type="button">remove</button>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<div class="entry__mon">
|
||||
<b>minio-gw</b>
|
||||
<span>http · every 60s</span>
|
||||
</div>
|
||||
<input class="in" placeholder="minio-gw" aria-label="Public name for minio-gw">
|
||||
<button class="rm" type="button">remove</button>
|
||||
</div>
|
||||
<div class="adds"><button class="btn" type="button">Add monitor</button></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel__head">
|
||||
<div>
|
||||
<h3>Incidents</h3>
|
||||
<p>Written by you. Outages Vantage detects appear on the page automatically.</p>
|
||||
</div>
|
||||
<div class="acts">
|
||||
<button class="btn" type="button">Schedule maintenance</button>
|
||||
<button class="btn btn--p" type="button">Open incident</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="inc-row">
|
||||
<div class="inc-row__l">
|
||||
<b>Elevated error rates on database writes</b>
|
||||
<span>Opened 09:12 UTC · 3 updates · affects Primary database, Public API</span>
|
||||
</div>
|
||||
<div class="inc-row__r">
|
||||
<span class="pill pill--mon">monitoring</span>
|
||||
<button class="btn" type="button">Post update</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inc-row">
|
||||
<div class="inc-row__l">
|
||||
<b>Object storage capacity upgrade</b>
|
||||
<span>31 Aug, 02:00–04:00 UTC · affects Object storage</span>
|
||||
</div>
|
||||
<div class="inc-row__r">
|
||||
<span class="pill pill--sch">scheduled</span>
|
||||
<button class="btn" type="button">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inc-row">
|
||||
<div class="inc-row__l">
|
||||
<b>Slow dashboard loads in Europe</b>
|
||||
<span>17 Jul · resolved after 1h 35m · affects Dashboard</span>
|
||||
</div>
|
||||
<div class="inc-row__r">
|
||||
<span class="pill pill--res">resolved</span>
|
||||
<button class="btn" type="button">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="notes">
|
||||
<li><span class="k">Naming</span><span>The monitor's own identifier stays visible on the left; the <b>public name</b> is a separate field beside it. An empty field falls back to the identifier, which the placeholder shows — so publishing an internal name is always a visible choice.</span></li>
|
||||
<li><span class="k">Address</span><span>The page address is fixed after creation and the record line carries the whole URL, click to copy. It is what gets pasted into a support article.</span></li>
|
||||
<li><span class="k">Copy</span><span>Buttons name the outcome: <b>Open incident</b>, <b>Post update</b>, <b>Schedule maintenance</b> — the same words the public timeline then shows.</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 90 daily cells per component. Seeded rather than random so the mockup is
|
||||
// stable between reloads and reviewers are looking at the same picture.
|
||||
const PATTERNS = {
|
||||
api: { downs: [2, 22], maint: [], noData: 0 },
|
||||
hooks: { downs: [], maint: [], noData: 0 },
|
||||
dash: { downs: [38], maint: [], noData: 0 },
|
||||
db: { downs: [0, 1, 12, 13, 47], maint: [], noData: 0 },
|
||||
obj: { downs: [61], maint: [0], noData: 0 },
|
||||
new: { downs: [], maint: [], noData: 61 }
|
||||
};
|
||||
|
||||
document.querySelectorAll(".comp").forEach((comp) => {
|
||||
const p = PATTERNS[comp.dataset.bar];
|
||||
const bar = comp.querySelector(".bar");
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let i = 89; i >= 0; i--) {
|
||||
const cell = document.createElement("span");
|
||||
let cls = "up";
|
||||
if (i >= 90 - p.noData) cls = "none";
|
||||
else if (p.maint.includes(i)) cls = "maint";
|
||||
else if (p.downs.includes(i)) cls = "down";
|
||||
cell.className = cls;
|
||||
cell.title = cls === "none" ? "no data" : cls === "maint" ? "maintenance" : cls === "down" ? "outage" : "operational";
|
||||
frag.appendChild(cell);
|
||||
}
|
||||
bar.appendChild(frag);
|
||||
});
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -96,8 +96,16 @@ rather than run in a half-prepared state.
|
||||
|
||||
## 4. Put a proxy in front
|
||||
|
||||
Point your reverse proxy at `web` on port `3000` and terminate TLS there. The
|
||||
web app reaches the API internally, so there is no need to publish port `8080`.
|
||||
Terminate TLS at your reverse proxy and route **one hostname to two backends**:
|
||||
|
||||
| Path | Backend |
|
||||
| -------------------------------------------------------------------------------- | ------------- |
|
||||
| `/api`, `/auth`, `/public`, `/install`, `/install.ps1`, `/update`, `/update.ps1` | `server:8080` |
|
||||
| everything else | `web:3000` |
|
||||
|
||||
Both rules are required. The web app forwards nothing to the API, so a proxy
|
||||
that sends the whole hostname to `web:3000` serves the interface and answers
|
||||
`404` to every request it makes — starting with the login form.
|
||||
|
||||
Agents connect to port `9090`. Vantage does not terminate TLS itself, so put
|
||||
that port behind your proxy too, with a certificate valid for the name in
|
||||
|
||||
@@ -28,13 +28,14 @@ entitlement.
|
||||
|
||||
## Features
|
||||
|
||||
Three features are enabled per instance rather than bundled into a tier:
|
||||
Four features are enabled per instance rather than bundled into a tier:
|
||||
|
||||
| Feature | What it enables |
|
||||
| ---------------------- | ------------------------------------------------------------------------------- |
|
||||
| Browser console | The [browser console](../vantage/browser-console.md) |
|
||||
| Single sign-on | [Sign-in through your identity provider](../vantage/settings.md#single-sign-on) |
|
||||
| Vulnerability scanning | [Package vulnerability scanning](../vantage/vulnerabilities.md) |
|
||||
| Status pages | [Public status pages](../vantage/status-pages.md) |
|
||||
|
||||
No tier includes them by default; you enable them on the instances that need
|
||||
them.
|
||||
|
||||
@@ -25,6 +25,7 @@ it is absent.
|
||||
| `VANTAGE_LICENSE` | no | | A licence supplied at startup, so an automated install does not have to paste one in |
|
||||
| `VANTAGE_TRIVY_DB_REF` | no | `ghcr.io/aquasecurity/trivy-db:2` | Where the vulnerability database is pulled from. Point it at a mirror for an air-gapped install |
|
||||
| `VANTAGE_VULNDB_DISABLED` | no | | `true` switches [vulnerability scanning](../vantage/vulnerabilities.md) off entirely. Findings already stored are still served, and still shown as stale |
|
||||
| `TRUSTED_PROXIES` | no | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | Comma-separated CIDRs or addresses of proxies allowed to set `X-Forwarded-For`. The shipped Docker Compose and Helm chart default to the private RFC1918 ranges, which covers Nginx Proxy Manager on the Docker bridge network and Traefik on a Kubernetes pod CIDR. An operator whose proxy sits on a public address must set this themselves, or every visitor behind it shares one address for rate-limiting purposes. Unset entirely (outside those shipped defaults) trusts none, so the client address is the direct peer. **On a LAN-only install, narrow this to your proxy's address.** The RFC1918 default trusts every private range, so a client on 192.168.0.0/16 reaching the server directly is itself a "trusted proxy" and can put whatever it likes in `X-Forwarded-For` — and, on the public status route, in `X-Forwarded-Host`. Behind a proxy on a public address, or with no proxy at all, that is not reachable; on a flat LAN it is |
|
||||
|
||||
:::danger `KEY_ENCRYPTION_KEY` has no recovery path
|
||||
It encrypts SSH private keys, vault secrets, OIDC client secrets and console
|
||||
|
||||
@@ -9,7 +9,7 @@ sidebar_label: Ports and networking
|
||||
| Port | Service | Who connects | Expose publicly |
|
||||
| ------- | ----------- | -------------------------------- | --------------- |
|
||||
| `3000` | web | Browsers, via your reverse proxy | Yes, behind TLS |
|
||||
| `8080` | server API | The web app | No, firewall it |
|
||||
| `8080` | server API | Your reverse proxy | Not directly — proxied |
|
||||
| `9090` | server gRPC | Agents | **Yes** |
|
||||
| `4822` | guacd | The server | No, firewall it |
|
||||
| `27017` | MongoDB | The server | No |
|
||||
@@ -20,8 +20,8 @@ sidebar_label: Ports and networking
|
||||
```mermaid
|
||||
flowchart LR
|
||||
B["Browser"] -->|HTTPS| P["Reverse proxy"]
|
||||
P --> W["web :3000"]
|
||||
W --> S["server :8080"]
|
||||
P -->|"everything else"| W["web :3000"]
|
||||
P -->|"/api /auth /public /install* /update*"| S["server :8080"]
|
||||
A["Agent on a managed server"] -->|"gRPC/TLS :9090, outbound"| S
|
||||
S --> G["guacd :4822"]
|
||||
G -->|"relayed over the :9090 stream"| A
|
||||
@@ -77,8 +77,20 @@ On a private network you can skip TLS instead, by setting `tls: false` in each
|
||||
|
||||
## Reverse proxy notes
|
||||
|
||||
- Point the proxy at `web:3000`. The web app reaches the API internally, so
|
||||
`8080` does not need publishing.
|
||||
- **The proxy routes two backends on one hostname**, and both are required:
|
||||
|
||||
| Path | Backend |
|
||||
| ------------------------------------------------------------- | ------------- |
|
||||
| `/api`, `/auth`, `/public`, `/install`, `/install.ps1`, `/update`, `/update.ps1` | `server:8080` |
|
||||
| everything else | `web:3000` |
|
||||
|
||||
The web app forwards nothing to the API. Sending the whole hostname to
|
||||
`web:3000` loads the interface and every request it makes answers `404` —
|
||||
including the login form.
|
||||
|
||||
- Both backends must be the **same** hostname and certificate. The browser
|
||||
calls `/api` relative to the page it is on, and the session cookie is
|
||||
host-only.
|
||||
- The console uses a **WebSocket** at `/api/console/tunnel`. A proxy that does
|
||||
not forward upgrade headers breaks the console and nothing else.
|
||||
- Workflow log streaming is a long-lived response. A short proxy read timeout
|
||||
|
||||
@@ -20,6 +20,13 @@ or is not 64 hex characters.
|
||||
|
||||
## Nobody can sign in
|
||||
|
||||
**Every request 404s and the interface loads fine.** Your reverse proxy sends
|
||||
the whole hostname to `web:3000`. `/api`, `/auth`, `/public`, `/install*` and
|
||||
`/update*` belong to `server:8080` and the web app forwards nothing — see
|
||||
[Ports and networking](./ports-and-networking.md#reverse-proxy-notes). The
|
||||
tell is `curl -si https://<your-host>/auth/bootstrap-status` returning HTML
|
||||
with `x-powered-by: Next.js` instead of JSON.
|
||||
|
||||
**`/setup` appears when users already exist.** The server is pointed at a
|
||||
different database than you think. Check the database name in `MONGO_URI`,
|
||||
which is taken from the end of the URI.
|
||||
@@ -95,6 +102,53 @@ instantaneous.
|
||||
- The keyword no longer appears in the response body.
|
||||
- Retries are `0`, so a single dropped packet flips the state.
|
||||
|
||||
### The check gets a 403, 429 or a CAPTCHA page
|
||||
|
||||
The endpoint is fine and answers a browser normally, but the monitor records a
|
||||
status it never sees by hand. Something between Vantage and the service is
|
||||
blocking automated traffic: a CDN, a WAF, a bot-protection product, a reverse
|
||||
proxy rule, or a rate limiter. The response usually comes from that layer and
|
||||
never reaches the origin at all, so nothing appears in the application's own
|
||||
logs.
|
||||
|
||||
Two things make it hard to spot. The check runs from the control plane's or the
|
||||
agent's address rather than yours, and those addresses are often datacenter
|
||||
ranges that bot protection scores badly. And a browser test proves nothing,
|
||||
because a browser is exactly what the blocking layer is willing to serve.
|
||||
|
||||
Every HTTP check Vantage makes identifies itself:
|
||||
|
||||
```
|
||||
User-Agent: Vantage-Monitor/1.0 (+https://vantage.hostxtra.co.uk)
|
||||
```
|
||||
|
||||
That string is the hook to allow the check through. In whichever product is
|
||||
doing the blocking, add a rule that skips bot protection, managed rules and rate
|
||||
limiting for requests carrying it — Cloudflare, AWS WAF, Azure Front Door,
|
||||
Akamai, Fastly, Imperva, Sucuri, ModSecurity, nginx and HAProxy all match on a
|
||||
request header. The shape of the rule is the same everywhere:
|
||||
|
||||
> If the host is *yours*, the path is *the one being monitored*, and the
|
||||
> User-Agent contains `Vantage-Monitor`, then skip the protection.
|
||||
|
||||
Three details are worth getting right:
|
||||
|
||||
- **Match on `contains`, not equality.** The version in the string moves. An
|
||||
exact match breaks silently on an upgrade, and the symptom is a monitor that
|
||||
goes down on deploy day.
|
||||
- **Keep the rule narrow.** Scope it to the specific host and path being
|
||||
monitored. A User-Agent is not a secret — anyone can send it — so a rule that
|
||||
skips protection site-wide on that string alone is a bypass you have
|
||||
published.
|
||||
- **Allow the source address too, where you can.** Combining the User-Agent with
|
||||
the checker's IP is stronger than either alone. Find the address in your
|
||||
blocking product's own event log; it is whichever client IP was blocked on the
|
||||
monitored path.
|
||||
|
||||
If the endpoint genuinely needs authentication rather than an exception, monitor
|
||||
a purpose-built health path that does not, and leave the protected paths
|
||||
protected.
|
||||
|
||||
## Notifications are not arriving
|
||||
|
||||
Use the channel **Test** button. It goes through the real delivery path, so a
|
||||
@@ -113,6 +167,32 @@ 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 `<your-instance>.vantage.<yourdomain>/status/<page-id>`, 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.
|
||||
|
||||
Third possibility: `/public` is not routed to the server. Check with
|
||||
`curl -si https://<your-instance>.vantage.<yourdomain>/public/status/<page-id>`
|
||||
— JSON is correct, HTML carrying `x-powered-by: Next.js` means the proxy sent
|
||||
that prefix to the web app.
|
||||
|
||||
**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
|
||||
|
||||
@@ -64,6 +64,15 @@ Posts the alert as message content.
|
||||
|
||||
Port `465` uses implicit TLS; anything else uses STARTTLS.
|
||||
|
||||
### Credentials are never read back
|
||||
|
||||
The SMTP `password`, the Telegram `token` and the webhook, Slack and Discord
|
||||
`url`s come back from `GET /api/channels` as `••••••••` — a webhook URL is the
|
||||
authorisation to post to that channel, so it is treated as a credential like
|
||||
the rest. Writing that value back unchanged keeps the stored one, which is what
|
||||
lets you rename a channel without retyping its password. Anything else you send
|
||||
is written as given, so clearing the field clears the credential.
|
||||
|
||||
Alert emails look like the rest of the mail Vantage sends you.
|
||||
|
||||
## The message
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
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://<your-vantage-address>/status/<page-id>
|
||||
```
|
||||
|
||||
On **Vantage Cloud** that address is your instance's own subdomain, so the page
|
||||
is at `https://<your-instance>.vantage.hostxtra.co.uk/status/<page-id>`.
|
||||
|
||||
On a **self-hosted** install it is whatever address you reach Vantage on —
|
||||
`https://vantage.acme.com/status/<page-id>`, or an IP and port on a LAN
|
||||
install. A self-hosted install serves exactly one Vantage instance, so no
|
||||
subdomain is needed to say which one you mean. The **Copy** control next to the
|
||||
page address in the editor gives you the exact URL for your install, which is
|
||||
the one to hand out.
|
||||
|
||||
**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.
|
||||
|
||||
**Delete page**, in the editor header, is the only way to correct a page id you
|
||||
regret — the id is fixed once created. It takes the page, its sections and its
|
||||
authored incidents with it; monitors and their history are untouched. If you
|
||||
only want the page off the internet, un-publish it instead.
|
||||
|
||||
## 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 / pending /
|
||||
unknown) and a 90-day uptime percentage. **Pending** is a monitor that has
|
||||
been added but has not produced a result yet; **unknown** is one nothing is
|
||||
checking any more.
|
||||
- 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, for anything you want said
|
||||
regardless of component state. It is one notice with one appearance — there
|
||||
are no severity levels to choose between.
|
||||
|
||||
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.
|
||||
@@ -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",
|
||||
|
||||
+22
-4
@@ -51,10 +51,10 @@ import (
|
||||
// @name Authorization
|
||||
// @description An API token, sent as "Bearer vt_…". Scoped and optionally expiring.
|
||||
|
||||
// @securityDefinitions.apikey esoAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description The External Secrets read token, rotated under Settings. It reaches /api/secrets/{group}/values and nothing else. It is a different credential from an API token, and the two must never be substituted for one another.
|
||||
// @securityDefinitions.apikey esoAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description The External Secrets read token, rotated under Settings. It reaches /api/secrets/{group}/values and nothing else. It is a different credential from an API token, and the two must never be substituted for one another.
|
||||
func main() {
|
||||
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017")
|
||||
|
||||
@@ -162,6 +162,10 @@ func runSchemaSetup() {
|
||||
log.Printf("warning: failed to ensure workflow indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureMonitorSampleIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure monitor sample indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureVulnIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure vuln indexes: %v", err)
|
||||
}
|
||||
@@ -170,6 +174,10 @@ func runSchemaSetup() {
|
||||
log.Printf("warning: failed to ensure workload indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureStatusPageIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure status page indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureAuditIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure audit indexes: %v", err)
|
||||
}
|
||||
@@ -255,9 +263,19 @@ func serve() {
|
||||
})
|
||||
|
||||
r := gin.New()
|
||||
// Without this gin trusts every proxy and ClientIP() is whatever the
|
||||
// caller wrote in X-Forwarded-For. That was survivable while ClientIP()
|
||||
// only produced audit strings; the public status limiter makes it load
|
||||
// bearing. Empty means trust nobody, which is correct for a direct
|
||||
// exposure and wrong behind a proxy — hence the explicit setting.
|
||||
if err := r.SetTrustedProxies(api.TrustedProxies()); err != nil {
|
||||
log.Fatalf("trusted proxies: %v", err)
|
||||
}
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/api/console/tunnel"}}))
|
||||
r.Use(corsMiddleware())
|
||||
services.SetStatusRedis(auth.Redis())
|
||||
|
||||
api.RegisterRoutes(r)
|
||||
|
||||
if err := api.AssertScopeMapComplete(r); err != nil {
|
||||
|
||||
@@ -34,7 +34,14 @@ func listChannels(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, channels)
|
||||
// Redacted here rather than in the service: the dispatchers read the same
|
||||
// documents and need the real credentials, so the masking belongs to the
|
||||
// boundary that hands them to a client.
|
||||
out := make([]models.NotificationChannel, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
out = append(out, ch.Redacted())
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// createChannel godoc
|
||||
@@ -69,7 +76,7 @@ func createChannel(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, created)
|
||||
c.JSON(http.StatusCreated, created.Redacted())
|
||||
}
|
||||
|
||||
// updateChannel godoc
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,10 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
r.GET("/auth/oidc/:providerId/callback", auth.HandleSSOCallback)
|
||||
r.GET("/auth/providers", auth.HandleListPublicProviders)
|
||||
|
||||
// Completely public: no session, no token, no licence gate. Mounted here
|
||||
// rather than under /api precisely so that none of those apply.
|
||||
r.GET("/public/status/:pageId", RateLimitPublicStatus(), getPublicStatusPage)
|
||||
|
||||
apiGroup := r.Group("/api")
|
||||
apiGroup.Use(auth.Middleware())
|
||||
// Scope enforcement sits between authentication and the licence gate, and
|
||||
@@ -162,6 +166,8 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
apiGroup.POST("/servers/:id/workloads/refresh", refreshServerWorkloads)
|
||||
apiGroup.POST("/servers/:id/workloads/:wid/action", auth.RequireRole("owner", "admin"), controlWorkload)
|
||||
apiGroup.GET("/servers/:id/workloads/:wid/logs", auth.RequireRole("owner", "admin"), getWorkloadLogs)
|
||||
|
||||
registerStatusPageRoutes(apiGroup)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
@@ -19,6 +20,7 @@ func registerMonitorRoutes(g *gin.RouterGroup) {
|
||||
g.DELETE("/monitors/:id", deleteMonitor)
|
||||
g.GET("/monitors/:id/incidents", getMonitorIncidents)
|
||||
g.GET("/monitors/:id/uptime", getMonitorUptime)
|
||||
g.GET("/monitors/:id/samples", getMonitorSamples)
|
||||
}
|
||||
|
||||
// listMonitors godoc
|
||||
@@ -111,7 +113,7 @@ func getMonitor(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Param body body object{name=string,type=string,target=models.MonitorTarget,interval_sec=int,runner=string,retries=int,enabled=bool,channel_ids=[]string} true "Fields to update"
|
||||
// @Param body body object{name=string,group=string,type=string,target=models.MonitorTarget,interval_sec=int,runner=string,retries=int,enabled=bool,channel_ids=[]string} true "Fields to update"
|
||||
// @Success 204
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
@@ -121,6 +123,7 @@ func getMonitor(c *gin.Context) {
|
||||
func updateMonitor(c *gin.Context) {
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Group *string `json:"group"`
|
||||
Type *string `json:"type"`
|
||||
Target *models.MonitorTarget `json:"target"`
|
||||
IntervalSec *int `json:"interval_sec"`
|
||||
@@ -137,6 +140,9 @@ func updateMonitor(c *gin.Context) {
|
||||
if body.Name != nil {
|
||||
upd["name"] = *body.Name
|
||||
}
|
||||
if body.Group != nil {
|
||||
upd["group"] = *body.Group
|
||||
}
|
||||
if body.Type != nil {
|
||||
upd["type"] = *body.Type
|
||||
}
|
||||
@@ -217,6 +223,49 @@ func getMonitorIncidents(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, incidents)
|
||||
}
|
||||
|
||||
// getMonitorSamples godoc
|
||||
//
|
||||
// @Summary Get a monitor's individual check results
|
||||
// @Description Raw check results for the last `minutes` minutes, oldest first. Samples expire after 48 hours; use the uptime rollups for longer ranges.
|
||||
// @Tags monitors
|
||||
// @Produce json
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Param minutes query int false "Window in minutes (default 60, max 2880)"
|
||||
// @Success 200 {array} models.MonitorSample
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id}/samples [get]
|
||||
func getMonitorSamples(c *gin.Context) {
|
||||
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if m == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"})
|
||||
return
|
||||
}
|
||||
// Clamped rather than rejected: the window is a view setting, and the only
|
||||
// honest answer past the TTL is the shorter window anyway.
|
||||
minutes := 60
|
||||
if raw := c.Query("minutes"); raw != "" {
|
||||
if n, convErr := strconv.Atoi(raw); convErr == nil && n > 0 {
|
||||
minutes = n
|
||||
}
|
||||
}
|
||||
if max := int(services.MonitorSampleTTL.Minutes()); minutes > max {
|
||||
minutes = max
|
||||
}
|
||||
samples, err := services.MonitorSamples(auth.InstanceID(c), c.Param("id"), time.Now().Add(-time.Duration(minutes)*time.Minute))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, samples)
|
||||
}
|
||||
|
||||
// getMonitorUptime godoc
|
||||
//
|
||||
// @Summary Get a monitor's uptime rollups
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// publicStatusRateLimit is per client address per minute. Generous enough that
|
||||
// a busy page during an outage is unaffected, small enough that scanning for
|
||||
// page ids is not free.
|
||||
const publicStatusRateLimit = 120
|
||||
|
||||
// RateLimitPublicStatus counts requests per client address in a one-minute
|
||||
// fixed window, exactly as RateLimitTokens does — including the part that
|
||||
// matters most: when Redis is unavailable it allows rather than denies. A
|
||||
// status page must survive the outage it exists to report.
|
||||
func RateLimitPublicStatus() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rdb := auth.Redis()
|
||||
if rdb == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
window := time.Now().UTC().Unix() / 60
|
||||
key := "vantage:statusrl:" + c.ClientIP() + ":" + strconv.FormatInt(window, 10)
|
||||
|
||||
count, err := rdb.Incr(c.Request.Context(), key).Result()
|
||||
if err != nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if count == 1 {
|
||||
rdb.Expire(c.Request.Context(), key, 2*time.Minute)
|
||||
}
|
||||
if count > publicStatusRateLimit {
|
||||
c.Header("Retry-After", "60")
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "too many requests",
|
||||
"code": "rate_limited",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// getPublicStatusPage is the only unauthenticated read of monitor data in the
|
||||
// product.
|
||||
//
|
||||
// It is mounted on the gin root rather than under /api on purpose: /api
|
||||
// carries auth.Middleware, RequireScopes, RateLimitTokens and
|
||||
// RequireActiveLicense by virtue of where it is mounted, and a public route
|
||||
// there would need four exemptions, each one a hole a later change can widen.
|
||||
//
|
||||
// Unknown host, unknown page and unpublished page all answer the same 404.
|
||||
//
|
||||
// It carries no @Router annotation deliberately. openapi.json declares a
|
||||
// single server of "/api", so a @Router of /public/status/{pageId} would be
|
||||
// published as /api/public/status/{pageId} — a path that does not exist, and
|
||||
// which would sit behind auth.Middleware if it did. The real address is:
|
||||
//
|
||||
// GET {scheme}://{instance-host}/public/status/{pageId}
|
||||
//
|
||||
// on the gin root, unauthenticated, rate limited per client address.
|
||||
//
|
||||
// @Summary Public status page
|
||||
// @Tags status
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Status page id"
|
||||
// @Success 200 {object} services.StatusSnapshot
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 429 {object} ErrorResponse
|
||||
func getPublicStatusPage(c *gin.Context) {
|
||||
pageID := c.Param("pageId")
|
||||
inst, ok := publicStatusInstance(c)
|
||||
if !ok {
|
||||
// Every 404 on this route is indistinguishable to the caller by
|
||||
// design, so the log is the only place the three reasons are told
|
||||
// apart. It carries no monitor data and no page contents.
|
||||
log.Printf("public status: 404 page=%q reason=no_instance", pageID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
snap, err := services.PublicStatusSnapshot(inst.InstanceID, pageID)
|
||||
if errors.Is(err, services.ErrPageNotFound) {
|
||||
log.Printf("public status: 404 page=%q instance=%s reason=page_missing_or_unpublished", pageID, inst.InstanceID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("public status: 500 page=%q instance=%s: %v", pageID, inst.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
// Public and cacheable, but only briefly: an intermediary holding this for
|
||||
// minutes would show a resolved incident as ongoing.
|
||||
c.Header("Cache-Control", "public, max-age=30")
|
||||
c.JSON(http.StatusOK, snap)
|
||||
}
|
||||
|
||||
// publicStatusInstance resolves which instance a public request is for.
|
||||
//
|
||||
// The browser never reaches this handler directly: the request arrives from
|
||||
// the Next server, which forwards the visitor's host in X-Forwarded-Host
|
||||
// because the Host header cannot be set on a fetch (undici drops it silently,
|
||||
// as a forbidden header name). That makes X-Forwarded-Host a tenant selector,
|
||||
// so it is honoured only when the machine that opened the connection is one of
|
||||
// the configured trusted proxies.
|
||||
//
|
||||
// When the resulting host names no slug at all — vantage.acme.com,
|
||||
// status.acme.com, a bare IP — and the deployment is not cloud, the single
|
||||
// instance of that install is used. A self-hosted install has exactly one, and
|
||||
// without this every self-hosted status page 404s forever. More than one is a
|
||||
// refusal rather than a guess.
|
||||
func publicStatusInstance(c *gin.Context) (*models.Instance, bool) {
|
||||
// Host resolution is where this route fails silently: an untrusted peer
|
||||
// means X-Forwarded-Host is ignored and the request host is the Go
|
||||
// service's own name, which names no slug. Log the inputs and the branch
|
||||
// taken, so the 404 says which of the four it was.
|
||||
host := c.Request.Host
|
||||
xfh := firstForwarded(c.GetHeader("X-Forwarded-Host"))
|
||||
trusted := trustedPeer(c)
|
||||
if trusted && xfh != "" {
|
||||
host = xfh
|
||||
}
|
||||
log.Printf("public status: resolve peer=%s trusted=%t request_host=%q x_forwarded_host=%q using_host=%q slug=%q",
|
||||
c.RemoteIP(), trusted, c.Request.Host, xfh, host, auth.HostSlug(host))
|
||||
|
||||
if inst, ok := auth.InstanceForHost(host); ok {
|
||||
return inst, true
|
||||
}
|
||||
if slug := auth.HostSlug(host); slug != "" {
|
||||
// The host named an instance and that instance does not exist.
|
||||
log.Printf("public status: no instance for slug=%q (host=%q)", slug, host)
|
||||
return nil, false
|
||||
}
|
||||
if services.DeploymentMode() == license.DeploymentCloud {
|
||||
log.Printf("public status: host %q names no slug and deployment is cloud, refusing to guess", host)
|
||||
return nil, false
|
||||
}
|
||||
inst, ok := auth.SoleInstance()
|
||||
if !ok {
|
||||
log.Printf("public status: host %q names no slug and this deployment has no single instance", host)
|
||||
}
|
||||
return inst, ok
|
||||
}
|
||||
@@ -100,6 +100,7 @@ var routeScopes = map[string]string{
|
||||
"DELETE /api/monitors/:id": "monitors:write",
|
||||
"GET /api/monitors/:id/incidents": "monitors:read",
|
||||
"GET /api/monitors/:id/uptime": "monitors:read",
|
||||
"GET /api/monitors/:id/samples": "monitors:read",
|
||||
|
||||
// Channel routes, registered by registerChannelRoutes. Channels exist to
|
||||
// serve alerts, so they share the monitors scope rather than getting their
|
||||
@@ -155,6 +156,19 @@ var routeScopes = map[string]string{
|
||||
"GET /api/openapi.json": "settings:read",
|
||||
"GET /api/docs": "settings:read",
|
||||
"GET /api/docs/scalar.js": "settings:read",
|
||||
|
||||
// Status pages. Reading is status:read even though the pages themselves
|
||||
// are public, because these routes read the unpublished ones too.
|
||||
"GET /api/status-pages": "status:read",
|
||||
"POST /api/status-pages": "status:write",
|
||||
"GET /api/status-pages/:pageId": "status:read",
|
||||
"PUT /api/status-pages/:pageId": "status:write",
|
||||
"DELETE /api/status-pages/:pageId": "status:write",
|
||||
"GET /api/status-pages/:pageId/incidents": "status:read",
|
||||
"POST /api/status-pages/:pageId/incidents": "status:write",
|
||||
"PUT /api/status-pages/:pageId/incidents/:incidentId": "status:write",
|
||||
"DELETE /api/status-pages/:pageId/incidents/:incidentId": "status:write",
|
||||
"POST /api/status-pages/:pageId/incidents/:incidentId/updates": "status:write",
|
||||
}
|
||||
|
||||
// RequireScopes enforces routeScopes for token-authenticated requests and does
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func registerStatusPageRoutes(g *gin.RouterGroup) {
|
||||
// Owner or admin throughout: publishing a page is speaking to the public
|
||||
// in the instance's name. The feature gate sits alongside the role gate so
|
||||
// authoring and serving are gated by the same licence feature.
|
||||
sp := g.Group("/status-pages")
|
||||
sp.Use(auth.RequireRole("owner", "admin"), RequireFeature(license.FeatureStatusPages))
|
||||
|
||||
sp.GET("", listStatusPages)
|
||||
sp.POST("", createStatusPage)
|
||||
sp.GET("/:pageId", getStatusPage)
|
||||
sp.PUT("/:pageId", updateStatusPage)
|
||||
sp.DELETE("/:pageId", deleteStatusPage)
|
||||
sp.GET("/:pageId/incidents", listStatusIncidents)
|
||||
sp.POST("/:pageId/incidents", createStatusIncident)
|
||||
sp.PUT("/:pageId/incidents/:incidentId", updateStatusIncident)
|
||||
sp.DELETE("/:pageId/incidents/:incidentId", deleteStatusIncident)
|
||||
sp.POST("/:pageId/incidents/:incidentId/updates", appendStatusIncidentUpdate)
|
||||
}
|
||||
|
||||
// statusPageError maps the service errors onto codes once, so ten handlers do
|
||||
// not each invent their own. services.ErrPageInvalid covers every validation
|
||||
// failure in the status page and incident services — a missing title or an
|
||||
// invalid incident status is a 400, not a 500.
|
||||
func statusPageError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrPageNotFound), errors.Is(err, services.ErrIncidentNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrPageIDTaken):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrInvalidPageID), errors.Is(err, services.ErrPageInvalid):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// listStatusPages godoc
|
||||
//
|
||||
// @Summary List status pages
|
||||
// @Tags status-pages
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.StatusPage
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages [get]
|
||||
func listStatusPages(c *gin.Context) {
|
||||
pages, err := services.ListStatusPages(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, pages)
|
||||
}
|
||||
|
||||
// createStatusPage godoc
|
||||
//
|
||||
// @Summary Create a status page
|
||||
// @Tags status-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.StatusPage true "Status page"
|
||||
// @Success 201 {object} models.StatusPage
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages [post]
|
||||
func createStatusPage(c *gin.Context) {
|
||||
var p models.StatusPage
|
||||
if err := c.ShouldBindJSON(&p); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
created, err := services.CreateStatusPage(auth.InstanceID(c), &p)
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "status_page_created", actorFromCtx(c), "", "",
|
||||
"Status page '"+created.PageID+"' created")
|
||||
c.JSON(http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// getStatusPage godoc
|
||||
//
|
||||
// @Summary Get a status page
|
||||
// @Tags status-pages
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Success 200 {object} models.StatusPage
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId} [get]
|
||||
func getStatusPage(c *gin.Context) {
|
||||
page, err := services.GetStatusPage(auth.InstanceID(c), c.Param("pageId"))
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, page)
|
||||
}
|
||||
|
||||
// updateStatusPage godoc
|
||||
//
|
||||
// @Summary Update a status page
|
||||
// @Tags status-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Param body body models.StatusPage true "Status page"
|
||||
// @Success 200 {object} models.StatusPage
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId} [put]
|
||||
func updateStatusPage(c *gin.Context) {
|
||||
var p models.StatusPage
|
||||
if err := c.ShouldBindJSON(&p); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, err := services.UpdateStatusPage(auth.InstanceID(c), c.Param("pageId"), &p)
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "status_page_updated", actorFromCtx(c), "", "",
|
||||
"Status page '"+updated.PageID+"' updated")
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// deleteStatusPage godoc
|
||||
//
|
||||
// @Summary Delete a status page
|
||||
// @Tags status-pages
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId} [delete]
|
||||
func deleteStatusPage(c *gin.Context) {
|
||||
if err := services.DeleteStatusPage(auth.InstanceID(c), c.Param("pageId")); err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "status_page_deleted", actorFromCtx(c), "", "",
|
||||
"Status page '"+c.Param("pageId")+"' deleted")
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// listStatusIncidents godoc
|
||||
//
|
||||
// @Summary List authored incidents for a status page
|
||||
// @Tags status-pages
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Success 200 {array} models.StatusIncident
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId}/incidents [get]
|
||||
func listStatusIncidents(c *gin.Context) {
|
||||
incs, err := services.ListStatusIncidents(auth.InstanceID(c), c.Param("pageId"))
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, incs)
|
||||
}
|
||||
|
||||
// createStatusIncident godoc
|
||||
//
|
||||
// @Summary Create an incident or maintenance window
|
||||
// @Tags status-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Param body body models.StatusIncident true "Incident"
|
||||
// @Success 201 {object} models.StatusIncident
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId}/incidents [post]
|
||||
func createStatusIncident(c *gin.Context) {
|
||||
var inc models.StatusIncident
|
||||
if err := c.ShouldBindJSON(&inc); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// The page in the path is always one of the pages the incident names, so
|
||||
// creating from a page cannot produce an incident that page never shows.
|
||||
if !contains(inc.PageIDs, c.Param("pageId")) {
|
||||
inc.PageIDs = append(inc.PageIDs, c.Param("pageId"))
|
||||
}
|
||||
created, err := services.CreateStatusIncident(auth.InstanceID(c), &inc)
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "status_incident_created", actorFromCtx(c), "", "",
|
||||
"Status "+created.Kind+" '"+created.Title+"' created")
|
||||
c.JSON(http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func contains(list []string, want string) bool {
|
||||
for _, v := range list {
|
||||
if v == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// updateStatusIncident godoc
|
||||
//
|
||||
// @Summary Update an incident or maintenance window
|
||||
// @Tags status-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Param incidentId path string true "Incident id"
|
||||
// @Param body body models.StatusIncident true "Incident"
|
||||
// @Success 200 {object} models.StatusIncident
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId}/incidents/{incidentId} [put]
|
||||
func updateStatusIncident(c *gin.Context) {
|
||||
var inc models.StatusIncident
|
||||
if err := c.ShouldBindJSON(&inc); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, err := services.UpdateStatusIncident(auth.InstanceID(c), c.Param("incidentId"), &inc)
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "status_incident_updated", actorFromCtx(c), "", "",
|
||||
"Status "+updated.Kind+" '"+updated.Title+"' updated")
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// deleteStatusIncident godoc
|
||||
//
|
||||
// @Summary Delete an incident or maintenance window
|
||||
// @Tags status-pages
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Param incidentId path string true "Incident id"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId}/incidents/{incidentId} [delete]
|
||||
func deleteStatusIncident(c *gin.Context) {
|
||||
if err := services.DeleteStatusIncident(auth.InstanceID(c), c.Param("incidentId")); err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "status_incident_deleted", actorFromCtx(c), "", "",
|
||||
"Status incident '"+c.Param("incidentId")+"' deleted")
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// appendStatusIncidentUpdate godoc
|
||||
//
|
||||
// @Summary Post an update to an incident
|
||||
// @Tags status-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Param incidentId path string true "Incident id"
|
||||
// @Param body body StatusIncidentUpdateRequest true "Update"
|
||||
// @Success 200 {object} models.StatusIncident
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId}/incidents/{incidentId}/updates [post]
|
||||
func appendStatusIncidentUpdate(c *gin.Context) {
|
||||
var body StatusIncidentUpdateRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, err := services.AppendStatusIncidentUpdate(
|
||||
auth.InstanceID(c), c.Param("incidentId"), body.Status, body.Body, actorFromCtx(c))
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "status_incident_update_posted", actorFromCtx(c), "", "",
|
||||
"Update posted to '"+updated.Title+"' ("+body.Status+")")
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TrustedProxies reads TRUSTED_PROXIES, a comma-separated list of CIDRs or
|
||||
// addresses. Unset means trust none: ClientIP() is then the peer address,
|
||||
// which is right for a direct exposure and means every request behind an
|
||||
// un-configured proxy shares one address for rate limiting. That is a visible
|
||||
// failure (one client limited) rather than an invisible one (no limit at all).
|
||||
//
|
||||
// This lives here rather than in main.go because the string has two consumers:
|
||||
// gin's own SetTrustedProxies, which main.go calls with it, and trustedPeer
|
||||
// below, which the public status page uses to decide whether to believe an
|
||||
// X-Forwarded-Host. One variable, one parser.
|
||||
func TrustedProxies() []string {
|
||||
v := strings.TrimSpace(os.Getenv("TRUSTED_PROXIES"))
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
out := []string{}
|
||||
for _, p := range strings.Split(v, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
trustedNetsOnce sync.Once
|
||||
trustedNets []*net.IPNet
|
||||
)
|
||||
|
||||
func parsedTrustedNets() []*net.IPNet {
|
||||
trustedNetsOnce.Do(func() {
|
||||
for _, entry := range TrustedProxies() {
|
||||
if _, n, err := net.ParseCIDR(entry); err == nil {
|
||||
trustedNets = append(trustedNets, n)
|
||||
continue
|
||||
}
|
||||
// A bare address is a /32 or /128.
|
||||
if ip := net.ParseIP(entry); ip != nil {
|
||||
bits := 32
|
||||
if ip.To4() == nil {
|
||||
bits = 128
|
||||
}
|
||||
trustedNets = append(trustedNets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
|
||||
}
|
||||
}
|
||||
})
|
||||
return trustedNets
|
||||
}
|
||||
|
||||
// trustedPeer reports whether the immediate peer is one of the configured
|
||||
// proxies.
|
||||
//
|
||||
// It deliberately uses RemoteIP() rather than ClientIP(): ClientIP() is the
|
||||
// reconstructed *client* address, which is derived from the very headers this
|
||||
// function exists to decide whether to believe. X-Forwarded-Host selects a
|
||||
// tenant on the public status route, so it is only honoured when the machine
|
||||
// that actually opened the connection is trusted to have set it.
|
||||
func trustedPeer(c *gin.Context) bool {
|
||||
nets := parsedTrustedNets()
|
||||
if len(nets) == 0 {
|
||||
return false
|
||||
}
|
||||
ip := net.ParseIP(c.RemoteIP())
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, n := range nets {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -237,3 +237,12 @@ type WorkloadLogsResponse struct {
|
||||
Text string `json:"text"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
// --- status pages ---
|
||||
|
||||
// StatusIncidentUpdateRequest is one post to an incident's timeline. The author
|
||||
// is taken from the session, never from the body.
|
||||
type StatusIncidentUpdateRequest struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
Body string `json:"body" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ var (
|
||||
|
||||
const instanceCacheTTL = 60 * time.Second
|
||||
|
||||
// soleInstanceCacheKey cannot collide with a slug: a slug is [a-z0-9-] and can
|
||||
// never contain a NUL.
|
||||
const soleInstanceCacheKey = "\x00sole"
|
||||
|
||||
func appRootLabel() string {
|
||||
if v := os.Getenv("APP_ROOT_LABEL"); v != "" {
|
||||
return strings.ToLower(v)
|
||||
@@ -50,25 +54,78 @@ func hostSlug(host string) string {
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
// HostSlug exposes the slug rules to callers outside this package that need to
|
||||
// distinguish "this host names no instance at all" from "this host names an
|
||||
// instance that does not exist". It is a thin wrapper rather than a second
|
||||
// implementation on purpose.
|
||||
func HostSlug(host string) string { return hostSlug(host) }
|
||||
|
||||
// InstanceFromHost resolves the instance named by the request's own Host
|
||||
// header. Callers that must resolve a host from somewhere else — the public
|
||||
// status page reads a trusted X-Forwarded-Host — use InstanceForHost so the
|
||||
// slug rules and the 60s cache stay single-implementation.
|
||||
func InstanceFromHost(c *gin.Context) (*models.Instance, bool) {
|
||||
slug := hostSlug(c.Request.Host)
|
||||
return InstanceForHost(c.Request.Host)
|
||||
}
|
||||
|
||||
// InstanceForHost is InstanceFromHost with the host supplied explicitly.
|
||||
func InstanceForHost(host string) (*models.Instance, bool) {
|
||||
slug := hostSlug(host)
|
||||
if slug == "" {
|
||||
return nil, false
|
||||
}
|
||||
instanceCacheMu.Lock()
|
||||
if e, ok := instanceCache[slug]; ok && time.Since(e.at) < instanceCacheTTL {
|
||||
instanceCacheMu.Unlock()
|
||||
return e.instance, e.instance != nil
|
||||
if inst, hit := cachedInstanceFor(slug); hit {
|
||||
return inst, inst != nil
|
||||
}
|
||||
instanceCacheMu.Unlock()
|
||||
|
||||
inst, err := services.GetInstanceBySlug(slug)
|
||||
if err != nil || inst == nil {
|
||||
|
||||
// Negative entries are cached too. Without them an unknown but
|
||||
// well-formed host costs a Mongo query per anonymous request, which
|
||||
// the public status page exposes to the open internet — and the
|
||||
// round trip is itself a timing oracle separating "no such instance"
|
||||
// from "instance exists, page does not".
|
||||
storeInstance(slug, nil)
|
||||
return nil, false
|
||||
}
|
||||
instanceCacheMu.Lock()
|
||||
instanceCache[slug] = cachedInstance{instance: inst, at: time.Now()}
|
||||
instanceCacheMu.Unlock()
|
||||
storeInstance(slug, inst)
|
||||
return inst, true
|
||||
}
|
||||
|
||||
// SoleInstance resolves the one instance of a deployment that has exactly one.
|
||||
// It is how a self-hosted install serves a host that names no slug at all —
|
||||
// vantage.acme.com, status.acme.com, or a bare address. It reuses the same
|
||||
// count-then-read that bootstrap uses, and refuses rather than guessing when
|
||||
// more than one instance exists.
|
||||
func SoleInstance() (*models.Instance, bool) {
|
||||
if inst, hit := cachedInstanceFor(soleInstanceCacheKey); hit {
|
||||
return inst, inst != nil
|
||||
}
|
||||
n, err := services.CountInstances()
|
||||
if err != nil || n != 1 {
|
||||
storeInstance(soleInstanceCacheKey, nil)
|
||||
return nil, false
|
||||
}
|
||||
inst, err := services.FirstInstance()
|
||||
if err != nil || inst == nil {
|
||||
storeInstance(soleInstanceCacheKey, nil)
|
||||
return nil, false
|
||||
}
|
||||
storeInstance(soleInstanceCacheKey, inst)
|
||||
return inst, true
|
||||
}
|
||||
|
||||
func cachedInstanceFor(key string) (*models.Instance, bool) {
|
||||
instanceCacheMu.Lock()
|
||||
defer instanceCacheMu.Unlock()
|
||||
if e, ok := instanceCache[key]; ok && time.Since(e.at) < instanceCacheTTL {
|
||||
return e.instance, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func storeInstance(key string, inst *models.Instance) {
|
||||
instanceCacheMu.Lock()
|
||||
instanceCache[key] = cachedInstance{instance: inst, at: time.Now()}
|
||||
instanceCacheMu.Unlock()
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ const (
|
||||
TypeTCP = "tcp"
|
||||
TypeICMP = "icmp"
|
||||
TypeTLS = "tls"
|
||||
|
||||
// UserAgent identifies Vantage monitor traffic so a WAF rule can single it
|
||||
// out. Match on a prefix, not equality: the version moves.
|
||||
UserAgent = "Vantage-Monitor/1.0 (+https://vantage.hostxtra.co.uk)"
|
||||
)
|
||||
|
||||
type Spec struct {
|
||||
@@ -80,6 +84,7 @@ func runHTTP(ctx context.Context, s Spec) Result {
|
||||
if err != nil {
|
||||
return Result{Message: err.Error()}
|
||||
}
|
||||
req.Header.Set("User-Agent", UserAgent)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: err.Error()}
|
||||
|
||||
@@ -14,6 +14,28 @@ const (
|
||||
ChannelTelegram = "telegram"
|
||||
)
|
||||
|
||||
// RedactedSecret is what a channel's secret config values read as over the API.
|
||||
// It is a sentinel and not merely a mask: a client may write it straight back,
|
||||
// and the value it stood for is preserved. See NotificationChannel.Redacted.
|
||||
const RedactedSecret = "••••••••"
|
||||
|
||||
// channelSecretKeys names, per channel type, the config entries that are
|
||||
// credentials rather than settings. A Slack or Discord webhook URL is on this
|
||||
// list because possession of the URL *is* the authorisation to post to that
|
||||
// channel — there is nothing else to steal.
|
||||
var channelSecretKeys = map[string][]string{
|
||||
ChannelWebhook: {"url"},
|
||||
ChannelSlack: {"url"},
|
||||
ChannelDiscord: {"url"},
|
||||
ChannelTelegram: {"token"},
|
||||
ChannelSMTP: {"password"},
|
||||
}
|
||||
|
||||
// ChannelSecretKeys reports which config keys of a channel type are secret.
|
||||
func ChannelSecretKeys(channelType string) []string {
|
||||
return channelSecretKeys[channelType]
|
||||
}
|
||||
|
||||
type NotificationChannel struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
@@ -24,3 +46,25 @@ type NotificationChannel struct {
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// Redacted returns a copy with every secret config value replaced by
|
||||
// RedactedSecret, for handing to a client. Nothing internal uses it: the
|
||||
// dispatchers read the stored document through GetChannel/GetChannels, so the
|
||||
// redaction is a property of the API boundary and cannot break delivery.
|
||||
//
|
||||
// A set-but-secret key keeps its key, so a caller can still tell configured
|
||||
// from absent; an empty value is left empty rather than being dressed up as a
|
||||
// credential that is not there.
|
||||
func (c NotificationChannel) Redacted() NotificationChannel {
|
||||
out := c
|
||||
out.Config = make(map[string]string, len(c.Config))
|
||||
for k, v := range c.Config {
|
||||
out.Config[k] = v
|
||||
}
|
||||
for _, k := range ChannelSecretKeys(c.Type) {
|
||||
if out.Config[k] != "" {
|
||||
out.Config[k] = RedactedSecret
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -43,10 +43,14 @@ type MonitorState struct {
|
||||
}
|
||||
|
||||
type Monitor struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
MonitorID string `bson:"monitor_id" json:"monitor_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
MonitorID string `bson:"monitor_id" json:"monitor_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
// Group is a display-only label. It buckets rows on the monitors page and
|
||||
// has no effect on scheduling, alerting or scope; an empty group means the
|
||||
// monitor is listed on its own under "Ungrouped".
|
||||
Group string `bson:"group,omitempty" json:"group,omitempty"`
|
||||
Type string `bson:"type" json:"type"`
|
||||
Target MonitorTarget `bson:"target" json:"target"`
|
||||
IntervalSec int `bson:"interval_sec" json:"interval_sec"`
|
||||
@@ -67,6 +71,21 @@ type Incident struct {
|
||||
Cause string `bson:"cause,omitempty" json:"cause,omitempty"`
|
||||
}
|
||||
|
||||
// MonitorSample is one check result, kept only long enough to draw the
|
||||
// sub-hour views of the history chart. Rollup remains the durable record: a
|
||||
// sample expires by TTL, a rollup does not.
|
||||
//
|
||||
// It carries no message. The failure text is on the incident, and a document
|
||||
// per check is the one place in this schema where a few bytes multiply by the
|
||||
// check rate.
|
||||
type MonitorSample struct {
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
MonitorID string `bson:"monitor_id" json:"monitor_id"`
|
||||
At time.Time `bson:"at" json:"at"`
|
||||
Up bool `bson:"up" json:"up"`
|
||||
LatencyMs int `bson:"latency_ms" json:"latency_ms"`
|
||||
}
|
||||
|
||||
type Rollup struct {
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
MonitorID string `bson:"monitor_id" json:"monitor_id"`
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// A status page entry kind. Incidents and maintenance share one document
|
||||
// because they share a timeline, an impact and a set of affected components.
|
||||
const (
|
||||
StatusKindIncident = "incident"
|
||||
StatusKindMaintenance = "maintenance"
|
||||
)
|
||||
|
||||
const (
|
||||
ImpactNone = "none"
|
||||
ImpactMinor = "minor"
|
||||
ImpactMajor = "major"
|
||||
ImpactCritical = "critical"
|
||||
)
|
||||
|
||||
// Incident statuses.
|
||||
const (
|
||||
IncidentInvestigating = "investigating"
|
||||
IncidentIdentified = "identified"
|
||||
IncidentMonitoring = "monitoring"
|
||||
IncidentResolved = "resolved"
|
||||
)
|
||||
|
||||
// Maintenance statuses.
|
||||
const (
|
||||
MaintenanceScheduled = "scheduled"
|
||||
MaintenanceInProgress = "in_progress"
|
||||
MaintenanceCompleted = "completed"
|
||||
)
|
||||
|
||||
// StatusPageEntry names one monitor on one page.
|
||||
//
|
||||
// DisplayName overrides the monitor's own name for this page only. A monitor's
|
||||
// internal name is frequently not a name anybody wants published, and the same
|
||||
// monitor may need different words on a customer page and a partner page.
|
||||
type StatusPageEntry struct {
|
||||
MonitorID string `bson:"monitor_id" json:"monitor_id"`
|
||||
DisplayName string `bson:"display_name,omitempty" json:"display_name,omitempty"`
|
||||
}
|
||||
|
||||
// StatusPageSection is page-local and unrelated to Monitor.Group, which labels
|
||||
// rows on the authenticated monitors list.
|
||||
type StatusPageSection struct {
|
||||
Name string `bson:"name" json:"name"`
|
||||
Entries []StatusPageEntry `bson:"entries" json:"entries"`
|
||||
}
|
||||
|
||||
// StatusPageBanner is three fields on the page rather than a collection,
|
||||
// because it is one string with no lifecycle.
|
||||
type StatusPageBanner struct {
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
Level string `bson:"level,omitempty" json:"level,omitempty"`
|
||||
Text string `bson:"text,omitempty" json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// StatusPage is read whole, always, which is why its structure is embedded
|
||||
// rather than joined: one page is one read is one cache fill.
|
||||
type StatusPage struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
PageID string `bson:"page_id" json:"page_id"`
|
||||
Title string `bson:"title" json:"title"`
|
||||
Description string `bson:"description,omitempty" json:"description,omitempty"`
|
||||
LogoURL string `bson:"logo_url,omitempty" json:"logo_url,omitempty"`
|
||||
Published bool `bson:"published" json:"published"`
|
||||
Banner StatusPageBanner `bson:"banner" json:"banner"`
|
||||
Sections []StatusPageSection `bson:"sections" json:"sections"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type StatusIncidentUpdate struct {
|
||||
At time.Time `bson:"at" json:"at"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
Body string `bson:"body" json:"body"`
|
||||
Author string `bson:"author" json:"author"`
|
||||
}
|
||||
|
||||
// StatusIncident is operator-authored. Monitor-detected outages stay in the
|
||||
// incidents collection and are derived at assembly time; copying them here
|
||||
// would be a second writer for the same fact.
|
||||
//
|
||||
// PageIDs is explicit rather than derived from AffectedMonitors: deriving it
|
||||
// would mean adding a monitor to a page retroactively republishes old
|
||||
// incidents to a new audience.
|
||||
type StatusIncident struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
IncidentID string `bson:"incident_id" json:"incident_id"`
|
||||
PageIDs []string `bson:"page_ids" json:"page_ids"`
|
||||
Kind string `bson:"kind" json:"kind"`
|
||||
Title string `bson:"title" json:"title"`
|
||||
Impact string `bson:"impact" json:"impact"`
|
||||
AffectedMonitors []string `bson:"affected_monitors,omitempty" json:"affected_monitors,omitempty"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
ScheduledStart *time.Time `bson:"scheduled_start,omitempty" json:"scheduled_start,omitempty"`
|
||||
ScheduledEnd *time.Time `bson:"scheduled_end,omitempty" json:"scheduled_end,omitempty"`
|
||||
Updates []StatusIncidentUpdate `bson:"updates" json:"updates"`
|
||||
StartedAt time.Time `bson:"started_at" json:"started_at"`
|
||||
ResolvedAt *time.Time `bson:"resolved_at,omitempty" json:"resolved_at,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
|
||||
}
|
||||
@@ -90,12 +90,57 @@ func CreateChannel(instanceID string, ch *models.NotificationChannel) (*models.N
|
||||
}
|
||||
|
||||
func UpdateChannel(instanceID, channelID string, upd bson.M) error {
|
||||
if cfg, ok := upd["config"].(map[string]string); ok {
|
||||
merged, err := mergeChannelSecrets(instanceID, channelID, upd, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
upd["config"] = merged
|
||||
}
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
_, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID, "instance_id": instanceID}, bson.M{"$set": upd})
|
||||
return err
|
||||
}
|
||||
|
||||
// mergeChannelSecrets resolves models.RedactedSecret back to what it stood for.
|
||||
//
|
||||
// The API hands out a sentinel rather than the credential, and the UI's edit
|
||||
// form round-trips whatever it was given, so an ordinary "rename this channel"
|
||||
// save arrives carrying the sentinel in place of the password. Writing it
|
||||
// through would replace the credential with eight bullet characters and break
|
||||
// delivery on the next alert. A value that is not the sentinel is written
|
||||
// verbatim — including the empty string, which is how a credential is cleared.
|
||||
func mergeChannelSecrets(instanceID, channelID string, upd bson.M, cfg map[string]string) (map[string]string, error) {
|
||||
stored, err := GetChannel(instanceID, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if stored == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
// The secret keys are the ones of the type being saved, which the same
|
||||
// request may be changing.
|
||||
channelType := stored.Type
|
||||
if t, ok := upd["type"].(string); ok && t != "" {
|
||||
channelType = t
|
||||
}
|
||||
out := make(map[string]string, len(cfg))
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
for _, k := range models.ChannelSecretKeys(channelType) {
|
||||
if out[k] == models.RedactedSecret {
|
||||
if prev, ok := stored.Config[k]; ok {
|
||||
out[k] = prev
|
||||
} else {
|
||||
delete(out, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func DeleteChannel(instanceID, channelID string) error {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
|
||||
@@ -36,6 +36,7 @@ var ScopedCollections = []string{
|
||||
"monitors",
|
||||
"incidents",
|
||||
"monitor_rollups",
|
||||
"monitor_samples",
|
||||
"notification_channels",
|
||||
"console_sessions",
|
||||
"audit_logs",
|
||||
@@ -45,6 +46,8 @@ var ScopedCollections = []string{
|
||||
"vuln_alert_rules",
|
||||
"api_tokens",
|
||||
"server_workloads",
|
||||
"status_pages",
|
||||
"status_incidents",
|
||||
}
|
||||
|
||||
// collectionRenames maps the two collections whose names change. Ordered so the
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/checker"
|
||||
@@ -21,6 +22,22 @@ func monCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 5*time.Second)
|
||||
}
|
||||
|
||||
// MaxMonitorGroupLen bounds the display-only group label. It is a heading on
|
||||
// the monitors page, not an identifier, so the cap is about the layout rather
|
||||
// than storage.
|
||||
const MaxMonitorGroupLen = 48
|
||||
|
||||
// normaliseGroup collapses the ways two people write the same group. Grouping
|
||||
// is by exact string, so " Production " and "Production" must not become two
|
||||
// headings.
|
||||
func normaliseGroup(g string) (string, error) {
|
||||
g = strings.Join(strings.Fields(g), " ")
|
||||
if len([]rune(g)) > MaxMonitorGroupLen {
|
||||
return "", fmt.Errorf("group must be %d characters or fewer", MaxMonitorGroupLen)
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func SpecFor(m *models.Monitor) checker.Spec {
|
||||
return checker.Spec{
|
||||
Type: m.Type,
|
||||
@@ -126,6 +143,11 @@ func CreateMonitor(instanceID string, m *models.Monitor) (*models.Monitor, error
|
||||
if err := validateRunner(instanceID, m.Runner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
group, err := normaliseGroup(m.Group)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Group = group
|
||||
m.InstanceID = instanceID
|
||||
m.MonitorID = uuid.NewString()
|
||||
m.CreatedAt = time.Now()
|
||||
@@ -158,6 +180,17 @@ func UpdateMonitor(instanceID, monitorID string, upd bson.M) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if raw, present := upd["group"]; present {
|
||||
g, ok := raw.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("group must be a string")
|
||||
}
|
||||
group, err := normaliseGroup(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
upd["group"] = group
|
||||
}
|
||||
if raw, present := upd["runner"]; present {
|
||||
runner, ok := raw.(string)
|
||||
if !ok {
|
||||
@@ -188,6 +221,7 @@ func DeleteMonitor(instanceID, monitorID string) error {
|
||||
}
|
||||
db.Col("incidents").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID})
|
||||
db.Col("monitor_rollups").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID})
|
||||
db.Col("monitor_samples").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -225,6 +259,31 @@ func UptimeRollups(instanceID, monitorID string, since time.Time) ([]models.Roll
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MaxMonitorSamples bounds one range read. At the 10s floor, 48h is 17,280
|
||||
// checks; the chart buckets them anyway, so a cap costs nothing visible and
|
||||
// stops one monitor pulling a megabyte of JSON per poll.
|
||||
const MaxMonitorSamples = 6000
|
||||
|
||||
// MonitorSamples returns individual check results since a point in time,
|
||||
// oldest first. Samples older than MonitorSampleTTL have expired, so an early
|
||||
// `since` silently returns a shorter window rather than an error — the caller
|
||||
// draws the gap.
|
||||
func MonitorSamples(instanceID, monitorID string, since time.Time) ([]models.MonitorSample, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("monitor_samples").Find(ctx,
|
||||
bson.M{"monitor_id": monitorID, "instance_id": instanceID, "at": bson.M{"$gte": since}},
|
||||
options.Find().SetSort(bson.M{"at": 1}).SetLimit(MaxMonitorSamples))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []models.MonitorSample
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func IngestResult(instanceID, runner, monitorID string, res checker.Result) error {
|
||||
if instanceID == "" {
|
||||
return errors.New("instance id required")
|
||||
@@ -295,6 +354,17 @@ func ingestResult(instanceID, runner, monitorID string, res checker.Result) erro
|
||||
up = 1
|
||||
}
|
||||
|
||||
/* The sample is the same result at full resolution, expiring by TTL. It is
|
||||
written next to the rollup rather than instead of it: the rollup is what
|
||||
survives, the sample is what the sub-hour views read. */
|
||||
db.Col("monitor_samples").InsertOne(ctx, models.MonitorSample{
|
||||
InstanceID: m.InstanceID,
|
||||
MonitorID: monitorID,
|
||||
At: now,
|
||||
Up: res.Up,
|
||||
LatencyMs: res.LatencyMs,
|
||||
})
|
||||
|
||||
db.Col("monitor_rollups").UpdateOne(ctx,
|
||||
bson.M{"monitor_id": monitorID, "period_start": bucket},
|
||||
bson.M{
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// MonitorSampleTTL is how long an individual check result is kept.
|
||||
//
|
||||
// It matches the longest range the chart draws from samples rather than the
|
||||
// longest range it draws at all: 24h and 48h come from the hourly rollups,
|
||||
// which are permanent. Keeping samples past the window that reads them would
|
||||
// only grow the collection.
|
||||
const MonitorSampleTTL = 48 * time.Hour
|
||||
|
||||
// EnsureMonitorSampleIndexes declares the sample range index and its TTL.
|
||||
//
|
||||
// Warn rather than fatal, like the other history indexes — but note the TTL is
|
||||
// not an optimisation: without it nothing ever removes a sample, and the
|
||||
// collection grows at the fleet's total check rate forever. A boot that logs
|
||||
// this warning needs following up.
|
||||
func EnsureMonitorSampleIndexes() error {
|
||||
ctx := context.Background()
|
||||
|
||||
idx := []mongo.IndexModel{
|
||||
// Every read is a range scan over this key.
|
||||
{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "monitor_id", Value: 1}, {Key: "at", Value: 1}}},
|
||||
// Expiry is Mongo's job: a sweeper would be another leader-scoped loop
|
||||
// doing what the server already does for free.
|
||||
{Keys: bson.D{{Key: "at", Value: 1}}, Options: options.Index().SetExpireAfterSeconds(int32(MonitorSampleTTL.Seconds()))},
|
||||
}
|
||||
if _, err := db.Col("monitor_samples").Indexes().CreateMany(ctx, idx); err != nil {
|
||||
log.Printf("warning: monitor_samples indexes: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -26,6 +26,7 @@ var ScopeResources = []string{
|
||||
"vulns",
|
||||
"workloads",
|
||||
"settings",
|
||||
"status",
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
var ErrIncidentNotFound = errors.New("status incident not found")
|
||||
|
||||
var incidentStatuses = map[string]bool{
|
||||
models.IncidentInvestigating: true,
|
||||
models.IncidentIdentified: true,
|
||||
models.IncidentMonitoring: true,
|
||||
models.IncidentResolved: true,
|
||||
}
|
||||
|
||||
var maintenanceStatuses = map[string]bool{
|
||||
models.MaintenanceScheduled: true,
|
||||
models.MaintenanceInProgress: true,
|
||||
models.MaintenanceCompleted: true,
|
||||
}
|
||||
|
||||
var impacts = map[string]bool{
|
||||
models.ImpactNone: true, models.ImpactMinor: true,
|
||||
models.ImpactMajor: true, models.ImpactCritical: true,
|
||||
}
|
||||
|
||||
func validateIncident(inc *models.StatusIncident) error {
|
||||
if inc.Title == "" {
|
||||
return fmt.Errorf("%w: title is required", ErrPageInvalid)
|
||||
}
|
||||
if len(inc.PageIDs) == 0 {
|
||||
return fmt.Errorf("%w: at least one page is required", ErrPageInvalid)
|
||||
}
|
||||
switch inc.Kind {
|
||||
case models.StatusKindIncident:
|
||||
if !incidentStatuses[inc.Status] {
|
||||
return fmt.Errorf("%w: invalid incident status %q", ErrPageInvalid, inc.Status)
|
||||
}
|
||||
case models.StatusKindMaintenance:
|
||||
if !maintenanceStatuses[inc.Status] {
|
||||
return fmt.Errorf("%w: invalid maintenance status %q", ErrPageInvalid, inc.Status)
|
||||
}
|
||||
if inc.ScheduledStart == nil || inc.ScheduledEnd == nil {
|
||||
return fmt.Errorf("%w: maintenance needs a scheduled start and end", ErrPageInvalid)
|
||||
}
|
||||
if !inc.ScheduledEnd.After(*inc.ScheduledStart) {
|
||||
return fmt.Errorf("%w: maintenance must end after it starts", ErrPageInvalid)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%w: invalid kind %q", ErrPageInvalid, inc.Kind)
|
||||
}
|
||||
if inc.Impact == "" {
|
||||
inc.Impact = models.ImpactNone
|
||||
}
|
||||
if !impacts[inc.Impact] {
|
||||
return fmt.Errorf("%w: invalid impact %q", ErrPageInvalid, inc.Impact)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateStatusIncident(instanceID string, inc *models.StatusIncident) (*models.StatusIncident, error) {
|
||||
if err := validateIncident(inc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inc.ID = bson.ObjectID{}
|
||||
inc.InstanceID = instanceID
|
||||
inc.IncidentID = uuid.NewString()
|
||||
now := time.Now()
|
||||
inc.CreatedAt = now
|
||||
inc.UpdatedAt = now
|
||||
if inc.StartedAt.IsZero() {
|
||||
if inc.Kind == models.StatusKindMaintenance && inc.ScheduledStart != nil {
|
||||
inc.StartedAt = *inc.ScheduledStart
|
||||
} else {
|
||||
inc.StartedAt = now
|
||||
}
|
||||
}
|
||||
if inc.Updates == nil {
|
||||
inc.Updates = []models.StatusIncidentUpdate{}
|
||||
}
|
||||
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
if _, err := db.Col("status_incidents").InsertOne(ctx, inc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
invalidatePages(instanceID, inc.PageIDs)
|
||||
return inc, nil
|
||||
}
|
||||
|
||||
func invalidatePages(instanceID string, pageIDs []string) {
|
||||
for _, p := range pageIDs {
|
||||
InvalidateStatusCache(instanceID, p)
|
||||
}
|
||||
}
|
||||
|
||||
func getIncident(instanceID, incidentID string) (*models.StatusIncident, error) {
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
var inc models.StatusIncident
|
||||
err := db.Col("status_incidents").
|
||||
FindOne(ctx, bson.M{"instance_id": instanceID, "incident_id": incidentID}).
|
||||
Decode(&inc)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrIncidentNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &inc, nil
|
||||
}
|
||||
|
||||
func ListStatusIncidents(instanceID, pageID string) ([]models.StatusIncident, error) {
|
||||
filter := bson.M{"instance_id": instanceID}
|
||||
if pageID != "" {
|
||||
filter["page_ids"] = pageID
|
||||
}
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("status_incidents").Find(ctx, filter,
|
||||
options.Find().SetSort(bson.M{"started_at": -1}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []models.StatusIncident{}
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListStatusIncidentsForPage is the public read's query: one page, bounded by
|
||||
// the history window, so a five-year-old instance does not assemble five years
|
||||
// of incidents on every cache miss.
|
||||
func ListStatusIncidentsForPage(instanceID, pageID string, since time.Time) ([]models.StatusIncident, error) {
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("status_incidents").Find(ctx, bson.M{
|
||||
"instance_id": instanceID,
|
||||
"page_ids": pageID,
|
||||
"$or": []bson.M{
|
||||
{"started_at": bson.M{"$gte": since}},
|
||||
{"resolved_at": nil},
|
||||
{"status": models.MaintenanceScheduled},
|
||||
},
|
||||
}, options.Find().SetSort(bson.M{"started_at": -1}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []models.StatusIncident{}
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func UpdateStatusIncident(instanceID, incidentID string, inc *models.StatusIncident) (*models.StatusIncident, error) {
|
||||
existing, err := getIncident(instanceID, incidentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inc.Kind = existing.Kind // kind is fixed at creation
|
||||
if err := validateIncident(inc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
set := bson.M{
|
||||
"page_ids": inc.PageIDs,
|
||||
"title": inc.Title,
|
||||
"impact": inc.Impact,
|
||||
"affected_monitors": inc.AffectedMonitors,
|
||||
"status": inc.Status,
|
||||
"scheduled_start": inc.ScheduledStart,
|
||||
"scheduled_end": inc.ScheduledEnd,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if inc.Status == models.IncidentResolved || inc.Status == models.MaintenanceCompleted {
|
||||
if existing.ResolvedAt == nil {
|
||||
now := time.Now()
|
||||
set["resolved_at"] = now
|
||||
}
|
||||
} else {
|
||||
// Reopening clears it, so a mistakenly resolved incident does not keep
|
||||
// a resolution time it no longer has.
|
||||
set["resolved_at"] = nil
|
||||
}
|
||||
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
if _, err := db.Col("status_incidents").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "incident_id": incidentID},
|
||||
bson.M{"$set": set}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Both old and new page sets, or a page the incident was just removed from
|
||||
// keeps showing it for up to 30 seconds.
|
||||
invalidatePages(instanceID, existing.PageIDs)
|
||||
invalidatePages(instanceID, inc.PageIDs)
|
||||
return getIncident(instanceID, incidentID)
|
||||
}
|
||||
|
||||
func AppendStatusIncidentUpdate(instanceID, incidentID, status, body, author string) (*models.StatusIncident, error) {
|
||||
existing, err := getIncident(instanceID, incidentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body == "" {
|
||||
return nil, fmt.Errorf("%w: update body is required", ErrPageInvalid)
|
||||
}
|
||||
valid := incidentStatuses
|
||||
if existing.Kind == models.StatusKindMaintenance {
|
||||
valid = maintenanceStatuses
|
||||
}
|
||||
if !valid[status] {
|
||||
return nil, fmt.Errorf("%w: invalid status %q for a %s", ErrPageInvalid, status, existing.Kind)
|
||||
}
|
||||
|
||||
upd := models.StatusIncidentUpdate{At: time.Now(), Status: status, Body: body, Author: author}
|
||||
set := bson.M{"status": status, "updated_at": upd.At}
|
||||
if status == models.IncidentResolved || status == models.MaintenanceCompleted {
|
||||
set["resolved_at"] = upd.At
|
||||
} else {
|
||||
// Reopening via an appended update must clear a previously-set
|
||||
// resolved_at the same way UpdateStatusIncident does — otherwise a
|
||||
// resolved incident reopened to "monitoring" keeps a stale resolved_at
|
||||
// and silently drops off ListStatusIncidentsForPage once started_at
|
||||
// ages past the since cutoff, because none of its $or clauses match.
|
||||
set["resolved_at"] = nil
|
||||
}
|
||||
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
if _, err := db.Col("status_incidents").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "incident_id": incidentID},
|
||||
bson.M{"$push": bson.M{"updates": upd}, "$set": set}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
invalidatePages(instanceID, existing.PageIDs)
|
||||
return getIncident(instanceID, incidentID)
|
||||
}
|
||||
|
||||
func DeleteStatusIncident(instanceID, incidentID string) error {
|
||||
existing, err := getIncident(instanceID, incidentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
if _, err := db.Col("status_incidents").DeleteOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "incident_id": incidentID}); err != nil {
|
||||
return err
|
||||
}
|
||||
invalidatePages(instanceID, existing.PageIDs)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// ErrInvalidPageID is returned for any page id that would not be safe or
|
||||
// pleasant in a URL handed to a customer.
|
||||
var ErrInvalidPageID = errors.New("page id must be 3-40 characters of a-z, 0-9 and -, starting and ending alphanumeric")
|
||||
|
||||
// The slug is operator-chosen rather than random because it is printed on
|
||||
// support pages and typed by people. First and last characters are
|
||||
// alphanumeric so a page id never reads as a flag or a trailing separator.
|
||||
var pageIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$`)
|
||||
|
||||
func ValidatePageID(id string) error {
|
||||
if !pageIDRe.MatchString(id) {
|
||||
return ErrInvalidPageID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func statusCacheKey(instanceID, pageID string) string {
|
||||
return "vantage:status:" + instanceID + ":" + pageID
|
||||
}
|
||||
|
||||
func spCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 5*time.Second)
|
||||
}
|
||||
|
||||
// EnsureStatusPageIndexes follows EnsureWorkflowIndexes rather than
|
||||
// EnsureAuthIndexes: the unique page_id index is a correctness property, but a
|
||||
// missing secondary index on a small collection degrades to a scan, which is no
|
||||
// reason to refuse to serve the fleet. main.go warns rather than exiting.
|
||||
//
|
||||
// All three are attempted and the failures joined, rather than returning on
|
||||
// the first. The three are independent, and two of them are uniqueness
|
||||
// constraints — bailing out on the status_pages index meant a transient
|
||||
// failure there silently left status_incidents with no unique
|
||||
// (instance_id, incident_id) index at all.
|
||||
func EnsureStatusPageIndexes() error {
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
|
||||
var errs []error
|
||||
|
||||
if _, err := db.Col("status_pages").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "page_id", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
}); err != nil {
|
||||
errs = append(errs, fmt.Errorf("status_pages (instance_id, page_id): %w", err))
|
||||
}
|
||||
|
||||
if _, err := db.Col("status_incidents").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "incident_id", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
}); err != nil {
|
||||
errs = append(errs, fmt.Errorf("status_incidents (instance_id, incident_id): %w", err))
|
||||
}
|
||||
|
||||
// The public read filters by page and orders by recency, and it is the
|
||||
// only query on this collection that runs on every visit.
|
||||
if _, err := db.Col("status_incidents").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "page_ids", Value: 1}, {Key: "started_at", Value: -1}},
|
||||
}); err != nil {
|
||||
errs = append(errs, fmt.Errorf("status_incidents (instance_id, page_ids, started_at): %w", err))
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
var (
|
||||
ErrPageNotFound = errors.New("status page not found")
|
||||
ErrPageIDTaken = errors.New("that page id is already in use")
|
||||
|
||||
// ErrPageInvalid is the sentinel for validation failures on a page or
|
||||
// incident body — anything the caller can fix by sending a different
|
||||
// request. statusPageError maps it to 400; wrap it rather than returning a
|
||||
// bare error, or a bad request answers 500.
|
||||
ErrPageInvalid = errors.New("status page request invalid")
|
||||
)
|
||||
|
||||
// statusRedis is set by main.go at boot. It is nil in any process that has not
|
||||
// set it, and every use below treats nil as "no cache" rather than an error.
|
||||
var statusRedis *redis.Client
|
||||
|
||||
func SetStatusRedis(c *redis.Client) { statusRedis = c }
|
||||
|
||||
// InvalidateStatusCache drops the assembled snapshot so an operator posting an
|
||||
// incident update sees it immediately rather than wondering for half a minute
|
||||
// whether it saved. Best effort: a stale entry expires in 30s anyway, and a
|
||||
// Redis error here must not fail the write that already succeeded.
|
||||
func InvalidateStatusCache(instanceID, pageID string) {
|
||||
if statusRedis == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
if err := statusRedis.Del(ctx, statusCacheKey(instanceID, pageID)).Err(); err != nil {
|
||||
log.Printf("status cache invalidate %s/%s: %v", instanceID, pageID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func ListStatusPages(instanceID string) ([]models.StatusPage, error) {
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("status_pages").Find(ctx,
|
||||
bson.M{"instance_id": instanceID},
|
||||
options.Find().SetSort(bson.M{"created_at": 1}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pages := []models.StatusPage{}
|
||||
if err := cur.All(ctx, &pages); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
func GetStatusPage(instanceID, pageID string) (*models.StatusPage, error) {
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
var p models.StatusPage
|
||||
err := db.Col("status_pages").
|
||||
FindOne(ctx, bson.M{"instance_id": instanceID, "page_id": pageID}).
|
||||
Decode(&p)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrPageNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func CreateStatusPage(instanceID string, p *models.StatusPage) (*models.StatusPage, error) {
|
||||
if err := ValidatePageID(p.PageID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Title == "" {
|
||||
return nil, fmt.Errorf("%w: title is required", ErrPageInvalid)
|
||||
}
|
||||
p.ID = bson.ObjectID{}
|
||||
p.InstanceID = instanceID
|
||||
p.CreatedAt = time.Now()
|
||||
p.UpdatedAt = p.CreatedAt
|
||||
if p.Sections == nil {
|
||||
p.Sections = []models.StatusPageSection{}
|
||||
}
|
||||
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
res, err := db.Col("status_pages").InsertOne(ctx, p)
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
// The unique index is what settles a race between two people reaching
|
||||
// for one page id; a pre-check alone would not.
|
||||
return nil, ErrPageIDTaken
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if oid, ok := res.InsertedID.(bson.ObjectID); ok {
|
||||
p.ID = oid
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// UpdateStatusPage replaces the whole page document except its identity and
|
||||
// creation time. Last-write-wins over one small document beats merge semantics
|
||||
// between two people editing one page, the same call PUT /servers/:id/tags
|
||||
// already makes.
|
||||
//
|
||||
// The page id itself is immutable: it is a URL that has been handed out.
|
||||
func UpdateStatusPage(instanceID, pageID string, p *models.StatusPage) (*models.StatusPage, error) {
|
||||
if p.Title == "" {
|
||||
return nil, fmt.Errorf("%w: title is required", ErrPageInvalid)
|
||||
}
|
||||
if p.Sections == nil {
|
||||
p.Sections = []models.StatusPageSection{}
|
||||
}
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
res, err := db.Col("status_pages").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "page_id": pageID},
|
||||
bson.M{"$set": bson.M{
|
||||
"title": p.Title,
|
||||
"description": p.Description,
|
||||
"logo_url": p.LogoURL,
|
||||
"published": p.Published,
|
||||
"banner": p.Banner,
|
||||
"sections": p.Sections,
|
||||
"updated_at": time.Now(),
|
||||
}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.MatchedCount == 0 {
|
||||
return nil, ErrPageNotFound
|
||||
}
|
||||
InvalidateStatusCache(instanceID, pageID)
|
||||
return GetStatusPage(instanceID, pageID)
|
||||
}
|
||||
|
||||
func DeleteStatusPage(instanceID, pageID string) error {
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
res, err := db.Col("status_pages").DeleteOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "page_id": pageID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.DeletedCount == 0 {
|
||||
return ErrPageNotFound
|
||||
}
|
||||
// Authored incidents keep their page_ids entry. A page deleted by mistake
|
||||
// and recreated with the same id gets its incident history back, and an id
|
||||
// that is never reused costs two bytes in an array.
|
||||
InvalidateStatusCache(instanceID, pageID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A new instance-scoped collection that is not in ScopedCollections leaves its
|
||||
// rows behind when the instance is deleted. This is the cheapest possible
|
||||
// guard against the omission.
|
||||
func TestStatusCollectionsAreScoped(t *testing.T) {
|
||||
want := []string{"status_pages", "status_incidents"}
|
||||
for _, name := range want {
|
||||
found := false
|
||||
for _, got := range ScopedCollections {
|
||||
if got == name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("ScopedCollections is missing %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePageID(t *testing.T) {
|
||||
valid := []string{"api", "prod-eu", "status2", "a1b", strings.Repeat("a", 40)}
|
||||
for _, s := range valid {
|
||||
if err := ValidatePageID(s); err != nil {
|
||||
t.Errorf("ValidatePageID(%q) = %v, want nil", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"", // empty
|
||||
"ab", // too short
|
||||
strings.Repeat("a", 41), // too long
|
||||
"-api", // leading hyphen
|
||||
"api-", // trailing hyphen
|
||||
"API", // uppercase
|
||||
"my page", // space
|
||||
"api_v2", // underscore
|
||||
"api/v2", // path separator
|
||||
"..", // dots
|
||||
}
|
||||
for _, s := range invalid {
|
||||
if err := ValidatePageID(s); err == nil {
|
||||
t.Errorf("ValidatePageID(%q) = nil, want error", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCacheKeyIsScopedByInstance(t *testing.T) {
|
||||
a := statusCacheKey("inst-a", "api")
|
||||
b := statusCacheKey("inst-b", "api")
|
||||
if a == b {
|
||||
t.Fatalf("two instances share a cache key: %q", a)
|
||||
}
|
||||
if a != "vantage:status:inst-a:api" {
|
||||
t.Fatalf("statusCacheKey = %q, want vantage:status:inst-a:api", a)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// HistoryDays is the width of the public history bar. It is also the window the
|
||||
// public uptime percentage is computed over.
|
||||
const HistoryDays = 90
|
||||
|
||||
// Day cell and component states. "maintenance" and "no_data" exist only here:
|
||||
// a monitor has no such states, and conflating no_data with down would report
|
||||
// a component as broken for every day before it was created.
|
||||
const (
|
||||
PublicUp = "up"
|
||||
PublicDown = "down"
|
||||
PublicMaintenance = "maintenance"
|
||||
PublicNoData = "no_data"
|
||||
PublicPending = "pending"
|
||||
PublicDegraded = "degraded"
|
||||
)
|
||||
|
||||
type PublicDay struct {
|
||||
Date string `json:"date"`
|
||||
State string `json:"state"`
|
||||
Uptime float64 `json:"uptime"`
|
||||
}
|
||||
|
||||
// PublicComponent is everything an anonymous caller learns about a monitor.
|
||||
//
|
||||
// Deliberately absent, and it must stay that way: the target URL, host and
|
||||
// port, the expected status and keyword, State.Message, State.CertExpiryAt,
|
||||
// latency, the runner, and the notification channel ids.
|
||||
type PublicComponent struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Uptime90d float64 `json:"uptime_90d"`
|
||||
Days []PublicDay `json:"days"`
|
||||
}
|
||||
|
||||
type PublicSection struct {
|
||||
Name string `json:"name"`
|
||||
Components []PublicComponent `json:"components"`
|
||||
}
|
||||
|
||||
type PublicIncidentUpdate struct {
|
||||
At time.Time `json:"at"`
|
||||
Status string `json:"status"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// PublicIncident covers both authored incidents and derived monitor outages.
|
||||
// A derived one carries no updates and no impact — and never a cause, which is
|
||||
// where internal hostnames live.
|
||||
type PublicIncident struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Impact string `json:"impact,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Affected []string `json:"affected,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||
ScheduledStart *time.Time `json:"scheduled_start,omitempty"`
|
||||
ScheduledEnd *time.Time `json:"scheduled_end,omitempty"`
|
||||
Updates []PublicIncidentUpdate `json:"updates,omitempty"`
|
||||
}
|
||||
|
||||
type PublicBanner struct {
|
||||
Level string `json:"level"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// StatusSnapshot is the entire public API surface of this feature.
|
||||
type StatusSnapshot struct {
|
||||
Available bool `json:"available"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
LogoURL string `json:"logo_url,omitempty"`
|
||||
Banner *PublicBanner `json:"banner,omitempty"`
|
||||
Overall string `json:"overall"`
|
||||
Sections []PublicSection `json:"sections"`
|
||||
ActiveIncidents []PublicIncident `json:"active_incidents"`
|
||||
UpcomingMaintenance []PublicIncident `json:"upcoming_maintenance"`
|
||||
History []PublicIncident `json:"history"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
}
|
||||
|
||||
// snapshotInput is everything assembleSnapshot needs, already read. Keeping the
|
||||
// assembly pure is what makes the redaction boundary testable without a
|
||||
// database.
|
||||
type snapshotInput struct {
|
||||
Page models.StatusPage
|
||||
Monitors map[string]models.Monitor
|
||||
Rollups map[string][]models.Rollup
|
||||
AutoIncidents []models.Incident
|
||||
Authored []models.StatusIncident
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
func assembleSnapshot(in snapshotInput) StatusSnapshot {
|
||||
snap := StatusSnapshot{
|
||||
Available: true,
|
||||
Title: in.Page.Title,
|
||||
Description: in.Page.Description,
|
||||
LogoURL: in.Page.LogoURL,
|
||||
Sections: []PublicSection{},
|
||||
ActiveIncidents: []PublicIncident{},
|
||||
UpcomingMaintenance: []PublicIncident{},
|
||||
History: []PublicIncident{},
|
||||
GeneratedAt: in.Now,
|
||||
}
|
||||
if in.Page.Banner.Enabled && in.Page.Banner.Text != "" {
|
||||
snap.Banner = &PublicBanner{Level: in.Page.Banner.Level, Text: in.Page.Banner.Text}
|
||||
}
|
||||
|
||||
authored := authoredForPage(in.Page.PageID, in.Authored)
|
||||
underMaintenance := maintenanceMonitors(authored, in.Now)
|
||||
|
||||
// names maps monitor id to the name this page publishes, so incidents can
|
||||
// name their affected components without reaching back into models.Monitor.
|
||||
names := map[string]string{}
|
||||
|
||||
for _, sec := range in.Page.Sections {
|
||||
out := PublicSection{Name: sec.Name, Components: []PublicComponent{}}
|
||||
for _, entry := range sec.Entries {
|
||||
mon, known := in.Monitors[entry.MonitorID]
|
||||
name := publicName(entry, mon.MonitorID)
|
||||
names[entry.MonitorID] = name
|
||||
|
||||
// Uptime is computed from the days as reported by rollups, before
|
||||
// any maintenance repaint — a no_data day must never be counted as
|
||||
// zero uptime just because it is later redrawn as "maintenance".
|
||||
days := buildDays(in.Rollups[entry.MonitorID], in.Now)
|
||||
comp := PublicComponent{
|
||||
Name: name,
|
||||
}
|
||||
comp.Uptime90d = uptimeFromDays(days)
|
||||
comp.Days = applyMaintenanceRepaint(days, underMaintenance[entry.MonitorID])
|
||||
comp.Status = componentStatus(mon, known, underMaintenance[entry.MonitorID], in.Now)
|
||||
out.Components = append(out.Components, comp)
|
||||
}
|
||||
snap.Sections = append(snap.Sections, out)
|
||||
}
|
||||
|
||||
for _, inc := range authored {
|
||||
p := publicFromAuthored(inc, names)
|
||||
switch {
|
||||
case inc.Kind == models.StatusKindMaintenance && inc.Status == models.MaintenanceScheduled:
|
||||
snap.UpcomingMaintenance = append(snap.UpcomingMaintenance, p)
|
||||
case isOpen(inc):
|
||||
snap.ActiveIncidents = append(snap.ActiveIncidents, p)
|
||||
default:
|
||||
snap.History = append(snap.History, p)
|
||||
}
|
||||
}
|
||||
|
||||
for _, inc := range in.AutoIncidents {
|
||||
name, onPage := names[inc.MonitorID]
|
||||
if !onPage {
|
||||
continue
|
||||
}
|
||||
if inc.StartedAt.Before(in.Now.AddDate(0, 0, -HistoryDays)) {
|
||||
continue
|
||||
}
|
||||
derived := PublicIncident{
|
||||
ID: inc.IncidentID,
|
||||
Kind: models.StatusKindIncident,
|
||||
Title: name + " unavailable",
|
||||
Status: autoStatus(inc),
|
||||
Affected: []string{name},
|
||||
StartedAt: inc.StartedAt,
|
||||
ResolvedAt: inc.ResolvedAt,
|
||||
}
|
||||
// An outage that has not recovered is happening now. Filing it under
|
||||
// History while the component pill reads Down and Overall reads down
|
||||
// told the reader the disruption was over.
|
||||
if inc.ResolvedAt == nil {
|
||||
snap.ActiveIncidents = append(snap.ActiveIncidents, derived)
|
||||
} else {
|
||||
snap.History = append(snap.History, derived)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(snap.History, func(i, j int) bool {
|
||||
return snap.History[i].StartedAt.After(snap.History[j].StartedAt)
|
||||
})
|
||||
sort.Slice(snap.ActiveIncidents, func(i, j int) bool {
|
||||
return snap.ActiveIncidents[i].StartedAt.After(snap.ActiveIncidents[j].StartedAt)
|
||||
})
|
||||
sort.Slice(snap.UpcomingMaintenance, func(i, j int) bool {
|
||||
return snap.UpcomingMaintenance[i].StartedAt.Before(snap.UpcomingMaintenance[j].StartedAt)
|
||||
})
|
||||
|
||||
snap.Overall = overallState(snap.Sections)
|
||||
return snap
|
||||
}
|
||||
|
||||
// publicName never falls back to the monitor's own name. An operator who has
|
||||
// not chosen a public name has not consented to publishing the internal one.
|
||||
func publicName(entry models.StatusPageEntry, monitorID string) string {
|
||||
if entry.DisplayName != "" {
|
||||
return entry.DisplayName
|
||||
}
|
||||
if monitorID != "" {
|
||||
return monitorID
|
||||
}
|
||||
return entry.MonitorID
|
||||
}
|
||||
|
||||
func authoredForPage(pageID string, all []models.StatusIncident) []models.StatusIncident {
|
||||
out := []models.StatusIncident{}
|
||||
for _, inc := range all {
|
||||
for _, p := range inc.PageIDs {
|
||||
if p == pageID {
|
||||
out = append(out, inc)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func maintenanceMonitors(authored []models.StatusIncident, now time.Time) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, inc := range authored {
|
||||
if inc.Kind != models.StatusKindMaintenance || inc.Status != models.MaintenanceInProgress {
|
||||
continue
|
||||
}
|
||||
if inc.ScheduledStart != nil && now.Before(*inc.ScheduledStart) {
|
||||
continue
|
||||
}
|
||||
if inc.ScheduledEnd != nil && now.After(*inc.ScheduledEnd) {
|
||||
continue
|
||||
}
|
||||
for _, m := range inc.AffectedMonitors {
|
||||
out[m] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isOpen(inc models.StatusIncident) bool {
|
||||
if inc.Kind == models.StatusKindMaintenance {
|
||||
return inc.Status == models.MaintenanceInProgress
|
||||
}
|
||||
return inc.Status != models.IncidentResolved
|
||||
}
|
||||
|
||||
func autoStatus(inc models.Incident) string {
|
||||
if inc.ResolvedAt != nil {
|
||||
return models.IncidentResolved
|
||||
}
|
||||
return models.IncidentInvestigating
|
||||
}
|
||||
|
||||
func publicFromAuthored(inc models.StatusIncident, names map[string]string) PublicIncident {
|
||||
p := PublicIncident{
|
||||
ID: inc.IncidentID,
|
||||
Kind: inc.Kind,
|
||||
Title: inc.Title,
|
||||
Impact: inc.Impact,
|
||||
Status: inc.Status,
|
||||
StartedAt: inc.StartedAt,
|
||||
ResolvedAt: inc.ResolvedAt,
|
||||
ScheduledStart: inc.ScheduledStart,
|
||||
ScheduledEnd: inc.ScheduledEnd,
|
||||
}
|
||||
for _, m := range inc.AffectedMonitors {
|
||||
// A monitor not on this page contributes nothing: publishing the raw
|
||||
// id would name a component the reader cannot see.
|
||||
if name, ok := names[m]; ok {
|
||||
p.Affected = append(p.Affected, name)
|
||||
}
|
||||
}
|
||||
for _, u := range inc.Updates {
|
||||
p.Updates = append(p.Updates, PublicIncidentUpdate{At: u.At, Status: u.Status, Body: u.Body})
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// buildDays produces exactly HistoryDays cells, oldest first, ending today.
|
||||
// It carries no maintenance state: a maintenance repaint is a display concern
|
||||
// applied afterwards by applyMaintenanceRepaint, once uptimeFromDays has
|
||||
// already read the true no_data/up/down state of each day. Folding the
|
||||
// repaint in here would let a today cell with no rollups yet flip from
|
||||
// no_data to maintenance before its uptime contribution was decided, and
|
||||
// uptimeFromDays skips no_data days by their State — so that day would stop
|
||||
// being skipped and start counting as a zero.
|
||||
func buildDays(rollups []models.Rollup, now time.Time) []PublicDay {
|
||||
type bucket struct{ checks, up int }
|
||||
byDay := map[string]*bucket{}
|
||||
for _, r := range rollups {
|
||||
key := r.PeriodStart.UTC().Format("2006-01-02")
|
||||
b, ok := byDay[key]
|
||||
if !ok {
|
||||
b = &bucket{}
|
||||
byDay[key] = b
|
||||
}
|
||||
b.checks += r.Checks
|
||||
b.up += r.UpCount
|
||||
}
|
||||
|
||||
today := now.UTC().Truncate(24 * time.Hour)
|
||||
days := make([]PublicDay, 0, HistoryDays)
|
||||
for i := HistoryDays - 1; i >= 0; i-- {
|
||||
d := today.AddDate(0, 0, -i)
|
||||
key := d.Format("2006-01-02")
|
||||
day := PublicDay{Date: key, State: PublicNoData}
|
||||
if b, ok := byDay[key]; ok && b.checks > 0 {
|
||||
day.Uptime = float64(b.up) / float64(b.checks) * 100
|
||||
if day.Uptime >= 99.9 {
|
||||
day.State = PublicUp
|
||||
} else {
|
||||
day.State = PublicDown
|
||||
}
|
||||
}
|
||||
days = append(days, day)
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
// applyMaintenanceRepaint redraws today's cell as "maintenance" for display,
|
||||
// after uptimeFromDays has already computed the component's Uptime90d from
|
||||
// the unpainted days. It never touches Uptime, and it must run after that
|
||||
// computation, not before: repainting first would turn a today cell with no
|
||||
// rollups yet from no_data (skipped) into maintenance (a 0% day counted in
|
||||
// the average), and repainting a day that DOES have rollups must still leave
|
||||
// that day's real up/down contribution in the average — maintenance changes
|
||||
// how a day is drawn, never what the numbers say.
|
||||
func applyMaintenanceRepaint(days []PublicDay, inMaintenance bool) []PublicDay {
|
||||
if inMaintenance && len(days) > 0 {
|
||||
days[len(days)-1].State = PublicMaintenance
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
// uptimeFromDays ignores no_data days rather than counting them as zero. A
|
||||
// component created last week is not 92% available.
|
||||
func uptimeFromDays(days []PublicDay) float64 {
|
||||
var sum float64
|
||||
var n int
|
||||
for _, d := range days {
|
||||
if d.State == PublicNoData {
|
||||
continue
|
||||
}
|
||||
sum += d.Uptime
|
||||
n++
|
||||
}
|
||||
if n == 0 {
|
||||
return 0
|
||||
}
|
||||
return sum / float64(n)
|
||||
}
|
||||
|
||||
func componentStatus(mon models.Monitor, known, inMaintenance bool, now time.Time) string {
|
||||
if inMaintenance {
|
||||
return PublicMaintenance
|
||||
}
|
||||
if !known {
|
||||
// The monitor was deleted while still listed on a page. Saying "up"
|
||||
// would be a claim nothing is checking.
|
||||
return PublicNoData
|
||||
}
|
||||
switch mon.State.Status {
|
||||
case models.StatusUp:
|
||||
return PublicUp
|
||||
case models.StatusDown:
|
||||
return PublicDown
|
||||
default:
|
||||
return PublicPending
|
||||
}
|
||||
}
|
||||
|
||||
const statusCacheTTL = 30 * time.Second
|
||||
|
||||
// unavailableSnapshot returns a StatusSnapshot with Available: false and all
|
||||
// four list fields initialized to empty slices rather than nil, ensuring
|
||||
// consistent JSON serialization across the available and unavailable paths.
|
||||
func unavailableSnapshot(reason, title string) *StatusSnapshot {
|
||||
return &StatusSnapshot{
|
||||
Available: false,
|
||||
Reason: reason,
|
||||
Title: title,
|
||||
Sections: []PublicSection{},
|
||||
ActiveIncidents: []PublicIncident{},
|
||||
UpcomingMaintenance: []PublicIncident{},
|
||||
History: []PublicIncident{},
|
||||
}
|
||||
}
|
||||
|
||||
// PublicStatusSnapshot is the whole public read path.
|
||||
//
|
||||
// A missing page, an unpublished page and a page belonging to another instance
|
||||
// all return ErrPageNotFound, identically. A distinct error for "exists but
|
||||
// unpublished" would confirm it exists.
|
||||
func PublicStatusSnapshot(instanceID, pageID string) (*StatusSnapshot, error) {
|
||||
if err := ValidatePageID(pageID); err != nil {
|
||||
log.Printf("public status: page id %q is not a valid id: %v", pageID, err)
|
||||
return nil, ErrPageNotFound
|
||||
}
|
||||
if snap := cachedSnapshot(instanceID, pageID); snap != nil {
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
page, err := GetStatusPage(instanceID, pageID)
|
||||
if err != nil {
|
||||
log.Printf("public status: instance=%s page=%q lookup: %v", instanceID, pageID, err)
|
||||
return nil, err
|
||||
}
|
||||
if !page.Published {
|
||||
log.Printf("public status: instance=%s page=%q exists but published=false", instanceID, pageID)
|
||||
return nil, ErrPageNotFound
|
||||
}
|
||||
|
||||
// The licence check answers 200 with available:false rather than 403,
|
||||
// because the reader is a member of the public who can do nothing about it
|
||||
// and deserves an explanation rather than a browser error.
|
||||
st := GetLicenseState(instanceID)
|
||||
if !st.Active() {
|
||||
return unavailableSnapshot("licence_inactive", page.Title), nil
|
||||
}
|
||||
if !st.Feature(license.FeatureStatusPages) {
|
||||
return unavailableSnapshot("feature_unavailable", page.Title), nil
|
||||
}
|
||||
|
||||
in, err := loadSnapshotInput(instanceID, *page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap := assembleSnapshot(in)
|
||||
storeSnapshot(instanceID, pageID, snap)
|
||||
return &snap, nil
|
||||
}
|
||||
|
||||
func loadSnapshotInput(instanceID string, page models.StatusPage) (snapshotInput, error) {
|
||||
in := snapshotInput{
|
||||
Page: page,
|
||||
Monitors: map[string]models.Monitor{},
|
||||
Rollups: map[string][]models.Rollup{},
|
||||
Now: time.Now().UTC(),
|
||||
}
|
||||
since := in.Now.AddDate(0, 0, -HistoryDays)
|
||||
|
||||
ids := []string{}
|
||||
for _, sec := range page.Sections {
|
||||
for _, e := range sec.Entries {
|
||||
ids = append(ids, e.MonitorID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
// A page with no components still renders: title, banner and any
|
||||
// authored incidents. Skipping the monitor queries avoids three
|
||||
// unbounded $in lookups on an empty list.
|
||||
authored, err := ListStatusIncidentsForPage(instanceID, page.PageID, since)
|
||||
if err != nil {
|
||||
return in, err
|
||||
}
|
||||
in.Authored = authored
|
||||
return in, nil
|
||||
}
|
||||
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
|
||||
cur, err := db.Col("monitors").Find(ctx, bson.M{
|
||||
"instance_id": instanceID,
|
||||
"monitor_id": bson.M{"$in": ids},
|
||||
})
|
||||
if err != nil {
|
||||
return in, err
|
||||
}
|
||||
mons := []models.Monitor{}
|
||||
if err := cur.All(ctx, &mons); err != nil {
|
||||
return in, err
|
||||
}
|
||||
for _, m := range mons {
|
||||
in.Monitors[m.MonitorID] = m
|
||||
}
|
||||
|
||||
rc, err := db.Col("monitor_rollups").Find(ctx, bson.M{
|
||||
"instance_id": instanceID,
|
||||
"monitor_id": bson.M{"$in": ids},
|
||||
"period_start": bson.M{"$gte": since},
|
||||
})
|
||||
if err != nil {
|
||||
return in, err
|
||||
}
|
||||
rollups := []models.Rollup{}
|
||||
if err := rc.All(ctx, &rollups); err != nil {
|
||||
return in, err
|
||||
}
|
||||
for _, r := range rollups {
|
||||
in.Rollups[r.MonitorID] = append(in.Rollups[r.MonitorID], r)
|
||||
}
|
||||
|
||||
ic, err := db.Col("incidents").Find(ctx, bson.M{
|
||||
"instance_id": instanceID,
|
||||
"monitor_id": bson.M{"$in": ids},
|
||||
"started_at": bson.M{"$gte": since},
|
||||
})
|
||||
if err != nil {
|
||||
return in, err
|
||||
}
|
||||
auto := []models.Incident{}
|
||||
if err := ic.All(ctx, &auto); err != nil {
|
||||
return in, err
|
||||
}
|
||||
in.AutoIncidents = auto
|
||||
|
||||
authored, err := ListStatusIncidentsForPage(instanceID, page.PageID, since)
|
||||
if err != nil {
|
||||
return in, err
|
||||
}
|
||||
in.Authored = authored
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// A cache miss on Redis is a cache miss, never an error: the status page must
|
||||
// survive the outage it exists to report.
|
||||
func cachedSnapshot(instanceID, pageID string) *StatusSnapshot {
|
||||
if statusRedis == nil {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
raw, err := statusRedis.Get(ctx, statusCacheKey(instanceID, pageID)).Bytes()
|
||||
if err != nil || len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var snap StatusSnapshot
|
||||
if err := json.Unmarshal(raw, &snap); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &snap
|
||||
}
|
||||
|
||||
func storeSnapshot(instanceID, pageID string, snap StatusSnapshot) {
|
||||
if statusRedis == nil {
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(snap)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
if err := statusRedis.Set(ctx, statusCacheKey(instanceID, pageID), raw, statusCacheTTL).Err(); err != nil {
|
||||
log.Printf("status cache store %s/%s: %v", instanceID, pageID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func overallState(sections []PublicSection) string {
|
||||
worst := PublicUp
|
||||
anyDown, anyMaint, anyOther := false, false, false
|
||||
counted := 0
|
||||
for _, s := range sections {
|
||||
for _, c := range s.Components {
|
||||
counted++
|
||||
switch c.Status {
|
||||
case PublicDown:
|
||||
anyDown = true
|
||||
case PublicMaintenance:
|
||||
anyMaint = true
|
||||
case PublicPending, PublicNoData:
|
||||
anyOther = true
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case counted == 0:
|
||||
// Nothing is being reported, so nothing is known. "All systems
|
||||
// operational" over zero components is a claim of health made from no
|
||||
// evidence at all; PublicNoData is what the view renders as
|
||||
// "Status unknown".
|
||||
worst = PublicNoData
|
||||
case anyDown:
|
||||
worst = PublicDown
|
||||
case anyMaint:
|
||||
worst = PublicMaintenance
|
||||
case anyOther:
|
||||
worst = PublicDegraded
|
||||
}
|
||||
return worst
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
func testInput(now time.Time) snapshotInput {
|
||||
return snapshotInput{
|
||||
Page: models.StatusPage{
|
||||
PageID: "api",
|
||||
Title: "Acme Status",
|
||||
Published: true,
|
||||
Sections: []models.StatusPageSection{{
|
||||
Name: "API",
|
||||
Entries: []models.StatusPageEntry{
|
||||
{MonitorID: "mon-1", DisplayName: "Public API"},
|
||||
{MonitorID: "mon-2"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
Monitors: map[string]models.Monitor{
|
||||
"mon-1": {
|
||||
MonitorID: "mon-1",
|
||||
Name: "prod-api-internal",
|
||||
Type: models.MonitorHTTP,
|
||||
ChannelIDs: []string{"chan-123"},
|
||||
Target: models.MonitorTarget{
|
||||
URL: "https://internal.example.com/health",
|
||||
Keyword: "SECRETKEYWORD",
|
||||
},
|
||||
State: models.MonitorState{
|
||||
Status: models.StatusUp,
|
||||
Message: "dial tcp 10.0.0.5:5432: connect refused",
|
||||
},
|
||||
},
|
||||
"mon-2": {
|
||||
MonitorID: "mon-2",
|
||||
Name: "db-primary",
|
||||
Type: models.MonitorTCP,
|
||||
Target: models.MonitorTarget{Host: "10.0.0.5", Port: 5432},
|
||||
State: models.MonitorState{Status: models.StatusDown},
|
||||
},
|
||||
},
|
||||
Rollups: map[string][]models.Rollup{},
|
||||
AutoIncidents: []models.Incident{},
|
||||
Authored: []models.StatusIncident{},
|
||||
Now: now,
|
||||
}
|
||||
}
|
||||
|
||||
// The snapshot is the only thing that reaches an anonymous caller. If any of
|
||||
// these strings can be found in its JSON, the boundary has a hole in it.
|
||||
func TestAssembleSnapshotRedactsMonitorInternals(t *testing.T) {
|
||||
in := testInput(time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC))
|
||||
in.AutoIncidents = []models.Incident{{
|
||||
IncidentID: "inc-1",
|
||||
MonitorID: "mon-2",
|
||||
StartedAt: in.Now.Add(-2 * time.Hour),
|
||||
Cause: "dial tcp 10.0.0.5:5432: connect refused",
|
||||
}}
|
||||
|
||||
b, err := json.Marshal(assembleSnapshot(in))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
got := string(b)
|
||||
|
||||
leaks := []string{
|
||||
"internal.example.com",
|
||||
"10.0.0.5",
|
||||
"connect refused",
|
||||
"chan-123",
|
||||
"SECRETKEYWORD",
|
||||
"prod-api-internal",
|
||||
"db-primary",
|
||||
"5432",
|
||||
}
|
||||
for _, leak := range leaks {
|
||||
if strings.Contains(got, leak) {
|
||||
t.Errorf("snapshot leaked %q\nfull snapshot: %s", leak, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleSnapshotUsesDisplayNameThenMonitorName(t *testing.T) {
|
||||
in := testInput(time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC))
|
||||
// mon-2 has no DisplayName override, and its monitor name ("db-primary")
|
||||
// is in TestAssembleSnapshotRedactsMonitorInternals's leak list, so an
|
||||
// un-overridden entry must fall back to something safe. It falls back to
|
||||
// the monitor id, never the internal name.
|
||||
snap := assembleSnapshot(in)
|
||||
comps := snap.Sections[0].Components
|
||||
if comps[0].Name != "Public API" {
|
||||
t.Errorf("component 0 name = %q, want %q", comps[0].Name, "Public API")
|
||||
}
|
||||
if comps[1].Name != "mon-2" {
|
||||
t.Errorf("component 1 name = %q, want the monitor id as fallback", comps[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleSnapshotHistoryIs90DaysWithNoDataForMissingRollups(t *testing.T) {
|
||||
now := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
in := testInput(now)
|
||||
in.Rollups = map[string][]models.Rollup{
|
||||
"mon-1": {
|
||||
{MonitorID: "mon-1", PeriodStart: now.Add(-24 * time.Hour), Checks: 60, UpCount: 60},
|
||||
{MonitorID: "mon-1", PeriodStart: now.Add(-48 * time.Hour), Checks: 60, UpCount: 30},
|
||||
},
|
||||
}
|
||||
snap := assembleSnapshot(in)
|
||||
days := snap.Sections[0].Components[0].Days
|
||||
if len(days) != 90 {
|
||||
t.Fatalf("len(days) = %d, want 90", len(days))
|
||||
}
|
||||
if days[89].Date != "2026-08-24" {
|
||||
t.Errorf("last day = %q, want 2026-08-24", days[89].Date)
|
||||
}
|
||||
if days[88].State != "up" {
|
||||
t.Errorf("yesterday state = %q, want up", days[88].State)
|
||||
}
|
||||
if days[87].State != "down" {
|
||||
t.Errorf("two days ago state = %q, want down (50%% up)", days[87].State)
|
||||
}
|
||||
if days[0].State != "no_data" {
|
||||
t.Errorf("oldest day state = %q, want no_data", days[0].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleSnapshotMaintenanceDoesNotChangeUptime(t *testing.T) {
|
||||
now := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
in := testInput(now)
|
||||
in.Rollups = map[string][]models.Rollup{
|
||||
"mon-2": {{MonitorID: "mon-2", PeriodStart: now, Checks: 100, UpCount: 50}},
|
||||
}
|
||||
start := now.Add(-time.Hour)
|
||||
end := now.Add(time.Hour)
|
||||
in.Authored = []models.StatusIncident{{
|
||||
IncidentID: "mnt-1",
|
||||
PageIDs: []string{"api"},
|
||||
Kind: models.StatusKindMaintenance,
|
||||
Title: "Database upgrade",
|
||||
Status: models.MaintenanceInProgress,
|
||||
AffectedMonitors: []string{"mon-2"},
|
||||
ScheduledStart: &start,
|
||||
ScheduledEnd: &end,
|
||||
StartedAt: start,
|
||||
}}
|
||||
|
||||
snap := assembleSnapshot(in)
|
||||
comp := snap.Sections[0].Components[1]
|
||||
if comp.Status != "maintenance" {
|
||||
t.Errorf("status = %q, want maintenance", comp.Status)
|
||||
}
|
||||
// Rollups are the durable record. A maintenance window changes how the
|
||||
// component is drawn, never what the numbers say.
|
||||
if comp.Uptime90d != 50 {
|
||||
t.Errorf("uptime = %v, want 50 (unmodified by the window)", comp.Uptime90d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleSnapshotMaintenanceRepaintDoesNotCountNoDataAsZero guards
|
||||
// against the maintenance repaint corrupting Uptime90d for a component whose
|
||||
// today rollup has not landed yet — an in-progress maintenance window on a
|
||||
// young component, or one that simply started before today's hourly rollup
|
||||
// was written. Repainting today's no_data cell to "maintenance" must never
|
||||
// make uptimeFromDays stop skipping it: doing so would turn a component with
|
||||
// one good day of history from 100% into 50%.
|
||||
func TestAssembleSnapshotMaintenanceRepaintDoesNotCountNoDataAsZero(t *testing.T) {
|
||||
now := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
in := testInput(now)
|
||||
in.Rollups = map[string][]models.Rollup{
|
||||
// Only yesterday has a rollup, fully up. Today has none.
|
||||
"mon-2": {{MonitorID: "mon-2", PeriodStart: now.Add(-24 * time.Hour), Checks: 60, UpCount: 60}},
|
||||
}
|
||||
start := now.Add(-time.Hour)
|
||||
end := now.Add(time.Hour)
|
||||
in.Authored = []models.StatusIncident{{
|
||||
IncidentID: "mnt-2",
|
||||
PageIDs: []string{"api"},
|
||||
Kind: models.StatusKindMaintenance,
|
||||
Title: "Database upgrade",
|
||||
Status: models.MaintenanceInProgress,
|
||||
AffectedMonitors: []string{"mon-2"},
|
||||
ScheduledStart: &start,
|
||||
ScheduledEnd: &end,
|
||||
StartedAt: start,
|
||||
}}
|
||||
|
||||
snap := assembleSnapshot(in)
|
||||
comp := snap.Sections[0].Components[1]
|
||||
if comp.Status != "maintenance" {
|
||||
t.Errorf("status = %q, want maintenance", comp.Status)
|
||||
}
|
||||
// Today's cell is redrawn as maintenance for display...
|
||||
if comp.Days[89].State != "maintenance" {
|
||||
t.Errorf("today state = %q, want maintenance", comp.Days[89].State)
|
||||
}
|
||||
// ...but it carries no rollup, so it must not have been averaged in as a
|
||||
// zero. The only day with data was 100% up, so the 90-day figure is 100,
|
||||
// not (100+0)/2 = 50.
|
||||
if comp.Uptime90d != 100 {
|
||||
t.Errorf("uptime = %v, want 100 (today's no_data must not count as zero)", comp.Uptime90d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleSnapshotOnlyIncludesAuthoredIncidentsForThisPage(t *testing.T) {
|
||||
now := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
in := testInput(now)
|
||||
in.Authored = []models.StatusIncident{
|
||||
{IncidentID: "mine", PageIDs: []string{"api"}, Kind: models.StatusKindIncident,
|
||||
Title: "Mine", Status: models.IncidentInvestigating, StartedAt: now},
|
||||
{IncidentID: "theirs", PageIDs: []string{"partners"}, Kind: models.StatusKindIncident,
|
||||
Title: "Theirs", Status: models.IncidentInvestigating, StartedAt: now},
|
||||
}
|
||||
snap := assembleSnapshot(in)
|
||||
if len(snap.ActiveIncidents) != 1 || snap.ActiveIncidents[0].Title != "Mine" {
|
||||
t.Fatalf("active incidents = %+v, want only the one naming this page", snap.ActiveIncidents)
|
||||
}
|
||||
}
|
||||
|
||||
// A derived outage that has not recovered is happening now. It used to be
|
||||
// appended to History unconditionally, so a live outage was reported under
|
||||
// "Past incidents" while the component pill next to it read Down.
|
||||
func TestAssembleSnapshotDerivedIncidentActiveUntilResolved(t *testing.T) {
|
||||
now := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
resolved := now.Add(-30 * time.Minute)
|
||||
|
||||
in := testInput(now)
|
||||
in.AutoIncidents = []models.Incident{
|
||||
{
|
||||
IncidentID: "inc-open",
|
||||
MonitorID: "mon-2",
|
||||
StartedAt: now.Add(-2 * time.Hour),
|
||||
},
|
||||
{
|
||||
IncidentID: "inc-closed",
|
||||
MonitorID: "mon-1",
|
||||
StartedAt: now.Add(-3 * time.Hour),
|
||||
ResolvedAt: &resolved,
|
||||
},
|
||||
}
|
||||
|
||||
snap := assembleSnapshot(in)
|
||||
|
||||
if len(snap.ActiveIncidents) != 1 || snap.ActiveIncidents[0].ID != "inc-open" {
|
||||
t.Fatalf("unresolved incident should be active, got %+v", snap.ActiveIncidents)
|
||||
}
|
||||
if snap.ActiveIncidents[0].ResolvedAt != nil {
|
||||
t.Errorf("active incident carries a resolved_at: %v", snap.ActiveIncidents[0].ResolvedAt)
|
||||
}
|
||||
if len(snap.History) != 1 || snap.History[0].ID != "inc-closed" {
|
||||
t.Fatalf("resolved incident should be history, got %+v", snap.History)
|
||||
}
|
||||
if snap.History[0].ResolvedAt == nil {
|
||||
t.Errorf("history entry lost its resolved_at")
|
||||
}
|
||||
}
|
||||
|
||||
// A page with no components knows nothing, and claiming "all systems
|
||||
// operational" from no evidence is the one answer it must not give.
|
||||
func TestAssembleSnapshotEmptyPageIsNotOperational(t *testing.T) {
|
||||
now := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
in := testInput(now)
|
||||
in.Page.Sections = nil
|
||||
if got := assembleSnapshot(in).Overall; got != PublicNoData {
|
||||
t.Errorf("overall for a page with no components = %q, want %q", got, PublicNoData)
|
||||
}
|
||||
|
||||
in = testInput(now)
|
||||
in.Page.Sections = []models.StatusPageSection{{Name: "API"}}
|
||||
if got := assembleSnapshot(in).Overall; got != PublicNoData {
|
||||
t.Errorf("overall for a page with an empty section = %q, want %q", got, PublicNoData)
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,11 @@ const (
|
||||
// findings themselves. The gate is at collection, not display: an ungated
|
||||
// instance stores no inventory, and storage is the expensive half.
|
||||
FeatureVulnScanning = "vuln_scanning"
|
||||
// FeatureStatusPages gates public status pages at both ends: authoring
|
||||
// them, and serving them. Serving answers 200 with available:false rather
|
||||
// than 403, because the page has to render an explanation to a member of
|
||||
// the public who cannot do anything about it.
|
||||
FeatureStatusPages = "status_pages"
|
||||
)
|
||||
|
||||
// Support levels. Carried for display and enforced by nothing — there is no code
|
||||
|
||||
+3
-4
@@ -30,10 +30,9 @@ WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Control-plane URL the Next server proxies /api, /auth and /install to. Runtime,
|
||||
# not build time: next.config.js is evaluated when server.js boots in standalone
|
||||
# mode, so this is overridable per deployment without a rebuild.
|
||||
ENV API_URL=http://localhost:8080
|
||||
# No control-plane address here on purpose: this app proxies nothing. /api,
|
||||
# /auth, /install, /update and /public are routed to the server by the reverse
|
||||
# proxy in front of both.
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
Slot,
|
||||
StatusChip,
|
||||
avgLatency,
|
||||
buildSampleSlots,
|
||||
buildSlots,
|
||||
displayStatus,
|
||||
formatDuration,
|
||||
formatMs,
|
||||
formatPct,
|
||||
relativeTime,
|
||||
slotLabel,
|
||||
statusStripe,
|
||||
targetSummary,
|
||||
uptimePct,
|
||||
@@ -30,13 +32,118 @@ import {
|
||||
const CHART_W = 480;
|
||||
const CHART_H = 158;
|
||||
|
||||
/*
|
||||
* The ranges. 24h and 48h are drawn from the hourly rollups, which are the
|
||||
* permanent record; anything shorter than an hour cannot be, so the three
|
||||
* short ranges read individual check results instead. Those expire after 48
|
||||
* hours, which is why no range longer than that is offered from samples.
|
||||
*
|
||||
* Bucket sizes are chosen to land near 50-70 bars, so the tape has the same
|
||||
* texture whichever range is selected.
|
||||
*/
|
||||
interface Range {
|
||||
label: string;
|
||||
minutes: number;
|
||||
/** "rollups" is hourly and permanent; "samples" is per check and expires. */
|
||||
source: "rollups" | "samples";
|
||||
bucketMs: number;
|
||||
}
|
||||
|
||||
const RANGES: Range[] = [
|
||||
{ label: "48h", minutes: 48 * 60, source: "rollups", bucketMs: 3600_000 },
|
||||
{ label: "24h", minutes: 24 * 60, source: "rollups", bucketMs: 3600_000 },
|
||||
{ label: "12h", minutes: 12 * 60, source: "samples", bucketMs: 600_000 },
|
||||
{ label: "8h", minutes: 8 * 60, source: "samples", bucketMs: 600_000 },
|
||||
{ label: "1h", minutes: 60, source: "samples", bucketMs: 60_000 },
|
||||
];
|
||||
|
||||
function rangeTitle(r: Range): string {
|
||||
const hours = r.minutes / 60;
|
||||
return hours === 1 ? "Last hour" : `Last ${hours} hours`;
|
||||
}
|
||||
|
||||
function bucketLabel(bucketMs: number): string {
|
||||
if (bucketMs >= 3600_000) return "1 hour per bar";
|
||||
return `${Math.round(bucketMs / 60_000)} min per bar`;
|
||||
}
|
||||
|
||||
function RangePicker({ value, onChange }: { value: Range; onChange: (r: Range) => void }) {
|
||||
return (
|
||||
<div className="flex overflow-hidden rounded-sm border border-border" role="group" aria-label="Chart range">
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r.label}
|
||||
type="button"
|
||||
onClick={() => onChange(r)}
|
||||
aria-pressed={r.label === value.label}
|
||||
className={`border-l border-border px-2.5 py-1 font-mono text-[11px] uppercase tracking-[0.08em] transition-colors first:border-l-0 focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-accent ${
|
||||
r.label === value.label
|
||||
? "bg-accent/15 text-accent"
|
||||
: "bg-surface-2 text-text-secondary hover:bg-surface hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function niceCeiling(ms: number): number {
|
||||
if (ms <= 0) return 100;
|
||||
const steps = [50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000];
|
||||
return steps.find((s) => s >= ms) ?? Math.ceil(ms / 10000) * 10000;
|
||||
}
|
||||
|
||||
function History({ slots }: { slots: Slot[] }) {
|
||||
/*
|
||||
* The bar readout. A native title attribute arrives a second late, cannot show
|
||||
* the latency alongside the uptime, and is invisible to keyboard users — so the
|
||||
* hovered hour gets a real popover, anchored to its own bar.
|
||||
*/
|
||||
function SlotPopover({ slot, index, count }: { slot: Slot; index: number; count: number }) {
|
||||
const end = new Date(slot.at.getTime() + slot.spanMs);
|
||||
const day = slot.at.toLocaleDateString(undefined, { weekday: "short", day: "numeric", month: "short" });
|
||||
const span = `${slot.at.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}–${end.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}`;
|
||||
|
||||
/* Anchored to the bar, but the first and last few bars would push a centred
|
||||
card off the chart, so the edges pin instead of centring. */
|
||||
const frac = count > 1 ? index / (count - 1) : 0.5;
|
||||
const shift = frac < 0.14 ? "0%" : frac > 0.86 ? "-100%" : "-50%";
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tooltip"
|
||||
/* Anchored at the top of the plot rather than above it: the chart
|
||||
box clips its overflow, so a card floated outside would vanish. */
|
||||
className="pointer-events-none absolute top-0 z-20 w-max"
|
||||
style={{ left: `${frac * 100}%`, transform: `translateX(${shift})` }}
|
||||
>
|
||||
<div className="rounded-sm border border-border bg-surface/95 px-3 py-2 shadow-lg backdrop-blur-sm">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-text-tertiary">{day}</p>
|
||||
<p className="mt-0.5 font-mono text-[11.5px] tabular-nums text-text-primary">{span}</p>
|
||||
{slot.pct === null ? (
|
||||
<p className="mt-1.5 text-[11.5px] text-text-secondary">No checks ran</p>
|
||||
) : (
|
||||
<dl className="mt-1.5 grid grid-cols-[auto_auto] gap-x-3 gap-y-0.5 text-[11.5px]">
|
||||
<dt className="text-text-tertiary">Uptime</dt>
|
||||
<dd
|
||||
className={`text-right font-mono tabular-nums ${slot.pct >= 99.5 ? "text-success" : slot.pct >= 80 ? "text-warning" : "text-danger"}`}
|
||||
>
|
||||
{slot.pct.toFixed(1)}%
|
||||
</dd>
|
||||
<dt className="text-text-tertiary">Response</dt>
|
||||
<dd className="text-right font-mono tabular-nums text-text-primary">{formatMs(slot.latency)}</dd>
|
||||
<dt className="text-text-tertiary">Checks</dt>
|
||||
<dd className="text-right font-mono tabular-nums text-text-primary">{slot.checks}</dd>
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function History({ slots, note }: { slots: Slot[]; note?: string }) {
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
const latencies = slots.map((s) => s.latency).filter((v): v is number => v !== null);
|
||||
const scale = niceCeiling(Math.max(...latencies, 0) * 1.15);
|
||||
|
||||
@@ -61,7 +168,16 @@ function History({ slots }: { slots: Slot[] }) {
|
||||
|
||||
const firstAt = slots[0]?.at;
|
||||
const midAt = slots[Math.floor(slots.length / 2)]?.at;
|
||||
const tick = (d?: Date) => (d ? d.toLocaleString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit" }) : "");
|
||||
/* A weekday on a one-hour window is noise: every bar is the same day. */
|
||||
const spanned = slots.length * (slots[0]?.spanMs ?? 3600_000);
|
||||
const tick = (d?: Date) =>
|
||||
d
|
||||
? d.toLocaleString(undefined, {
|
||||
...(spanned > 6 * 3600_000 ? { weekday: "short" as const } : {}),
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -72,20 +188,39 @@ function History({ slots }: { slots: Slot[] }) {
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="absolute inset-x-2.5 bottom-6 top-2.5 flex items-end gap-0.5">
|
||||
{slots.map((s) => (
|
||||
<div
|
||||
{/* z-10 so the bars sit above the trace and stay hoverable. */}
|
||||
<div
|
||||
className="absolute inset-x-2.5 bottom-6 top-2.5 z-10 flex items-end gap-0.5"
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
>
|
||||
{slots.map((s, i) => (
|
||||
<button
|
||||
key={s.at.getTime()}
|
||||
className="h-full flex-1"
|
||||
title={s.pct === null ? "no checks ran" : `${s.pct.toFixed(1)}% up`}
|
||||
style={{ display: "flex", alignItems: "flex-end" }}
|
||||
type="button"
|
||||
className="flex h-full flex-1 items-end focus:outline-none"
|
||||
onMouseEnter={() => setHovered(i)}
|
||||
onFocus={() => setHovered(i)}
|
||||
onBlur={() => setHovered(null)}
|
||||
aria-label={slotLabel(s)}
|
||||
>
|
||||
<div
|
||||
className={`w-full rounded-[1px] ${s.pct === null ? "bg-border-soft" : s.pct >= 99.5 ? "bg-success/60" : s.pct >= 80 ? "bg-warning/70" : "bg-danger/80"}`}
|
||||
<span
|
||||
className={`w-full rounded-[1px] transition-opacity ${
|
||||
hovered !== null && hovered !== i ? "opacity-50" : ""
|
||||
} ${s.pct === null ? "bg-border-soft" : s.pct >= 99.5 ? "bg-success/60" : s.pct >= 80 ? "bg-warning/70" : "bg-danger/80"}`}
|
||||
style={{ height: s.pct === null ? "18%" : `${Math.max(s.pct, 12)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{hovered !== null && slots[hovered] && (
|
||||
<>
|
||||
<span
|
||||
className="pointer-events-none absolute inset-y-0 w-px bg-text-tertiary/40"
|
||||
style={{ left: `${(hovered / Math.max(slots.length - 1, 1)) * 100}%` }}
|
||||
aria-hidden
|
||||
/>
|
||||
<SlotPopover slot={slots[hovered]} index={hovered} count={slots.length} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<svg
|
||||
@@ -113,7 +248,7 @@ function History({ slots }: { slots: Slot[] }) {
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="block h-0.5 w-3.5 bg-accent" /> Response time
|
||||
</span>
|
||||
<span className="text-text-tertiary">Gaps mean no checks ran</span>
|
||||
<span className="text-text-tertiary">{note ?? "Gaps mean no checks ran"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -182,6 +317,7 @@ export default function MonitorDetailPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const monitorId = params.id as string;
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [range, setRange] = useState<Range>(RANGES[0]);
|
||||
const toast = useToast();
|
||||
|
||||
const { data: monitor, isLoading } = useQuery({
|
||||
@@ -196,6 +332,17 @@ export default function MonitorDetailPage() {
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
/* Only fetched for the ranges that read it — the 24h and 48h views are
|
||||
served by the rollups the page already holds. The 1h view refreshes on
|
||||
the check interval's order rather than the rollup's: at one minute per
|
||||
bar, a 60s poll is the difference between live and a bar behind. */
|
||||
const { data: samples } = useQuery({
|
||||
queryKey: ["monitors", monitorId, "samples", range.minutes],
|
||||
queryFn: () => api.getMonitorSamples(monitorId, range.minutes),
|
||||
enabled: range.source === "samples",
|
||||
refetchInterval: range.minutes <= 60 ? 15_000 : 30_000,
|
||||
});
|
||||
|
||||
const { data: incidents } = useQuery({
|
||||
queryKey: ["monitors", monitorId, "incidents"],
|
||||
queryFn: () => api.getMonitorIncidents(monitorId),
|
||||
@@ -261,7 +408,13 @@ export default function MonitorDetailPage() {
|
||||
}
|
||||
|
||||
const all: Rollup[] = rollups ?? [];
|
||||
const slots = buildSlots(all);
|
||||
const slots =
|
||||
range.source === "rollups"
|
||||
? buildSlots(all, Math.round(range.minutes / 60))
|
||||
: buildSampleSlots(samples ?? [], range.minutes * 60_000, range.bucketMs);
|
||||
/* Samples expire after 48h and only start accruing once a check runs, so an
|
||||
empty short range is a real answer and not a failure to load. */
|
||||
const emptyRange = range.source === "samples" && (samples ?? []).length === 0;
|
||||
const status = displayStatus(monitor);
|
||||
const pct24 = uptimePct(all.slice(-24));
|
||||
const pct30d = uptimePct(all);
|
||||
@@ -290,6 +443,11 @@ export default function MonitorDetailPage() {
|
||||
<span className="rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-secondary">
|
||||
{monitor.type}
|
||||
</span>
|
||||
{monitor.group && (
|
||||
<span className="rounded-sm border border-border-soft bg-surface-2 px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-tertiary">
|
||||
{monitor.group}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 break-all font-mono text-xs text-text-tertiary">{targetSummary(monitor)}</p>
|
||||
{monitor.state.message && <p className="mt-1.5 text-sm text-text-secondary">{monitor.state.message}</p>}
|
||||
@@ -336,11 +494,21 @@ export default function MonitorDetailPage() {
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="rounded-lg border border-border bg-surface">
|
||||
<div className="flex items-baseline justify-between gap-3 border-b border-border-soft px-5 py-3.5">
|
||||
<h2 className="text-[15px] font-semibold text-text-primary">Last 48 hours</h2>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">1 hour per bar</span>
|
||||
<h2 className="text-[15px] font-semibold text-text-primary">
|
||||
{rangeTitle(range)}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="hidden font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary sm:inline">
|
||||
{bucketLabel(range.bucketMs)}
|
||||
</span>
|
||||
<RangePicker value={range} onChange={setRange} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<History slots={slots} />
|
||||
<History
|
||||
slots={slots}
|
||||
note={emptyRange ? "No check results recorded in this window yet" : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 border-t border-border-soft sm:grid-cols-4">
|
||||
<Figure
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { api, Monitor, Rollup } from "@/lib/api";
|
||||
@@ -27,6 +28,10 @@ import {
|
||||
* Uptime is per monitor, so the rollups are fetched per monitor. A fleet is
|
||||
* tens of checks, not thousands, and the alternative is a list endpoint that
|
||||
* embeds history for every row whether or not anyone looks at it.
|
||||
*
|
||||
* Groups are display only — a label on the monitor, nothing schedules or
|
||||
* alerts by it. A fleet that never sets one sees the flat list it had before,
|
||||
* with no "Ungrouped" heading over the whole page.
|
||||
*/
|
||||
|
||||
const ROW = "grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1.15fr)_minmax(0,2fr)_170px] sm:gap-5";
|
||||
@@ -68,6 +73,103 @@ function FleetMeter({ counts, uptime }: { counts: Record<DisplayStatus, number>;
|
||||
);
|
||||
}
|
||||
|
||||
const COLLAPSE_KEY = "vantage.monitors.collapsedGroups";
|
||||
|
||||
const UNGROUPED = "Ungrouped";
|
||||
|
||||
interface MonitorGroup {
|
||||
name: string;
|
||||
rows: { monitor: Monitor; rollups: Rollup[] }[];
|
||||
}
|
||||
|
||||
/** Alphabetical, with the ungrouped remainder last so it reads as a leftover. */
|
||||
function groupMonitors(rows: { monitor: Monitor; rollups: Rollup[] }[]): MonitorGroup[] {
|
||||
const byName = new Map<string, MonitorGroup["rows"]>();
|
||||
for (const row of rows) {
|
||||
const name = row.monitor.group?.trim() || UNGROUPED;
|
||||
const bucket = byName.get(name);
|
||||
if (bucket) bucket.push(row);
|
||||
else byName.set(name, [row]);
|
||||
}
|
||||
return [...byName.entries()]
|
||||
.map(([name, groupRows]) => ({ name, rows: groupRows }))
|
||||
.sort((a, b) => {
|
||||
if (a.name === UNGROUPED) return 1;
|
||||
if (b.name === UNGROUPED) return -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
/* Collapse is per browser, not per account: it is which sections this person
|
||||
has folded away, and a round trip to store it would be a write on every
|
||||
click. */
|
||||
function useCollapsedGroups() {
|
||||
const [collapsed, setCollapsed] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(COLLAPSE_KEY);
|
||||
if (raw) setCollapsed(JSON.parse(raw) as string[]);
|
||||
} catch {
|
||||
/* A malformed or unavailable store just means nothing is folded. */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback((name: string) => {
|
||||
setCollapsed((prev) => {
|
||||
const next = prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name];
|
||||
try {
|
||||
window.localStorage.setItem(COLLAPSE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* Not being able to remember it is not a reason to refuse the click. */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { collapsed, toggle };
|
||||
}
|
||||
|
||||
function GroupHeader({
|
||||
group,
|
||||
collapsed,
|
||||
onToggle,
|
||||
}: {
|
||||
group: MonitorGroup;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const counts: Record<DisplayStatus, number> = { up: 0, down: 0, pending: 0, paused: 0 };
|
||||
for (const { monitor } of group.rows) counts[displayStatus(monitor)] += 1;
|
||||
const pct = uptimePct(group.rows.flatMap(({ rollups }) => rollups.slice(-24)));
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={!collapsed}
|
||||
className="flex w-full items-center gap-3 border-b border-border-soft bg-surface-2 px-4 py-2.5 text-left transition-colors hover:bg-surface focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent sm:px-5"
|
||||
>
|
||||
<span className={`font-mono text-[10px] text-text-tertiary transition-transform ${collapsed ? "" : "rotate-90"}`} aria-hidden>
|
||||
▶
|
||||
</span>
|
||||
<span className="truncate font-mono text-[11px] uppercase tracking-[0.16em] text-text-secondary">{group.name}</span>
|
||||
<span className="font-mono text-[11px] tabular-nums text-text-tertiary">
|
||||
{group.rows.length} {group.rows.length === 1 ? "check" : "checks"}
|
||||
</span>
|
||||
{counts.down > 0 && (
|
||||
<span className="rounded-sm border border-danger/40 bg-danger/10 px-1.5 font-mono text-[10px] uppercase tracking-[0.08em] text-danger">
|
||||
{counts.down} down
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto font-mono text-[11px] tabular-nums text-text-secondary">
|
||||
{formatPct(pct)}
|
||||
{pct !== null && <span className="text-text-tertiary">%</span>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MonitorRow({ monitor, rollups }: { monitor: Monitor; rollups: Rollup[] }) {
|
||||
const status = displayStatus(monitor);
|
||||
const slots = buildSlots(rollups);
|
||||
@@ -122,6 +224,12 @@ export default function MonitorsPage() {
|
||||
})),
|
||||
});
|
||||
|
||||
const { collapsed, toggle } = useCollapsedGroups();
|
||||
|
||||
const rows = (monitors ?? []).map((m, i) => ({ monitor: m, rollups: uptimeQueries[i]?.data ?? [] }));
|
||||
const grouped = rows.some(({ monitor }) => !!monitor.group?.trim());
|
||||
const groups = groupMonitors(rows);
|
||||
|
||||
const counts: Record<DisplayStatus, number> = { up: 0, down: 0, pending: 0, paused: 0 };
|
||||
for (const m of monitors ?? []) counts[displayStatus(m)] += 1;
|
||||
|
||||
@@ -176,11 +284,28 @@ export default function MonitorsPage() {
|
||||
<p className="text-right font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">Uptime 24h · response</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-surface">
|
||||
{monitors.map((m, i) => (
|
||||
<MonitorRow key={m.monitor_id} monitor={m} rollups={uptimeQueries[i]?.data ?? []} />
|
||||
))}
|
||||
</div>
|
||||
{grouped ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{groups.map((group) => {
|
||||
const isCollapsed = collapsed.includes(group.name);
|
||||
return (
|
||||
<div key={group.name} className="overflow-hidden rounded-lg border border-border bg-surface">
|
||||
<GroupHeader group={group} collapsed={isCollapsed} onToggle={() => toggle(group.name)} />
|
||||
{!isCollapsed &&
|
||||
group.rows.map(({ monitor, rollups }) => (
|
||||
<MonitorRow key={monitor.monitor_id} monitor={monitor} rollups={rollups} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-surface">
|
||||
{rows.map(({ monitor, rollups }) => (
|
||||
<MonitorRow key={monitor.monitor_id} monitor={monitor} rollups={rollups} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -171,7 +171,11 @@ function RecordPanel({ license }: { license: LicenseInfo }) {
|
||||
<Keyed label="Instance ID">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="truncate font-mono text-xs text-text-primary">{license.instance_id}</code>
|
||||
<button type="button" onClick={copyId} className="flex-shrink-0 rounded-sm border border-border px-1.5 py-0.5 font-mono text-[0.6rem] uppercase tracking-[0.1em] text-text-secondary transition-colors hover:border-text-tertiary hover:text-text-primary">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyId}
|
||||
className="flex-shrink-0 rounded-sm border border-border px-1.5 py-0.5 font-mono text-[0.6rem] uppercase tracking-[0.1em] text-text-secondary transition-colors hover:border-text-tertiary hover:text-text-primary"
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -257,6 +261,7 @@ export default function LicensePage() {
|
||||
<Feature label="Browser console" included={Boolean(license.features.console)} />
|
||||
<Feature label="Single sign-on" included={Boolean(license.features.oidc)} />
|
||||
<Feature label="Vulnerability Scanning" included={Boolean(license.features.vuln_scanning)} />
|
||||
<Feature label="Status Pages" included={Boolean(license.features.status_pages)} />
|
||||
</div>
|
||||
</Card>
|
||||
</Group>
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { api, ChannelInput, ChannelType, NotificationChannel } from "@/lib/api";
|
||||
import {
|
||||
api,
|
||||
CHANNEL_SECRET_FIELDS,
|
||||
ChannelInput,
|
||||
ChannelType,
|
||||
NotificationChannel,
|
||||
REDACTED_SECRET,
|
||||
} from "@/lib/api";
|
||||
import { Badge, Button, Card, ConfirmDialog, friendlyMessage, useToast } from "@/components/ui";
|
||||
import { VulnAlertRulesCard } from "@/components/vulnerabilities/VulnAlertRulesCard";
|
||||
|
||||
@@ -67,17 +74,28 @@ function ChannelForm({ initial, onDone }: { initial?: NotificationChannel; onDon
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{CONFIG_FIELDS[type].map((field) => (
|
||||
<div key={field}>
|
||||
<label className={labelClass}>{field}</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
type={field === "password" ? "password" : "text"}
|
||||
value={config[field] ?? ""}
|
||||
onChange={(e) => setConfig({ ...config, [field]: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{CONFIG_FIELDS[type].map((field) => {
|
||||
// A secret comes back from the API as the sentinel, never as itself.
|
||||
// The field renders empty rather than showing bullets in a URL box, and
|
||||
// the sentinel is left sitting in state so an untouched save preserves
|
||||
// the credential. Typing replaces it; clearing the field back to empty
|
||||
// is how a credential is removed.
|
||||
const secret = CHANNEL_SECRET_FIELDS[type].includes(field);
|
||||
const value = config[field] ?? "";
|
||||
const unchanged = secret && value === REDACTED_SECRET;
|
||||
return (
|
||||
<div key={field}>
|
||||
<label className={labelClass}>{field}</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
type={field === "password" ? "password" : "text"}
|
||||
value={unchanged ? "" : value}
|
||||
placeholder={unchanged ? "unchanged — type to replace" : undefined}
|
||||
onChange={(e) => setConfig({ ...config, [field]: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error && <p className="text-sm text-danger">{(error as Error).message}</p>}
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" variant="primary" loading={isPending}>
|
||||
|
||||
@@ -0,0 +1,932 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
api,
|
||||
Monitor,
|
||||
StatusIncident,
|
||||
StatusPage,
|
||||
StatusPageSection,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
AsyncBoundary,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CenteredSpinner,
|
||||
ConfirmDialog,
|
||||
friendlyMessage,
|
||||
Modal,
|
||||
useToast,
|
||||
} from "@/components/ui";
|
||||
import { relativeTime } from "@/components/monitors/MonitorVisuals";
|
||||
|
||||
/*
|
||||
* Details and Components are edited as one local draft and saved together by
|
||||
* the single "Save changes" button in the header, matching artboard 2 of the
|
||||
* mockup. Incidents are their own timeline and mutate immediately — opening
|
||||
* one, posting an update or editing a maintenance window has no "unsaved"
|
||||
* state to lose, so there is nothing to batch.
|
||||
*/
|
||||
|
||||
function publicUrl(pageId: string): string {
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
return `${origin}/status/${pageId}`;
|
||||
}
|
||||
|
||||
interface Draft {
|
||||
title: string;
|
||||
description: string;
|
||||
logoUrl: string;
|
||||
published: boolean;
|
||||
banner: { enabled: boolean; level: string; text: string };
|
||||
sections: StatusPageSection[];
|
||||
}
|
||||
|
||||
function draftFromPage(page: StatusPage): Draft {
|
||||
return {
|
||||
title: page.title,
|
||||
description: page.description ?? "",
|
||||
logoUrl: page.logo_url ?? "",
|
||||
published: page.published,
|
||||
banner: { enabled: page.banner?.enabled ?? false, level: page.banner?.level ?? "", text: page.banner?.text ?? "" },
|
||||
// Deep copy so section/entry edits never mutate the query cache directly.
|
||||
sections: page.sections.map((s) => ({ name: s.name, entries: s.entries.map((e) => ({ ...e })) })),
|
||||
};
|
||||
}
|
||||
|
||||
function draftToInput(draft: Draft): Partial<StatusPage> {
|
||||
return {
|
||||
title: draft.title.trim(),
|
||||
description: draft.description.trim() || undefined,
|
||||
logo_url: draft.logoUrl.trim() || undefined,
|
||||
published: draft.published,
|
||||
banner: {
|
||||
enabled: draft.banner.enabled,
|
||||
level: draft.banner.text.trim() ? draft.banner.level || "info" : undefined,
|
||||
text: draft.banner.text.trim() || undefined,
|
||||
},
|
||||
sections: draft.sections.map((s) => ({ name: s.name.trim(), entries: s.entries })),
|
||||
};
|
||||
}
|
||||
|
||||
// A blank-named section used to be dropped silently on save -- filtered out
|
||||
// here and the reseed from the server then made it vanish with no message.
|
||||
// Finding it instead lets the caller block the save and name which section
|
||||
// needs a name, rather than discarding an operator's work.
|
||||
function unnamedSectionIndex(draft: Draft): number {
|
||||
return draft.sections.findIndex((s) => s.name.trim().length === 0);
|
||||
}
|
||||
|
||||
/*
|
||||
* A blank display_name does NOT fall back to the monitor's name on the server —
|
||||
* it publishes the raw monitor UUID, deliberately, because publishing an
|
||||
* internal name has to be a decision rather than a default. So the editor
|
||||
* refuses to save one instead of letting an operator add five monitors and
|
||||
* discover five UUIDs on their public page.
|
||||
*/
|
||||
function unnamedComponent(draft: Draft): { section: number; entry: number } | null {
|
||||
for (let si = 0; si < draft.sections.length; si++) {
|
||||
const ei = draft.sections[si].entries.findIndex((e) => (e.display_name ?? "").trim().length === 0);
|
||||
if (ei >= 0) return { section: si, entry: ei };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
|
||||
|
||||
function Switch({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative h-[21px] w-[38px] flex-shrink-0 rounded-full border-0 p-0 transition-colors ${checked ? "bg-success" : "bg-border"}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-0 top-[2px] h-[17px] w-[17px] rounded-full transition-transform ${
|
||||
checked ? "translate-x-[19px] bg-accent-ink" : "translate-x-[2px] bg-text-tertiary"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function monitorMeta(m: Monitor): string {
|
||||
return `${m.type} · every ${m.interval_sec}s`;
|
||||
}
|
||||
|
||||
function DetailsPanel({ draft, setDraft, pageId }: { draft: Draft; setDraft: (d: Draft) => void; pageId: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-bold tracking-[-0.02em] text-text-primary">Details</h2>
|
||||
<p className="mt-0.5 text-sm text-text-secondary">What visitors see at the top of the page.</p>
|
||||
</div>
|
||||
<Badge variant={draft.published ? "success" : "neutral"}>{draft.published ? "published" : "draft"}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 flex items-center justify-between gap-4 rounded border border-border bg-surface-2 px-3.5 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-text-primary">Published</p>
|
||||
<p className="mt-0.5 max-w-[52ch] text-xs text-text-tertiary">
|
||||
Anyone with the link can read this page. Unpublished pages return not found, so you can compose before announcing.
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={draft.published} onChange={(v) => setDraft({ ...draft, published: v })} label="Published" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Title</label>
|
||||
<input type="text" value={draft.title} onChange={(e) => setDraft({ ...draft, title: e.target.value })} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Page address</label>
|
||||
<input type="text" value={pageId} disabled className={`${inputClass} font-mono opacity-60`} />
|
||||
<p className="mt-1 text-xs text-text-tertiary">Fixed once created — the link is already out there.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.description}
|
||||
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Logo URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.logoUrl}
|
||||
onChange={(e) => setDraft({ ...draft, logoUrl: e.target.value })}
|
||||
placeholder="https://acme.example.com/logo.svg"
|
||||
className={`${inputClass} font-mono`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Notice</label>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.banner.text}
|
||||
onChange={(e) => setDraft({ ...draft, banner: { ...draft.banner, text: e.target.value, enabled: e.target.value.trim().length > 0 } })}
|
||||
placeholder="Europe region only. US and APAC are unaffected."
|
||||
className={inputClass}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-tertiary">Shown above everything else. Clear it to remove the notice.</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionEditor({
|
||||
section,
|
||||
monitors,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
section: StatusPageSection;
|
||||
monitors: Monitor[];
|
||||
onChange: (s: StatusPageSection) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const usedIds = new Set(section.entries.map((e) => e.monitor_id));
|
||||
const available = monitors.filter((m) => !usedIds.has(m.monitor_id));
|
||||
|
||||
return (
|
||||
<div className="rounded border border-border bg-surface-2">
|
||||
<div className="flex items-center gap-2.5 border-b border-border px-3 py-2.5">
|
||||
<input
|
||||
type="text"
|
||||
value={section.name}
|
||||
onChange={(e) => onChange({ ...section, name: e.target.value })}
|
||||
aria-label="Section name"
|
||||
placeholder="Section name"
|
||||
className={`${inputClass} max-w-[220px] bg-surface`}
|
||||
/>
|
||||
<button type="button" onClick={onRemove} className="ml-auto font-mono text-xs text-text-tertiary hover:text-danger">
|
||||
remove section
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{section.entries.map((entry, i) => {
|
||||
const monitor = monitors.find((m) => m.monitor_id === entry.monitor_id);
|
||||
return (
|
||||
<div
|
||||
key={entry.monitor_id}
|
||||
className="grid grid-cols-1 items-center gap-3 border-t border-border-soft px-3 py-2.5 sm:grid-cols-[1fr_1fr_auto]"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-text-primary">{monitor?.name ?? entry.monitor_id}</p>
|
||||
{monitor && <p className="mt-0.5 font-mono text-[11px] text-text-tertiary">{monitorMeta(monitor)}</p>}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={entry.display_name ?? ""}
|
||||
onChange={(e) => {
|
||||
const entries = [...section.entries];
|
||||
entries[i] = { ...entry, display_name: e.target.value };
|
||||
onChange({ ...section, entries });
|
||||
}}
|
||||
placeholder="Public name (required)"
|
||||
aria-label={`Public name for ${monitor?.name ?? entry.monitor_id}`}
|
||||
className={inputClass}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ ...section, entries: section.entries.filter((_, j) => j !== i) })}
|
||||
className="justify-self-start font-mono text-xs text-text-tertiary hover:text-danger sm:justify-self-auto"
|
||||
>
|
||||
remove
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="px-3 py-2.5">
|
||||
<select
|
||||
value=""
|
||||
disabled={available.length === 0}
|
||||
onChange={(e) => {
|
||||
const monitorId = e.target.value;
|
||||
if (!monitorId) return;
|
||||
onChange({ ...section, entries: [...section.entries, { monitor_id: monitorId, display_name: "" }] });
|
||||
}}
|
||||
className={`${inputClass} max-w-xs`}
|
||||
>
|
||||
<option value="">{available.length === 0 ? "All monitors added" : "Add monitor…"}</option>
|
||||
{available.map((m) => (
|
||||
<option key={m.monitor_id} value={m.monitor_id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComponentsPanel({
|
||||
draft,
|
||||
setDraft,
|
||||
monitors,
|
||||
}: {
|
||||
draft: Draft;
|
||||
setDraft: (d: Draft) => void;
|
||||
monitors: Monitor[];
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-bold tracking-[-0.02em] text-text-primary">Components</h2>
|
||||
<p className="mt-0.5 text-sm text-text-secondary">
|
||||
Monitors grouped for the public page. Grouping here is separate from the groups on Monitors.
|
||||
Each component needs a public name — monitor names are never published for you.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setDraft({ ...draft, sections: [...draft.sections, { name: "", entries: [] }] })}
|
||||
>
|
||||
Add section
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{draft.sections.length === 0 ? (
|
||||
<p className="rounded border border-dashed border-border px-4 py-6 text-center text-sm text-text-tertiary">
|
||||
No sections yet. Add one and choose the monitors it should show.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{draft.sections.map((section, i) => (
|
||||
<SectionEditor
|
||||
key={i}
|
||||
section={section}
|
||||
monitors={monitors}
|
||||
onChange={(s) => {
|
||||
const sections = [...draft.sections];
|
||||
sections[i] = s;
|
||||
setDraft({ ...draft, sections });
|
||||
}}
|
||||
onRemove={() => setDraft({ ...draft, sections: draft.sections.filter((_, j) => j !== i) })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Incidents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const INCIDENT_STATUSES = ["investigating", "identified", "monitoring", "resolved"] as const;
|
||||
const MAINTENANCE_STATUSES = ["scheduled", "in_progress", "completed"] as const;
|
||||
const IMPACTS = ["none", "minor", "major", "critical"] as const;
|
||||
|
||||
function statusVariant(status: string): "success" | "warning" | "danger" | "neutral" | "accent" {
|
||||
if (status === "resolved" || status === "completed") return "success";
|
||||
if (status === "monitoring" || status === "in_progress") return "warning";
|
||||
if (status === "scheduled") return "accent";
|
||||
if (status === "investigating" || status === "identified") return "danger";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function isOpenIncident(inc: StatusIncident): boolean {
|
||||
return inc.kind === "incident" && inc.status !== "resolved";
|
||||
}
|
||||
|
||||
function toLocalInput(iso?: string): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function fromLocalInput(s: string): string | undefined {
|
||||
if (!s) return undefined;
|
||||
const d = new Date(s);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
|
||||
}
|
||||
|
||||
function IncidentFormModal({
|
||||
pageId,
|
||||
kind,
|
||||
monitors,
|
||||
initial,
|
||||
onClose,
|
||||
}: {
|
||||
pageId: string;
|
||||
kind: "incident" | "maintenance";
|
||||
monitors: Monitor[];
|
||||
initial?: StatusIncident;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const statuses = kind === "incident" ? INCIDENT_STATUSES : MAINTENANCE_STATUSES;
|
||||
|
||||
const [title, setTitle] = useState(initial?.title ?? "");
|
||||
const [impact, setImpact] = useState(initial?.impact ?? "minor");
|
||||
const [status, setStatus] = useState(initial?.status ?? statuses[0]);
|
||||
const [affected, setAffected] = useState<string[]>(initial?.affected_monitors ?? []);
|
||||
const [scheduledStart, setScheduledStart] = useState(toLocalInput(initial?.scheduled_start));
|
||||
const [scheduledEnd, setScheduledEnd] = useState(toLocalInput(initial?.scheduled_end));
|
||||
|
||||
// Mirrors services.validateIncident's maintenance rule (server side is not
|
||||
// reachable client-side, so this is a duplicate that must stay in sync with
|
||||
// it): a maintenance window needs both timestamps, and the end must be
|
||||
// strictly after the start. Named per-rule so the message says which one
|
||||
// failed rather than a generic "invalid".
|
||||
const scheduleError =
|
||||
kind === "maintenance"
|
||||
? !scheduledStart
|
||||
? "A start time is required."
|
||||
: !scheduledEnd
|
||||
? "An end time is required."
|
||||
: new Date(scheduledEnd).getTime() <= new Date(scheduledStart).getTime()
|
||||
? "The end time must be after the start time."
|
||||
: null
|
||||
: null;
|
||||
|
||||
const {
|
||||
mutate: save,
|
||||
isPending,
|
||||
error,
|
||||
} = useMutation({
|
||||
mutationFn: () => {
|
||||
const body = {
|
||||
kind,
|
||||
title: title.trim(),
|
||||
impact,
|
||||
status,
|
||||
affected_monitors: affected,
|
||||
scheduled_start: kind === "maintenance" ? fromLocalInput(scheduledStart) : undefined,
|
||||
scheduled_end: kind === "maintenance" ? fromLocalInput(scheduledEnd) : undefined,
|
||||
// UpdateStatusIncident requires page_ids on the body -- it is not
|
||||
// merged with the existing document, so omitting it here fails
|
||||
// validateIncident's "at least one page is required" on every edit.
|
||||
...(initial ? { page_ids: initial.page_ids } : {}),
|
||||
};
|
||||
return initial ? api.updateStatusIncident(pageId, initial.incident_id, body) : api.createStatusIncident(pageId, body);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["status-pages", pageId, "incidents"] });
|
||||
toast.success(initial ? "Updated." : kind === "incident" ? "Incident opened." : "Maintenance scheduled.");
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
function toggleMonitor(id: string) {
|
||||
setAffected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={initial ? "Edit" : kind === "incident" ? "Open incident" : "Schedule maintenance"}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger" role="alert">
|
||||
{(error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Title</label>
|
||||
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Impact</label>
|
||||
<select value={impact} onChange={(e) => setImpact(e.target.value as (typeof IMPACTS)[number])} className={inputClass}>
|
||||
{IMPACTS.map((i) => (
|
||||
<option key={i} value={i}>
|
||||
{i}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Status</label>
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)} className={inputClass}>
|
||||
{statuses.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{kind === "maintenance" && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Starts</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={scheduledStart}
|
||||
onChange={(e) => setScheduledStart(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Ends</label>
|
||||
<input type="datetime-local" value={scheduledEnd} onChange={(e) => setScheduledEnd(e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
{scheduleError && <p className="col-span-2 text-xs text-danger">{scheduleError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Affected components</label>
|
||||
<div className="max-h-40 space-y-1 overflow-auto rounded border border-border p-2">
|
||||
{monitors.length === 0 && <p className="px-1 py-1 text-xs text-text-tertiary">No monitors yet.</p>}
|
||||
{monitors.map((m) => (
|
||||
<label key={m.monitor_id} className="flex items-center gap-2 rounded px-1 py-1 hover:bg-surface-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={affected.includes(m.monitor_id)}
|
||||
onChange={() => toggleMonitor(m.monitor_id)}
|
||||
className="h-4 w-4 accent-accent"
|
||||
/>
|
||||
<span className="text-sm text-text-primary">{m.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button variant="secondary" onClick={onClose} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" loading={isPending} disabled={!title.trim() || !!scheduleError} onClick={() => save()}>
|
||||
{initial ? "Save" : kind === "incident" ? "Open incident" : "Schedule maintenance"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function PostUpdateModal({ pageId, incident, onClose }: { pageId: string; incident: StatusIncident; onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [status, setStatus] = useState<string>(incident.status === "investigating" ? "identified" : "monitoring");
|
||||
const [body, setBody] = useState("");
|
||||
|
||||
const {
|
||||
mutate: post,
|
||||
isPending,
|
||||
error,
|
||||
} = useMutation({
|
||||
mutationFn: () => api.postStatusIncidentUpdate(pageId, incident.incident_id, status, body.trim()),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["status-pages", pageId, "incidents"] });
|
||||
toast.success("Update posted.");
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal open title="Post update" onClose={onClose}>
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger" role="alert">
|
||||
{(error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Status</label>
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)} className={inputClass}>
|
||||
{INCIDENT_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Update</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="What changed since the last update."
|
||||
className={`${inputClass} resize-none`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button variant="secondary" onClick={onClose} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" loading={isPending} disabled={!body.trim()} onClick={() => post()}>
|
||||
Post update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function incidentMeta(inc: StatusIncident): string {
|
||||
if (inc.kind === "maintenance" && inc.scheduled_start) {
|
||||
const start = new Date(inc.scheduled_start);
|
||||
const end = inc.scheduled_end ? new Date(inc.scheduled_end) : null;
|
||||
// Explicitly UTC, matching the label: toLocale*(undefined, ...) renders
|
||||
// in the viewer's own zone, which made the hardcoded "UTC" suffix wrong
|
||||
// for anyone not on it (a London summer viewer read 01:00-03:00 UTC as
|
||||
// "02:00-04:00 UTC"). timeZone: "UTC" keeps the numbers honest instead
|
||||
// of dropping the label.
|
||||
const date = start.toLocaleDateString(undefined, { day: "numeric", month: "short", timeZone: "UTC" });
|
||||
const startTime = start.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", timeZone: "UTC" });
|
||||
const endTime = end ? end.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", timeZone: "UTC" }) : null;
|
||||
return `${date}, ${startTime}${endTime ? `–${endTime}` : ""} UTC`;
|
||||
}
|
||||
if (inc.status === "resolved" && inc.resolved_at) {
|
||||
return `${relativeTime(inc.started_at)} · resolved ${relativeTime(inc.resolved_at)}`;
|
||||
}
|
||||
return `Opened ${relativeTime(inc.started_at)} · ${inc.updates?.length ?? 0} update${inc.updates?.length === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function DeleteIncidentButton({ pageId, incident }: { pageId: string; incident: StatusIncident }) {
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { mutate, isPending, error } = useMutation({
|
||||
mutationFn: () => api.deleteStatusIncident(pageId, incident.incident_id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["status-pages", pageId, "incidents"] });
|
||||
setOpen(false);
|
||||
toast.success("Incident deleted.");
|
||||
},
|
||||
// The dialog stays open and shows the failure, as the monitor delete does.
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="font-mono text-xs text-text-tertiary hover:text-danger"
|
||||
>
|
||||
delete
|
||||
</button>
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
title="Delete incident"
|
||||
confirmLabel="Delete"
|
||||
loading={isPending}
|
||||
error={error ? friendlyMessage(error) : null}
|
||||
onClose={() => setOpen(false)}
|
||||
onConfirm={() => mutate()}
|
||||
body={
|
||||
<>
|
||||
<p>
|
||||
<span className="font-mono text-text-primary">{incident.title}</span> is removed from every
|
||||
page it was published to, along with its updates.
|
||||
</p>
|
||||
<p>
|
||||
To leave the record standing but close it out, mark it resolved instead — the public page
|
||||
files a resolved incident under its history.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentsPanel({ pageId, monitors }: { pageId: string; monitors: Monitor[] }) {
|
||||
const {
|
||||
data: incidents,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["status-pages", pageId, "incidents"],
|
||||
queryFn: () => api.listStatusIncidents(pageId),
|
||||
});
|
||||
|
||||
const [openForm, setOpenForm] = useState<"incident" | "maintenance" | null>(null);
|
||||
const [editing, setEditing] = useState<StatusIncident | null>(null);
|
||||
const [posting, setPosting] = useState<StatusIncident | null>(null);
|
||||
|
||||
const monitorName = useMemo(() => {
|
||||
const m = new Map(monitors.map((mon) => [mon.monitor_id, mon.name]));
|
||||
return (id: string) => m.get(id) ?? id;
|
||||
}, [monitors]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{(openForm || editing) && (
|
||||
<IncidentFormModal
|
||||
pageId={pageId}
|
||||
kind={editing?.kind ?? openForm ?? "incident"}
|
||||
monitors={monitors}
|
||||
initial={editing ?? undefined}
|
||||
onClose={() => {
|
||||
setOpenForm(null);
|
||||
setEditing(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{posting && <PostUpdateModal pageId={pageId} incident={posting} onClose={() => setPosting(null)} />}
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-bold tracking-[-0.02em] text-text-primary">Incidents</h2>
|
||||
<p className="mt-0.5 text-sm text-text-secondary">Written by you. Outages Vantage detects appear on the page automatically.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => setOpenForm("maintenance")}>
|
||||
Schedule maintenance
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={() => setOpenForm("incident")}>
|
||||
Open incident
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AsyncBoundary
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onRetry={refetch}
|
||||
skeleton={<CenteredSpinner />}
|
||||
isEmpty={!incidents || incidents.length === 0}
|
||||
empty={<p className="py-8 text-center text-sm text-text-tertiary">No incidents or maintenance windows yet.</p>}
|
||||
>
|
||||
<div className="divide-y divide-border-soft">
|
||||
{incidents?.map((inc) => (
|
||||
<div key={inc.incident_id} className="flex items-start justify-between gap-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-text-primary">{inc.title}</p>
|
||||
<p className="mt-0.5 text-xs text-text-tertiary">
|
||||
{incidentMeta(inc)}
|
||||
{inc.affected_monitors && inc.affected_monitors.length > 0 && (
|
||||
<> · affects {inc.affected_monitors.map(monitorName).join(", ")}</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-2.5">
|
||||
<Badge variant={statusVariant(inc.status)}>{inc.status.replace("_", " ")}</Badge>
|
||||
{isOpenIncident(inc) ? (
|
||||
<Button variant="secondary" size="sm" onClick={() => setPosting(inc)}>
|
||||
Post update
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="secondary" size="sm" onClick={() => setEditing(inc)}>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
<DeleteIncidentButton pageId={pageId} incident={inc} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AsyncBoundary>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function StatusPageEditorPage() {
|
||||
const params = useParams();
|
||||
const pageId = params.pageId as string;
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const {
|
||||
data: page,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["status-pages", pageId],
|
||||
queryFn: () => api.getStatusPage(pageId),
|
||||
});
|
||||
|
||||
const { data: monitors } = useQuery({
|
||||
queryKey: ["monitors"],
|
||||
queryFn: () => api.listMonitors(),
|
||||
});
|
||||
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
|
||||
// Seeded once the page loads. Refetches after that (e.g. after Save)
|
||||
// must not stomp on in-progress edits, so this only runs while draft is
|
||||
// still unset.
|
||||
useEffect(() => {
|
||||
if (page && !draft) setDraft(draftFromPage(page));
|
||||
}, [page, draft]);
|
||||
|
||||
const unnamedIndex = draft ? unnamedSectionIndex(draft) : -1;
|
||||
const unnamed = draft ? unnamedComponent(draft) : null;
|
||||
const sectionError =
|
||||
unnamedIndex >= 0
|
||||
? `Section ${unnamedIndex + 1} needs a name before this can be saved.`
|
||||
: unnamed
|
||||
? `Component ${unnamed.entry + 1} in section ${unnamed.section + 1} needs a public name before this can be saved. A blank name publishes the monitor's id, not its name.`
|
||||
: null;
|
||||
|
||||
const {
|
||||
mutate: save,
|
||||
isPending: isSaving,
|
||||
error: saveError,
|
||||
} = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!draft) throw new Error("nothing to save");
|
||||
if (unnamedSectionIndex(draft) >= 0 || unnamedComponent(draft)) {
|
||||
throw new Error(sectionError ?? "A section and every component needs a name.");
|
||||
}
|
||||
return api.updateStatusPage(pageId, draftToInput(draft));
|
||||
},
|
||||
onSuccess: (updated) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["status-pages"] });
|
||||
queryClient.setQueryData(["status-pages", pageId], updated);
|
||||
setDraft(draftFromPage(updated));
|
||||
toast.success("Saved.");
|
||||
},
|
||||
});
|
||||
|
||||
/*
|
||||
* Delete is the only correction for a typo'd page address: the address is
|
||||
* immutable by design, because it is a URL handed to customers. Typed, like
|
||||
* the monitor and secret-group deletes, and for the same reason — the page,
|
||||
* its components and its authored incidents go together and there is
|
||||
* nothing to restore them from.
|
||||
*/
|
||||
const {
|
||||
mutate: deletePage,
|
||||
isPending: isDeleting,
|
||||
error: deleteError,
|
||||
} = useMutation({
|
||||
mutationFn: () => api.deleteStatusPage(pageId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["status-pages"] });
|
||||
toast.success(`Deleted ${page?.title ?? "the status page"}.`);
|
||||
router.push("/status-pages");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<Link href="/status-pages" className="mb-2.5 inline-block text-sm text-text-secondary hover:text-text-primary">
|
||||
← All status pages
|
||||
</Link>
|
||||
|
||||
{/* error is checked before the draft-seeding gap below, matching the
|
||||
AsyncBoundary pattern the list page and IncidentsPanel already use --
|
||||
a 404, a role/licence refusal or a network failure gets a message and
|
||||
a retry instead of an endless spinner. */}
|
||||
<AsyncBoundary isLoading={isLoading} error={error} onRetry={() => refetch()}>
|
||||
{!page || !draft ? (
|
||||
<CenteredSpinner />
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-text-primary">{page.title}</h1>
|
||||
<PublicUrlLine pageId={pageId} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button href={`/status/${pageId}`} variant="secondary" target="_blank" rel="noreferrer">
|
||||
View page
|
||||
</Button>
|
||||
<Button variant="primary" loading={isSaving} disabled={!!sectionError} onClick={() => save()}>
|
||||
Save changes
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
|
||||
Delete page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
title="Delete status page"
|
||||
requireTyped={pageId}
|
||||
loading={isDeleting}
|
||||
error={deleteError ? friendlyMessage(deleteError) : null}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
onConfirm={() => deletePage()}
|
||||
body={
|
||||
<>
|
||||
<p>
|
||||
<span className="font-mono text-text-primary">{publicUrl(pageId)}</span> stops
|
||||
resolving, and the page's sections and authored incidents go with it.
|
||||
Monitors and their history are untouched.
|
||||
</p>
|
||||
<p>
|
||||
To take it off the internet without losing the work, un-publish it in Details
|
||||
instead.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{sectionError && (
|
||||
<div className="mb-5 rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger" role="alert">
|
||||
{sectionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
<div className="mb-5 rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger" role="alert">
|
||||
{(saveError as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-5">
|
||||
<DetailsPanel draft={draft} setDraft={setDraft} pageId={pageId} />
|
||||
<ComponentsPanel draft={draft} setDraft={setDraft} monitors={monitors ?? []} />
|
||||
<IncidentsPanel pageId={pageId} monitors={monitors ?? []} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AsyncBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PublicUrlLine({ pageId }: { pageId: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const url = publicUrl(pageId);
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<code className="rounded border border-border bg-well px-2 py-1 font-mono text-xs text-text-secondary">
|
||||
{url.replace(/^https?:\/\//, "")}
|
||||
</code>
|
||||
<button type="button" onClick={copy} className="font-mono text-xs text-text-tertiary hover:text-accent">
|
||||
{copied ? "copied" : "copy"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api, StatusPage } from "@/lib/api";
|
||||
import {
|
||||
AsyncBoundary,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
Modal,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableSkeleton,
|
||||
useToast,
|
||||
} from "@/components/ui";
|
||||
|
||||
// Same rule as services.ValidatePageID on the server. Checked here purely so
|
||||
// a typo is a red field rather than a round trip that comes back 400.
|
||||
const PAGE_ID_RE = /^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$/;
|
||||
|
||||
function componentCount(page: StatusPage): number {
|
||||
return page.sections.reduce((sum, s) => sum + s.entries.length, 0);
|
||||
}
|
||||
|
||||
function publicUrl(pageId: string): string {
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
return `${origin}/status/${pageId}`;
|
||||
}
|
||||
|
||||
function CopyLink({ pageId }: { pageId: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const url = publicUrl(pageId);
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="max-w-full truncate rounded border border-border bg-well px-2 py-1 font-mono text-[11px] text-text-tertiary transition-colors hover:text-accent"
|
||||
title={url}
|
||||
>
|
||||
{copied ? "Copied!" : url.replace(/^https?:\/\//, "")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateStatusPageModal({ onClose }: { onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const router = useRouter();
|
||||
const [pageId, setPageId] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [logoUrl, setLogoUrl] = useState("");
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
const idValid = PAGE_ID_RE.test(pageId);
|
||||
|
||||
const {
|
||||
mutate: create,
|
||||
isPending,
|
||||
error,
|
||||
} = useMutation({
|
||||
mutationFn: () =>
|
||||
api.createStatusPage({
|
||||
page_id: pageId,
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
logo_url: logoUrl.trim() || undefined,
|
||||
}),
|
||||
onSuccess: (page) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["status-pages"] });
|
||||
toast.success(`Created ${page.title}. Add sections and publish when it is ready.`);
|
||||
router.push(`/status-pages/${page.page_id}`);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal open title="New status page" onClose={onClose}>
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger" role="alert">
|
||||
{(error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Page address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={pageId}
|
||||
onChange={(e) => setPageId(e.target.value.toLowerCase())}
|
||||
onBlur={() => setTouched(true)}
|
||||
placeholder="api"
|
||||
className="w-full rounded border border-border bg-surface-2 px-3 py-2 font-mono text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-tertiary">
|
||||
{`Becomes ${publicUrl(pageId || "<address>")}. Fixed once created — lowercase letters, numbers and hyphens, 3-40 characters.`}
|
||||
</p>
|
||||
{touched && pageId.length > 0 && !idValid && <p className="mt-1 text-xs text-danger">Not a valid page address.</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Acme Platform Status"
|
||||
className="w-full rounded border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Live availability for the Acme API and dashboard."
|
||||
className="w-full rounded border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Logo URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={logoUrl}
|
||||
onChange={(e) => setLogoUrl(e.target.value)}
|
||||
placeholder="https://acme.example.com/logo.svg"
|
||||
className="w-full rounded border border-border bg-surface-2 px-3 py-2 font-mono text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button variant="secondary" onClick={onClose} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" loading={isPending} disabled={!idValid || !title.trim()} onClick={() => create()}>
|
||||
Create page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StatusPagesListPage() {
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const {
|
||||
data: pages,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["status-pages"],
|
||||
queryFn: () => api.listStatusPages(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
{showCreate && <CreateStatusPageModal onClose={() => setShowCreate(false)} />}
|
||||
|
||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">Status Pages</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
{pages?.length ?? 0} page{pages?.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => setShowCreate(true)}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
New status page
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card padding={false}>
|
||||
<AsyncBoundary
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onRetry={refetch}
|
||||
skeleton={<TableSkeleton columns={4} />}
|
||||
isEmpty={!pages || pages.length === 0}
|
||||
empty={
|
||||
<EmptyState
|
||||
title="No status pages yet."
|
||||
description="Create a page, group monitors into sections, and publish it to give customers and partners a place to check availability."
|
||||
icon={
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<rect x="3" y="4.5" width="18" height="15" rx="2.25" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 15.75v-3M12 15.75v-6M17.25 15.75v-4.5" />
|
||||
</svg>
|
||||
}
|
||||
action={{ label: "Create your first page", onClick: () => setShowCreate(true) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Title</Th>
|
||||
<Th>Address</Th>
|
||||
<Th>Components</Th>
|
||||
<Th>Status</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{pages?.map((page) => (
|
||||
<Tr key={page.page_id}>
|
||||
<Td label="Title">
|
||||
<span className="font-medium text-text-primary">{page.title}</span>
|
||||
</Td>
|
||||
<Td label="Address">
|
||||
<CopyLink pageId={page.page_id} />
|
||||
</Td>
|
||||
<Td label="Components">
|
||||
<span className="text-text-secondary">{componentCount(page)}</span>
|
||||
</Td>
|
||||
<Td label="Status">
|
||||
<Badge variant={page.published ? "success" : "neutral"}>{page.published ? "Published" : "Draft"}</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Button href={`/status-pages/${page.page_id}`} variant="ghost" size="sm">
|
||||
Edit <span aria-hidden="true">→</span>
|
||||
<span className="sr-only">{page.title}</span>
|
||||
</Button>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</AsyncBoundary>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { StatusSnapshot } from "@/lib/api";
|
||||
import ComponentRow from "@/components/status/ComponentRow";
|
||||
import IncidentCard from "@/components/status/IncidentCard";
|
||||
|
||||
const OVERALL_COPY: Record<string, string> = {
|
||||
up: "All systems operational",
|
||||
degraded: "Partially degraded service",
|
||||
maintenance: "Under maintenance",
|
||||
down: "Service disruption",
|
||||
};
|
||||
|
||||
// Every state carries a word as well as a colour: the page must be readable
|
||||
// without relying on hue. The banner also carries a distinct glyph per state
|
||||
// (below), matching the approved mockup's shape requirement.
|
||||
const OVERALL_TONE: Record<string, string> = {
|
||||
up: "bg-success/10 text-success border-success/30",
|
||||
degraded: "bg-warning/10 text-warning border-warning/30",
|
||||
maintenance: "bg-accent/10 text-accent border-accent/30",
|
||||
down: "bg-danger/10 text-danger border-danger/30",
|
||||
};
|
||||
|
||||
function OverallGlyph({ state }: { state: string }) {
|
||||
// A distinct shape per state, not just a distinct colour: a filled
|
||||
// check for up, a wrench-like circle for maintenance, and an
|
||||
// exclamation mark (upright for degraded, in a filled ring for down)
|
||||
// otherwise.
|
||||
if (state === "up") {
|
||||
return (
|
||||
<svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="6.4" />
|
||||
<path d="M5.2 8.2l2 2 3.6-4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (state === "maintenance") {
|
||||
return (
|
||||
<svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="6.4" />
|
||||
<path d="M6 6l4 4M10 6l-4 4" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="6.4" />
|
||||
<path d="M8 4.8v3.6M8 11.1h.01" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StatusPageView({
|
||||
pageId,
|
||||
initial,
|
||||
}: {
|
||||
pageId: string;
|
||||
initial: StatusSnapshot;
|
||||
}) {
|
||||
const [snap, setSnap] = useState(initial);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(`/public/status/${encodeURIComponent(pageId)}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (res.ok) setSnap(await res.json());
|
||||
} catch {
|
||||
// A failed refresh leaves the last good snapshot on screen.
|
||||
// A status page that blanks itself when the network hiccups is
|
||||
// worse than one showing data 60 seconds old.
|
||||
}
|
||||
}, 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, [pageId]);
|
||||
|
||||
if (!snap.available) {
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-6 py-24 text-center">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">{snap.title || "Status"}</h1>
|
||||
<p className="mt-4 text-text-secondary">
|
||||
{snap.reason === "licence_inactive"
|
||||
? "This status page is temporarily unavailable."
|
||||
: "Status pages are not enabled on this instance."}
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-6 py-12">
|
||||
<header className="mb-8 flex items-center gap-4">
|
||||
{snap.logo_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={snap.logo_url} alt="" className="h-10 w-auto" />
|
||||
) : null}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-text-primary">{snap.title}</h1>
|
||||
{snap.description ? (
|
||||
<p className="mt-1 text-sm text-text-secondary">{snap.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className={`mb-6 flex items-center gap-3 rounded-lg border px-4 py-3 text-sm font-medium ${
|
||||
OVERALL_TONE[snap.overall] ?? OVERALL_TONE.degraded
|
||||
}`}
|
||||
>
|
||||
<OverallGlyph state={snap.overall} />
|
||||
{OVERALL_COPY[snap.overall] ?? "Status unknown"}
|
||||
</div>
|
||||
|
||||
{snap.banner ? (
|
||||
<div className="mb-8 rounded-lg border border-accent/30 bg-accent/10 px-4 py-3 text-sm text-text-primary">
|
||||
{snap.banner.text}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{snap.active_incidents.length > 0 ? (
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-3 font-mono text-xs uppercase tracking-wider text-text-secondary">
|
||||
Active
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{snap.active_incidents.map((i) => (
|
||||
<IncidentCard key={i.id} incident={i} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{snap.upcoming_maintenance.length > 0 ? (
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-3 font-mono text-xs uppercase tracking-wider text-text-secondary">
|
||||
Scheduled maintenance
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{snap.upcoming_maintenance.map((i) => (
|
||||
<IncidentCard key={i.id} incident={i} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{snap.sections.map((section) => (
|
||||
<section key={section.name} className="mb-8">
|
||||
<h2 className="mb-3 font-mono text-xs uppercase tracking-wider text-text-secondary">
|
||||
{section.name}
|
||||
</h2>
|
||||
{section.components.length > 0 ? (
|
||||
<div className="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||
{section.components.map((c) => (
|
||||
<ComponentRow key={c.name} component={c} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface px-4 py-4 text-sm text-text-secondary">
|
||||
No components in this section.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{snap.history.length > 0 ? (
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-3 font-mono text-xs uppercase tracking-wider text-text-secondary">
|
||||
Past incidents
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{snap.history.map((i) => (
|
||||
<IncidentCard key={i.id} incident={i} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<footer className="mt-12 text-center text-xs text-text-secondary">
|
||||
Updated {new Date(snap.generated_at).toLocaleString()} · refreshes every 60 seconds
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import StatusPageView from "./StatusPageView";
|
||||
import type { StatusSnapshot } from "@/lib/api";
|
||||
|
||||
// Deliberately outside the (app) route group: no sidebar, no session fetch, no
|
||||
// auth redirect. This page is served to the public.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type FetchResult =
|
||||
| { kind: "ok"; snapshot: StatusSnapshot }
|
||||
| { kind: "not-found" }
|
||||
| { kind: "unavailable" };
|
||||
|
||||
/*
|
||||
* The control plane this call is made to is the visitor's own host.
|
||||
*
|
||||
* This app proxies nothing and holds no address for the server (see
|
||||
* next.config.ts): /public is routed to it by the reverse proxy, exactly as
|
||||
* /api and /auth are for the browser. So the SSR fetch goes back through that
|
||||
* proxy at <slug>.vantage.<tld>, which is a per-tenant address by
|
||||
* construction — and X-Forwarded-Host below is still what selects the tenant,
|
||||
* because the hop from this process cannot set Host.
|
||||
*/
|
||||
function apiBase(proto: string, host: string): string {
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
async function fetchSnapshot(base: string, host: string, forwardedFor: string, pageId: string): Promise<FetchResult> {
|
||||
|
||||
// The instance is resolved server-side from the visitor's host, so it has
|
||||
// to be forwarded explicitly — this is a server-to-server call and its own
|
||||
// Host names the Go service.
|
||||
//
|
||||
// It goes in X-Forwarded-Host and NOT in Host: `Host` is a forbidden header
|
||||
// name, and undici (the fetch behind Node) discards it silently. Setting it
|
||||
// looked like it worked and delivered `host: <api-host>` upstream, so every
|
||||
// status page resolved no instance and 404'd.
|
||||
const outbound: Record<string, string> = { "X-Forwarded-Host": host };
|
||||
|
||||
// Likewise the visitor's own address. Without it the Go server sees a
|
||||
// request from this pod with no XFF and rate limits every visitor of every
|
||||
// page into one 120/min bucket — tripped by exactly the traffic an outage
|
||||
// produces. Appending rather than replacing keeps the chain in front of us
|
||||
// intact.
|
||||
if (forwardedFor) outbound["X-Forwarded-For"] = forwardedFor;
|
||||
|
||||
const url = `${base}/public/status/${encodeURIComponent(pageId)}`;
|
||||
// This call is container-to-container and never appears in the front
|
||||
// proxy's access log, which is why a 404 here reads as "Next 404'd it".
|
||||
// Log both halves so the upstream status is visible in the web logs.
|
||||
console.log(`[status] GET ${url} host=${host} xff=${forwardedFor || "-"}`);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
headers: outbound,
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch (e) {
|
||||
// The control plane is unreachable. That is not "no such page".
|
||||
console.error(`[status] upstream unreachable ${url}:`, e);
|
||||
return { kind: "unavailable" };
|
||||
}
|
||||
|
||||
console.log(`[status] upstream ${res.status} for ${url}`);
|
||||
if (res.status === 404) return { kind: "not-found" };
|
||||
// A 429, a 500 or anything else is a page that exists and cannot be read
|
||||
// right now. Telling a customer mid-outage that their status page does not
|
||||
// exist is the worst available answer.
|
||||
if (!res.ok) return { kind: "unavailable" };
|
||||
|
||||
try {
|
||||
return { kind: "ok", snapshot: (await res.json()) as StatusSnapshot };
|
||||
} catch {
|
||||
return { kind: "unavailable" };
|
||||
}
|
||||
}
|
||||
|
||||
// The shell StatusPageView already renders for available:false, reused rather
|
||||
// than written a second time so there is one unavailable page, not two.
|
||||
function unavailableSnapshot(): StatusSnapshot {
|
||||
return {
|
||||
available: false,
|
||||
reason: "licence_inactive",
|
||||
title: "Status",
|
||||
overall: "no_data",
|
||||
sections: [],
|
||||
active_incidents: [],
|
||||
upcoming_maintenance: [],
|
||||
history: [],
|
||||
generated_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PublicStatusPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ pageId: string }>;
|
||||
}) {
|
||||
const { pageId } = await params;
|
||||
const h = await headers();
|
||||
const host = h.get("x-forwarded-host") ?? h.get("host") ?? "";
|
||||
|
||||
const inboundFor = h.get("x-forwarded-for");
|
||||
const peer = h.get("x-real-ip");
|
||||
const forwardedFor = [inboundFor, inboundFor ? null : peer]
|
||||
.filter((v): v is string => !!v)
|
||||
.join(", ");
|
||||
|
||||
const proto = h.get("x-forwarded-proto")?.split(",")[0].trim() || "https";
|
||||
const result = await fetchSnapshot(apiBase(proto, host), host, forwardedFor, pageId);
|
||||
if (result.kind === "not-found") notFound();
|
||||
|
||||
return (
|
||||
<StatusPageView
|
||||
pageId={pageId}
|
||||
initial={result.kind === "ok" ? result.snapshot : unavailableSnapshot()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ pageId: string }> }) {
|
||||
const { pageId } = await params;
|
||||
return { title: `Status — ${pageId}` };
|
||||
}
|
||||
@@ -119,6 +119,15 @@ function MonitorIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<rect x="3" y="4.5" width="18" height="15" rx="2.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 15.75v-3M12 15.75v-6M17.25 15.75v-4.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StepsIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
@@ -198,6 +207,7 @@ const navGroups: NavGroup[] = [
|
||||
{
|
||||
label: "Instance",
|
||||
items: [
|
||||
{ href: "/status-pages", label: "Status Pages", icon: <StatusIcon />, adminOnly: true },
|
||||
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
|
||||
{ href: "/settings/license", label: "Licence", icon: <LicenceIcon />, adminOnly: true },
|
||||
{ href: "/settings", label: "Settings", icon: <SettingsIcon />, adminOnly: true },
|
||||
|
||||
@@ -91,6 +91,7 @@ export function MonitorForm({
|
||||
error?: Error | null;
|
||||
}) {
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [group, setGroup] = useState(initial?.group ?? "");
|
||||
const [type, setType] = useState<MonitorType>(initial?.type ?? "http");
|
||||
const [url, setUrl] = useState(initial?.target.url ?? "");
|
||||
const [host, setHost] = useState(initial?.target.host ?? "");
|
||||
@@ -107,6 +108,11 @@ export function MonitorForm({
|
||||
const [channelIds, setChannelIds] = useState<string[]>(initial?.channel_ids ?? []);
|
||||
|
||||
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
|
||||
/* The group is free text, so the existing groups are offered as suggestions
|
||||
rather than a fixed list — grouping is a label people invent, and a
|
||||
select would mean adding one before it could be used. */
|
||||
const { data: allMonitors } = useQuery({ queryKey: ["monitors"], queryFn: () => api.listMonitors() });
|
||||
const knownGroups = Array.from(new Set((allMonitors ?? []).map((m) => m.group).filter((g): g is string => !!g))).sort();
|
||||
const { data: channels } = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
@@ -128,7 +134,7 @@ export function MonitorForm({
|
||||
target.host = host;
|
||||
target.port = port;
|
||||
}
|
||||
onSubmit({ name, type, target, interval_sec: intervalSec, retries, runner, enabled, channel_ids: channelIds });
|
||||
onSubmit({ name, group: group.trim(), type, target, interval_sec: intervalSec, retries, runner, enabled, channel_ids: channelIds });
|
||||
}
|
||||
|
||||
const runnerName = runner === "server" ? "the control plane" : servers?.find((s) => s.server_id === runner)?.hostname || "an agent";
|
||||
@@ -138,193 +144,214 @@ export function MonitorForm({
|
||||
const downAfter = formatDuration(new Date(Date.now() - intervalSec * Math.max(retries, 1) * 1000).toISOString());
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex max-w-3xl flex-col gap-5">
|
||||
<Section title="Check" hint={typeCopy[type].target}>
|
||||
<Field label="Name" help="Shown in the fleet list and in every alert this check sends.">
|
||||
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="Billing API" required />
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium text-text-secondary">Kind</span>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{(Object.keys(typeCopy) as MonitorType[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setType(t)}
|
||||
aria-pressed={type === t}
|
||||
className={`rounded-lg border px-3 py-2.5 text-left transition-colors ${
|
||||
type === t
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-accent/40"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`block font-mono text-[11px] uppercase tracking-[0.1em] ${type === t ? "text-accent" : "text-text-secondary"}`}
|
||||
>
|
||||
{typeCopy[t].title}
|
||||
</span>
|
||||
<span className="mt-1 block text-[11px] leading-snug text-text-tertiary">{typeCopy[t].blurb}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{type === "http" && (
|
||||
<>
|
||||
<Field label="URL">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
||||
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(320px,400px)]">
|
||||
<Section title="Check" hint={typeCopy[type].target}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
|
||||
<Field label="Name" help="Shown in the fleet list and in every alert this check sends.">
|
||||
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="Billing API" required />
|
||||
</Field>
|
||||
<Field label="Group" help="Optional heading on the monitors page. Nothing else reads it.">
|
||||
<input
|
||||
className={`${inputClass} font-mono`}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com/healthz"
|
||||
required
|
||||
className={inputClass}
|
||||
value={group}
|
||||
onChange={(e) => setGroup(e.target.value)}
|
||||
placeholder="Production"
|
||||
list="monitor-groups"
|
||||
maxLength={48}
|
||||
/>
|
||||
<datalist id="monitor-groups">
|
||||
{knownGroups.map((g) => (
|
||||
<option key={g} value={g} />
|
||||
))}
|
||||
</datalist>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium text-text-secondary">Kind</span>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{(Object.keys(typeCopy) as MonitorType[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setType(t)}
|
||||
aria-pressed={type === t}
|
||||
className={`rounded-lg border px-3 py-2.5 text-left transition-colors ${
|
||||
type === t
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-accent/40"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`block font-mono text-[11px] uppercase tracking-[0.1em] ${type === t ? "text-accent" : "text-text-secondary"}`}
|
||||
>
|
||||
{typeCopy[t].title}
|
||||
</span>
|
||||
<span className="mt-1 block text-[11px] leading-snug text-text-tertiary">{typeCopy[t].blurb}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{type === "http" && (
|
||||
<>
|
||||
<Field label="URL">
|
||||
<input
|
||||
className={`${inputClass} font-mono`}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com/healthz"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Method">
|
||||
<select className={inputClass} value={method} onChange={(e) => setMethod(e.target.value)}>
|
||||
<option>GET</option>
|
||||
<option>HEAD</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Expected status">
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputClass} font-mono tabular-nums`}
|
||||
value={expectedStatus}
|
||||
onChange={(e) => setExpectedStatus(Number(e.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Body must contain" help="Optional. The check fails if the response body is missing this text.">
|
||||
<input className={inputClass} value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="ok" />
|
||||
</Field>
|
||||
<Check
|
||||
checked={insecure}
|
||||
onChange={setInsecure}
|
||||
title="Accept any certificate"
|
||||
detail="Use for self-signed or expired certificates. The check stops reporting TLS problems."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(type === "tcp" || type === "tls" || type === "icmp") && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Host">
|
||||
<input
|
||||
className={`${inputClass} font-mono`}
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
placeholder="example.com"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
{type !== "icmp" && (
|
||||
<Field label="Port">
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputClass} font-mono tabular-nums`}
|
||||
value={port}
|
||||
onChange={(e) => setPort(Number(e.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === "tls" && (
|
||||
<Field label="Warn this many days before expiry">
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputClass} font-mono tabular-nums`}
|
||||
value={tlsWarnDays}
|
||||
onChange={(e) => setTlsWarnDays(Number(e.target.value))}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<Section title="Schedule">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Method">
|
||||
<select className={inputClass} value={method} onChange={(e) => setMethod(e.target.value)}>
|
||||
<option>GET</option>
|
||||
<option>HEAD</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Expected status">
|
||||
<Field label="Run every" help="Seconds between checks. Minimum 10.">
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputClass} font-mono tabular-nums`}
|
||||
value={expectedStatus}
|
||||
onChange={(e) => setExpectedStatus(Number(e.target.value))}
|
||||
value={intervalSec}
|
||||
onChange={(e) => setIntervalSec(Number(e.target.value))}
|
||||
min={10}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Fails after" help="Consecutive failures before an incident opens.">
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputClass} font-mono tabular-nums`}
|
||||
value={retries}
|
||||
onChange={(e) => setRetries(Number(e.target.value))}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Body must contain" help="Optional. The check fails if the response body is missing this text.">
|
||||
<input className={inputClass} value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="ok" />
|
||||
</Field>
|
||||
<Check
|
||||
checked={insecure}
|
||||
onChange={setInsecure}
|
||||
title="Accept any certificate"
|
||||
detail="Use for self-signed or expired certificates. The check stops reporting TLS problems."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(type === "tcp" || type === "tls" || type === "icmp") && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Host">
|
||||
<input
|
||||
className={`${inputClass} font-mono`}
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
placeholder="example.com"
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label="Runs from"
|
||||
help="Pick an agent for anything only reachable from inside that network. Everything else runs centrally."
|
||||
>
|
||||
<select className={inputClass} value={runner} onChange={(e) => setRunner(e.target.value)}>
|
||||
<option value="server">Control plane</option>
|
||||
{servers?.map((s) => (
|
||||
<option key={s.server_id} value={s.server_id}>
|
||||
Agent · {s.hostname}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{type !== "icmp" && (
|
||||
<Field label="Port">
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputClass} font-mono tabular-nums`}
|
||||
value={port}
|
||||
onChange={(e) => setPort(Number(e.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<p className="rounded-lg bg-well px-4 py-3 font-mono text-[11.5px] leading-relaxed text-text-secondary">
|
||||
Checked every {intervalSec} s from {runnerName}. Reported down after {retries}{" "}
|
||||
{retries === 1 ? "failure" : "consecutive failures"} — roughly {downAfter}.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Alerts" hint={channelIds.length > 0 ? `${channelIds.length} selected` : undefined}>
|
||||
{!channels || channels.length === 0 ? (
|
||||
<p className="text-sm text-text-secondary">
|
||||
No channels exist yet, so nobody will be told when this check fails.{" "}
|
||||
<Link href="/settings/notifications" className="text-accent hover:underline">
|
||||
Add a channel
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{channels.map((ch) => (
|
||||
<Check
|
||||
key={ch.channel_id}
|
||||
checked={channelIds.includes(ch.channel_id)}
|
||||
onChange={(v) =>
|
||||
setChannelIds((prev) => (v ? [...prev, ch.channel_id] : prev.filter((id) => id !== ch.channel_id)))
|
||||
}
|
||||
title={ch.name}
|
||||
detail={
|
||||
<span className="font-mono uppercase tracking-[0.1em]">
|
||||
{ch.type}
|
||||
{!ch.enabled && " · disabled, sends nothing"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === "tls" && (
|
||||
<Field label="Warn this many days before expiry">
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputClass} font-mono tabular-nums`}
|
||||
value={tlsWarnDays}
|
||||
onChange={(e) => setTlsWarnDays(Number(e.target.value))}
|
||||
min={1}
|
||||
<Check
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
title="Start checking straight away"
|
||||
detail="Turn this off to save the monitor without running it. You can resume it any time."
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Schedule">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Run every" help="Seconds between checks. Minimum 10.">
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputClass} font-mono tabular-nums`}
|
||||
value={intervalSec}
|
||||
onChange={(e) => setIntervalSec(Number(e.target.value))}
|
||||
min={10}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Fails after" help="Consecutive failures before an incident opens.">
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputClass} font-mono tabular-nums`}
|
||||
value={retries}
|
||||
onChange={(e) => setRetries(Number(e.target.value))}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Runs from"
|
||||
help="Pick an agent for anything only reachable from inside that network. Everything else runs centrally."
|
||||
>
|
||||
<select className={inputClass} value={runner} onChange={(e) => setRunner(e.target.value)}>
|
||||
<option value="server">Control plane</option>
|
||||
{servers?.map((s) => (
|
||||
<option key={s.server_id} value={s.server_id}>
|
||||
Agent · {s.hostname}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<p className="rounded-lg bg-well px-4 py-3 font-mono text-[11.5px] leading-relaxed text-text-secondary">
|
||||
Checked every {intervalSec} s from {runnerName}. Reported down after {retries}{" "}
|
||||
{retries === 1 ? "failure" : "consecutive failures"} — roughly {downAfter}.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Alerts" hint={channelIds.length > 0 ? `${channelIds.length} selected` : undefined}>
|
||||
{!channels || channels.length === 0 ? (
|
||||
<p className="text-sm text-text-secondary">
|
||||
No channels exist yet, so nobody will be told when this check fails.{" "}
|
||||
<Link href="/settings/notifications" className="text-accent hover:underline">
|
||||
Add a channel
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{channels.map((ch) => (
|
||||
<Check
|
||||
key={ch.channel_id}
|
||||
checked={channelIds.includes(ch.channel_id)}
|
||||
onChange={(v) =>
|
||||
setChannelIds((prev) => (v ? [...prev, ch.channel_id] : prev.filter((id) => id !== ch.channel_id)))
|
||||
}
|
||||
title={ch.name}
|
||||
detail={
|
||||
<span className="font-mono uppercase tracking-[0.1em]">
|
||||
{ch.type}
|
||||
{!ch.enabled && " · disabled, sends nothing"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Check
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
title="Start checking straight away"
|
||||
detail="Turn this off to save the monitor without running it. You can resume it any time."
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">{error.message}</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Monitor, MonitorStatus, Rollup } from "@/lib/api";
|
||||
import { Monitor, MonitorSample, MonitorStatus, Rollup } from "@/lib/api";
|
||||
|
||||
/*
|
||||
* Shared vocabulary for the monitors screens.
|
||||
@@ -109,8 +109,10 @@ export function formatDuration(fromIso: string, toIso?: string): string {
|
||||
/* ------------------------------------------------------------------- tape */
|
||||
|
||||
export interface Slot {
|
||||
/** Start of the hour this slot covers. */
|
||||
/** Start of the period this slot covers. */
|
||||
at: Date;
|
||||
/** Length of that period. An hour for a rollup slot, less for a sample bucket. */
|
||||
spanMs: number;
|
||||
/** Percentage of checks that passed, or null when no check ran. */
|
||||
pct: number | null;
|
||||
checks: number;
|
||||
@@ -141,6 +143,7 @@ export function buildSlots(rollups: Rollup[], hours = 48): Slot[] {
|
||||
const r = byHour.get(at.getTime());
|
||||
slots.push({
|
||||
at,
|
||||
spanMs: 3600_000,
|
||||
checks: r?.checks ?? 0,
|
||||
pct: r && r.checks > 0 ? (r.up_count / r.checks) * 100 : null,
|
||||
latency: r && r.checks > 0 ? r.sum_latency / r.checks : null,
|
||||
@@ -149,6 +152,41 @@ export function buildSlots(rollups: Rollup[], hours = 48): Slot[] {
|
||||
return slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same tape, bucketed from individual check results rather than hourly
|
||||
* rollups — this is what the sub-hour ranges are drawn from, because an hourly
|
||||
* rollup cannot say anything about a window shorter than an hour.
|
||||
*
|
||||
* Buckets are built from the clock like buildSlots, for the same reason: a
|
||||
* window with no checks in it has to read as a gap and not shorten the tape.
|
||||
* `endAt` is passed rather than read from the clock so every series on one
|
||||
* screen shares an edge.
|
||||
*/
|
||||
export function buildSampleSlots(samples: MonitorSample[], windowMs: number, bucketMs: number, endAt = Date.now()): Slot[] {
|
||||
const count = Math.max(Math.round(windowMs / bucketMs), 1);
|
||||
const end = Math.floor(endAt / bucketMs) * bucketMs + bucketMs;
|
||||
const start = end - count * bucketMs;
|
||||
|
||||
const totals = Array.from({ length: count }, () => ({ checks: 0, up: 0, latency: 0 }));
|
||||
for (const sample of samples) {
|
||||
const t = new Date(sample.at).getTime();
|
||||
if (t < start || t >= end) continue;
|
||||
const bucket = totals[Math.floor((t - start) / bucketMs)];
|
||||
if (!bucket) continue;
|
||||
bucket.checks += 1;
|
||||
if (sample.up) bucket.up += 1;
|
||||
bucket.latency += sample.latency_ms;
|
||||
}
|
||||
|
||||
return totals.map((bucket, i) => ({
|
||||
at: new Date(start + i * bucketMs),
|
||||
spanMs: bucketMs,
|
||||
checks: bucket.checks,
|
||||
pct: bucket.checks > 0 ? (bucket.up / bucket.checks) * 100 : null,
|
||||
latency: bucket.checks > 0 ? bucket.latency / bucket.checks : null,
|
||||
}));
|
||||
}
|
||||
|
||||
function slotColor(s: Slot): string {
|
||||
if (s.pct === null) return "bg-border-soft";
|
||||
if (s.pct >= 99.5) return "bg-success";
|
||||
@@ -162,7 +200,9 @@ function slotHeight(s: Slot): number {
|
||||
return 55 + (s.pct - 80) * 2.2;
|
||||
}
|
||||
|
||||
function slotTitle(s: Slot): string {
|
||||
/** One line of plain text for a slot — the tape's tooltip and the chart's
|
||||
* accessible name for a bar, so both read the same hour the same way. */
|
||||
export function slotLabel(s: Slot): string {
|
||||
const when = s.at.toLocaleString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit" });
|
||||
if (s.pct === null) return `${when} · no checks ran`;
|
||||
return `${when} · ${s.pct.toFixed(1)}% up · ${s.checks} checks`;
|
||||
@@ -179,7 +219,7 @@ export function Tape({ slots, height = "h-9", live = true }: { slots: Slot[]; he
|
||||
return (
|
||||
<div className={`relative flex ${height} items-end gap-px rounded-sm bg-well p-[3px]`}>
|
||||
{slots.map((s) => (
|
||||
<div key={s.at.getTime()} className="flex h-full flex-1 items-end" title={slotTitle(s)}>
|
||||
<div key={s.at.getTime()} className="flex h-full flex-1 items-end" title={slotLabel(s)}>
|
||||
<div className={`w-full rounded-[1px] ${slotColor(s)}`} style={{ height: `${slotHeight(s)}%` }} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { PublicComponent } from "@/lib/api";
|
||||
import HistoryBar from "./HistoryBar";
|
||||
|
||||
const LABEL: Record<string, string> = {
|
||||
up: "Operational",
|
||||
down: "Down",
|
||||
maintenance: "Maintenance",
|
||||
pending: "Pending",
|
||||
no_data: "Unknown",
|
||||
};
|
||||
|
||||
// A component under maintenance is drawn as maintenance, never as down — but
|
||||
// its uptime figure (below) is left untouched. The window changes how a
|
||||
// component is drawn, never what the numbers say; see
|
||||
// applyMaintenanceRepaint in statussnapshot.go.
|
||||
const DOT: Record<string, string> = {
|
||||
up: "bg-success",
|
||||
down: "bg-danger",
|
||||
maintenance: "bg-accent",
|
||||
pending: "bg-warning",
|
||||
no_data: "bg-border",
|
||||
};
|
||||
|
||||
export default function ComponentRow({ component }: { component: PublicComponent }) {
|
||||
return (
|
||||
<div className="px-4 py-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-4">
|
||||
<span className="font-medium text-text-primary">{component.name}</span>
|
||||
{/* Every state carries a word next to the dot: colour alone
|
||||
never carries the meaning on this page. */}
|
||||
<span className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${DOT[component.status] ?? DOT.no_data}`}
|
||||
/>
|
||||
{LABEL[component.status] ?? "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<HistoryBar days={component.days} />
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-xs text-text-secondary">
|
||||
<span>90 days ago</span>
|
||||
<span>{component.uptime_90d.toFixed(2)}% uptime</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { PublicDay } from "@/lib/api";
|
||||
|
||||
// The no-data tail (a component created less recently than 90 days ago) reads
|
||||
// as grey rather than as uptime — see uptimeFromDays in
|
||||
// server/internal/services/statussnapshot.go, which skips these days rather
|
||||
// than counting them as zero. Painting them the same as "up" here would undo
|
||||
// that on the one screen a reader actually looks at.
|
||||
const TONE: Record<string, string> = {
|
||||
up: "bg-success",
|
||||
down: "bg-danger",
|
||||
maintenance: "bg-accent",
|
||||
no_data: "bg-border",
|
||||
};
|
||||
|
||||
export default function HistoryBar({ days }: { days: PublicDay[] }) {
|
||||
return (
|
||||
<div className="flex gap-[2px]" aria-hidden="true">
|
||||
{days.map((d) => (
|
||||
<span
|
||||
key={d.date}
|
||||
title={
|
||||
d.state === "no_data"
|
||||
? `${d.date}: no data`
|
||||
: `${d.date}: ${d.uptime.toFixed(2)}% up`
|
||||
}
|
||||
className={`h-6 w-[3px] rounded-full ${TONE[d.state] ?? TONE.no_data}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { PublicIncident } from "@/lib/api";
|
||||
|
||||
// Mirrors the mockup's .pill--inv/--mon/--res/--sch: colour plus the status
|
||||
// word itself (rendered as the pill's text), never colour alone.
|
||||
const PILL_TONE: Record<string, string> = {
|
||||
investigating: "text-danger border-danger/35 bg-danger/10",
|
||||
identified: "text-danger border-danger/35 bg-danger/10",
|
||||
monitoring: "text-warning border-warning/35 bg-warning/10",
|
||||
resolved: "text-success border-success/35 bg-success/10",
|
||||
scheduled: "text-accent border-accent/35 bg-accent/10",
|
||||
in_progress: "text-accent border-accent/35 bg-accent/10",
|
||||
completed: "text-success border-success/35 bg-success/10",
|
||||
};
|
||||
|
||||
function StatusPill({ status }: { status: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`shrink-0 whitespace-nowrap rounded-full border px-2.5 py-1 font-mono text-[0.62rem] uppercase tracking-wider ${
|
||||
PILL_TONE[status] ?? "text-text-secondary border-border bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{status.replace("_", " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IncidentCard({ incident }: { incident: PublicIncident }) {
|
||||
return (
|
||||
<article className="rounded-lg border border-border bg-surface px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="font-medium text-text-primary">{incident.title}</h3>
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
{new Date(incident.started_at).toLocaleString()}
|
||||
{incident.resolved_at
|
||||
? ` — resolved ${new Date(incident.resolved_at).toLocaleString()}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<StatusPill status={incident.status} />
|
||||
</div>
|
||||
{incident.affected && incident.affected.length > 0 ? (
|
||||
<p className="mt-2 text-xs text-text-secondary">
|
||||
Affects {incident.affected.join(", ")}
|
||||
</p>
|
||||
) : null}
|
||||
{incident.updates && incident.updates.length > 0 ? (
|
||||
<ol className="mt-3 space-y-2 border-l border-border pl-3">
|
||||
{incident.updates
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((u, i) => (
|
||||
<li key={i} className="text-sm">
|
||||
<span className="font-mono text-xs uppercase tracking-wider text-text-secondary">
|
||||
{u.status.replace("_", " ")}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-text-secondary">
|
||||
{new Date(u.at).toLocaleString()}
|
||||
</span>
|
||||
<p className="mt-1 text-text-primary">{u.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
+227
@@ -62,6 +62,8 @@ export interface MonitorState {
|
||||
export interface Monitor {
|
||||
monitor_id: string;
|
||||
name: string;
|
||||
/** Display-only heading on the monitors page. Empty means ungrouped. */
|
||||
group?: string;
|
||||
type: MonitorType;
|
||||
target: MonitorTarget;
|
||||
interval_sec: number;
|
||||
@@ -75,6 +77,7 @@ export interface Monitor {
|
||||
|
||||
export interface MonitorInput {
|
||||
name: string;
|
||||
group?: string;
|
||||
type: MonitorType;
|
||||
target: MonitorTarget;
|
||||
interval_sec: number;
|
||||
@@ -92,6 +95,14 @@ export interface Incident {
|
||||
cause?: string;
|
||||
}
|
||||
|
||||
/** One check result. Kept for 48 hours, which is what the sub-hour views read. */
|
||||
export interface MonitorSample {
|
||||
monitor_id: string;
|
||||
at: string;
|
||||
up: boolean;
|
||||
latency_ms: number;
|
||||
}
|
||||
|
||||
export interface Rollup {
|
||||
monitor_id: string;
|
||||
period_start: string;
|
||||
@@ -100,8 +111,88 @@ export interface Rollup {
|
||||
sum_latency: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status page authoring types. These mirror server/internal/models/statuspage.go
|
||||
// field for field -- that Go file is the contract. The public shapes
|
||||
// (PublicDay, PublicComponent, PublicSection, PublicIncident, StatusSnapshot)
|
||||
// live further down this file, added by the public status page task; this
|
||||
// block must not redeclare them.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type StatusPageKind = "incident" | "maintenance";
|
||||
export type StatusImpact = "none" | "minor" | "major" | "critical";
|
||||
|
||||
export interface StatusPageEntry {
|
||||
monitor_id: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
export interface StatusPageSection {
|
||||
name: string;
|
||||
entries: StatusPageEntry[];
|
||||
}
|
||||
|
||||
export interface StatusPageBanner {
|
||||
enabled: boolean;
|
||||
level?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface StatusPage {
|
||||
page_id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
logo_url?: string;
|
||||
published: boolean;
|
||||
banner: StatusPageBanner;
|
||||
sections: StatusPageSection[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface StatusIncidentUpdate {
|
||||
at: string;
|
||||
status: string;
|
||||
body: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
export interface StatusIncident {
|
||||
incident_id: string;
|
||||
page_ids: string[];
|
||||
kind: StatusPageKind;
|
||||
title: string;
|
||||
impact: StatusImpact;
|
||||
affected_monitors?: string[];
|
||||
status: string;
|
||||
scheduled_start?: string;
|
||||
scheduled_end?: string;
|
||||
updates: StatusIncidentUpdate[];
|
||||
started_at: string;
|
||||
resolved_at?: string;
|
||||
}
|
||||
|
||||
export type ChannelType = "webhook" | "smtp" | "discord" | "slack" | "telegram";
|
||||
|
||||
/**
|
||||
* What a channel's secret config values read as over the API. Writing it back
|
||||
* unchanged preserves the stored credential; anything else, including "", is
|
||||
* written verbatim.
|
||||
*
|
||||
* Mirrors `models.RedactedSecret` and `models.channelSecretKeys` in
|
||||
* `server/internal/models/channel.go` — change both in the same commit, the
|
||||
* same hazard as the mirrored token blocks.
|
||||
*/
|
||||
export const REDACTED_SECRET = "••••••••";
|
||||
|
||||
export const CHANNEL_SECRET_FIELDS: Record<ChannelType, string[]> = {
|
||||
webhook: ["url"],
|
||||
slack: ["url"],
|
||||
discord: ["url"],
|
||||
telegram: ["token"],
|
||||
smtp: ["password"],
|
||||
};
|
||||
|
||||
export interface NotificationChannel {
|
||||
channel_id: string;
|
||||
name: string;
|
||||
@@ -648,6 +739,73 @@ export const api = {
|
||||
return request<Rollup[]>(`/monitors/${monitorId}/uptime`);
|
||||
},
|
||||
|
||||
getMonitorSamples(monitorId: string, minutes: number): Promise<MonitorSample[]> {
|
||||
return request<MonitorSample[]>(`/monitors/${monitorId}/samples?minutes=${minutes}`);
|
||||
},
|
||||
|
||||
listStatusPages(): Promise<StatusPage[]> {
|
||||
return request<StatusPage[]>("/status-pages");
|
||||
},
|
||||
|
||||
getStatusPage(pageId: string): Promise<StatusPage> {
|
||||
return request<StatusPage>(`/status-pages/${pageId}`);
|
||||
},
|
||||
|
||||
createStatusPage(input: Partial<StatusPage>): Promise<StatusPage> {
|
||||
return request<StatusPage>("/status-pages", { method: "POST", body: JSON.stringify(input) });
|
||||
},
|
||||
|
||||
updateStatusPage(pageId: string, input: Partial<StatusPage>): Promise<StatusPage> {
|
||||
return request<StatusPage>(`/status-pages/${pageId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
},
|
||||
|
||||
deleteStatusPage(pageId: string): Promise<void> {
|
||||
return request<void>(`/status-pages/${pageId}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
listStatusIncidents(pageId: string): Promise<StatusIncident[]> {
|
||||
return request<StatusIncident[]>(`/status-pages/${pageId}/incidents`);
|
||||
},
|
||||
|
||||
createStatusIncident(pageId: string, input: Partial<StatusIncident>): Promise<StatusIncident> {
|
||||
return request<StatusIncident>(`/status-pages/${pageId}/incidents`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
},
|
||||
|
||||
updateStatusIncident(
|
||||
pageId: string,
|
||||
incidentId: string,
|
||||
input: Partial<StatusIncident>,
|
||||
): Promise<StatusIncident> {
|
||||
return request<StatusIncident>(`/status-pages/${pageId}/incidents/${incidentId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
},
|
||||
|
||||
deleteStatusIncident(pageId: string, incidentId: string): Promise<void> {
|
||||
return request<void>(`/status-pages/${pageId}/incidents/${incidentId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
},
|
||||
|
||||
postStatusIncidentUpdate(
|
||||
pageId: string,
|
||||
incidentId: string,
|
||||
status: string,
|
||||
body: string,
|
||||
): Promise<StatusIncident> {
|
||||
return request<StatusIncident>(`/status-pages/${pageId}/incidents/${incidentId}/updates`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ status, body }),
|
||||
});
|
||||
},
|
||||
|
||||
listChannels(): Promise<NotificationChannel[]> {
|
||||
return request<NotificationChannel[]>("/channels");
|
||||
},
|
||||
@@ -1213,3 +1371,72 @@ export interface Skip {
|
||||
due: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public status page types
|
||||
//
|
||||
// These mirror services.StatusSnapshot and friends in
|
||||
// server/internal/services/statussnapshot.go field for field — that Go file
|
||||
// is the contract. They back the anonymous /status/[pageId] page, which is
|
||||
// deliberately outside the (app) route group and never calls `request()`
|
||||
// (no session, no auth). Task 10 adds the authoring types and api client
|
||||
// methods alongside these; it must not redeclare this block.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PublicDay {
|
||||
date: string;
|
||||
state: "up" | "down" | "maintenance" | "no_data";
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
export interface PublicComponent {
|
||||
name: string;
|
||||
status: "up" | "down" | "maintenance" | "pending" | "no_data";
|
||||
uptime_90d: number;
|
||||
days: PublicDay[];
|
||||
}
|
||||
|
||||
export interface PublicSection {
|
||||
name: string;
|
||||
components: PublicComponent[];
|
||||
}
|
||||
|
||||
export interface PublicIncidentUpdate {
|
||||
at: string;
|
||||
status: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface PublicIncident {
|
||||
id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
impact?: string;
|
||||
status: string;
|
||||
affected?: string[];
|
||||
started_at: string;
|
||||
resolved_at?: string;
|
||||
scheduled_start?: string;
|
||||
scheduled_end?: string;
|
||||
updates?: PublicIncidentUpdate[];
|
||||
}
|
||||
|
||||
export interface PublicBanner {
|
||||
level: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface StatusSnapshot {
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
logo_url?: string;
|
||||
banner?: PublicBanner;
|
||||
overall: string;
|
||||
sections: PublicSection[];
|
||||
active_incidents: PublicIncident[];
|
||||
upcoming_maintenance: PublicIncident[];
|
||||
history: PublicIncident[];
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
+16
-34
@@ -1,11 +1,21 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
// Read at runtime, not baked in. The rewrites below run in the Next server
|
||||
// process, never in the browser, so this never needed the NEXT_PUBLIC_ prefix
|
||||
// that pins a value into the image at build time. NEXT_PUBLIC_API_URL is still
|
||||
// honoured so an existing deployment passing it keeps working.
|
||||
const apiUrl = process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
|
||||
|
||||
/*
|
||||
* This app proxies nothing.
|
||||
*
|
||||
* /api, /auth, /install, /update and /public are the Go server's, and routing
|
||||
* them there is the reverse proxy's job — the same proxy that already
|
||||
* terminates TLS in front of this process. Next used to rewrite them itself
|
||||
* from an API_URL naming the control plane, which meant every deployment had
|
||||
* two possible request paths for the same URL and one environment variable
|
||||
* that silently broke a whole prefix when it named the wrong host: pointed at
|
||||
* the marketing site, /public/status/... 404'd as a page that does not exist,
|
||||
* indistinguishable from a status page that does not exist.
|
||||
*
|
||||
* Browser calls are same-origin and relative (see lib/api.ts), and the public
|
||||
* status page's server-side fetch is made against the visitor's own host, so
|
||||
* nothing in this app needs to know the control plane's address any more.
|
||||
*/
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
async redirects() {
|
||||
@@ -22,34 +32,6 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
];
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${apiUrl}/api/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/auth/:path*",
|
||||
destination: `${apiUrl}/auth/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/install",
|
||||
destination: `${apiUrl}/install`,
|
||||
},
|
||||
{
|
||||
source: "/install.ps1",
|
||||
destination: `${apiUrl}/install.ps1`,
|
||||
},
|
||||
{
|
||||
source: "/update",
|
||||
destination: `${apiUrl}/update`,
|
||||
},
|
||||
{
|
||||
source: "/update.ps1",
|
||||
destination: `${apiUrl}/update.ps1`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user