Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
a0fbf5b9ba | ||
|
|
ddf0814803 |
@@ -383,9 +383,11 @@ one wire shape, worded per platform in the UI, which is the only layer that
|
||||
knows the host's OS. The platform split lives entirely in the agent, as build
|
||||
tags (`systemd_linux.go` / `services_windows.go` and the matching `control_`
|
||||
and `logs_` pairs); the control plane is OS-blind and needed no changes.
|
||||
Windows collection runs PowerShell through `agent/internal/winexec`, and every
|
||||
script emits JSON that a build-tag-free parser reads, so the parsers are tested
|
||||
on Linux — the agent module has no Windows CI.
|
||||
Windows collection runs PowerShell through `agent/internal/winexec`. Every
|
||||
script that reports data emits JSON that a build-tag-free parser reads, so
|
||||
those parsers are tested on Linux — the agent module has no Windows CI. The
|
||||
control verbs and `serviceDisplayName` emit no JSON and have no parser; they
|
||||
are exercised only by running the agent on Windows.
|
||||
|
||||
**Not gated by licence**: this reads as core fleet management, so v1 ships
|
||||
everywhere with no `HasFeature` check. If that changes the check belongs at
|
||||
@@ -454,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.
|
||||
@@ -465,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
|
||||
@@ -746,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)
|
||||
@@ -829,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/`.
|
||||
|
||||
@@ -854,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`:
|
||||
@@ -895,7 +992,7 @@ tls: true
|
||||
|
||||
```
|
||||
1. SyncKeys(server_id, agent_token, agent_version)
|
||||
2. Non-Linux hosts stop here — Windows agents register and heartbeat only
|
||||
2. Non-Linux hosts stop here — the key-management steps below are Linux-only; a Windows agent's other work (workflow steps, inventory, OS updates, workloads) runs from the goroutines started above, not from this loop
|
||||
3. Diff desired keys against /root/.ssh/authorized_keys; unchanged → no write
|
||||
4. Changed → write .tmp, os.Rename() over the real file, chmod 0600
|
||||
```
|
||||
@@ -938,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 |
|
||||
@@ -1171,9 +1269,11 @@ git push origin main # server + web deploy
|
||||
- **Windows agents cover the fleet-management path** — register, heartbeat, run
|
||||
steps, report inventory, OS updates through the Windows Update COM API, and
|
||||
workloads (services plus containers, with control and logs). They still do no
|
||||
`authorized_keys` management, and no package inventory or CVE matching: the
|
||||
vulnerability feeds this project uses carry no Windows data, so a Windows host
|
||||
correctly reports `unsupported` rather than a clean bill of health.
|
||||
`authorized_keys` management, and no package inventory or CVE matching: a
|
||||
Windows agent never calls `ReportPackages`, so no `server_packages` document
|
||||
exists for it and it reports no package inventory at all — a different,
|
||||
earlier state than the `unsupported` a Linux distribution reaches when its
|
||||
family has no security feed.
|
||||
- **Both `server` and `web` scale horizontally** — see "Running more than one server replica" below. `web` holds nothing; `server` holds per-agent state that is routed between replicas over Redis rather than duplicated.
|
||||
- **Deletion lives in the control plane** — admin sends the warnings because it knows the billing address; the control plane performs the delete because it is the only service that knows which collections carry `instance_id`. Mirroring that list into admin would drift, and a drift there deletes the wrong rows.
|
||||
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -19,12 +19,16 @@ func Run(ctx context.Context, script string) (string, error) {
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
|
||||
return "", fmt.Errorf("powershell: %s", strings.TrimSpace(string(ee.Stderr)))
|
||||
}
|
||||
// Checked before the ExitError/stderr branch: CommandContext kills the
|
||||
// process on timeout, and that kill can itself produce an ExitError
|
||||
// carrying stderr text, so a genuine timeout would otherwise surface
|
||||
// as that stderr instead of the "timed out" message callers match on.
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return "", fmt.Errorf("powershell: timed out")
|
||||
}
|
||||
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
|
||||
return "", fmt.Errorf("powershell: %s", strings.TrimSpace(string(ee.Stderr)))
|
||||
}
|
||||
return "", fmt.Errorf("powershell: %w", err)
|
||||
}
|
||||
return string(out), nil
|
||||
|
||||
@@ -12,8 +12,16 @@ const servicesTimeout = 60 * time.Second
|
||||
|
||||
const servicesScript = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$svcs = Get-CimInstance Win32_Service |
|
||||
Select-Object Name,DisplayName,State,StartMode,PathName,ExitCode
|
||||
$svcs = Get-CimInstance Win32_Service | ForEach-Object {
|
||||
[pscustomobject]@{
|
||||
Name = $_.Name
|
||||
DisplayName = $_.DisplayName
|
||||
State = $_.State
|
||||
StartMode = $_.StartMode
|
||||
PathName = $_.PathName
|
||||
ExitCode = $_.ExitCode
|
||||
}
|
||||
}
|
||||
ConvertTo-Json -InputObject @($svcs) -Depth 3 -Compress
|
||||
`
|
||||
|
||||
|
||||
@@ -113,10 +113,15 @@ func parseServices(jsonText, systemRoot string) ([]Workload, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
state := "stopped"
|
||||
// The wire shape is shared with the systemd collector — both report
|
||||
// under kind "unit" — so the state word has to be too, or the UI
|
||||
// (which colours and filters on it, and does so before it knows
|
||||
// which platform sent the row) needs two vocabularies for one kind.
|
||||
// running/stopped/failed become active/inactive/failed to match.
|
||||
state := "inactive"
|
||||
switch {
|
||||
case running:
|
||||
state = "running"
|
||||
state = "active"
|
||||
case failed:
|
||||
state = "failed"
|
||||
}
|
||||
@@ -189,7 +194,10 @@ func parseEvents(jsonText, serviceName, displayName string, tail int) (string, e
|
||||
continue
|
||||
}
|
||||
}
|
||||
msg := strings.TrimSpace(strings.ReplaceAll(e.M, "\r\n", " "))
|
||||
// Collapse every newline form, not just "\r\n": a message containing a
|
||||
// bare "\n" would otherwise still break the one-line-per-event shape
|
||||
// this renders for the log dialog, and undercount the tail trim above.
|
||||
msg := strings.TrimSpace(strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(e.M))
|
||||
lines = append(lines, e.T+" "+e.L+" "+msg)
|
||||
}
|
||||
|
||||
|
||||
@@ -59,12 +59,12 @@ func TestParseServicesFilters(t *testing.T) {
|
||||
t.Fatalf("got %d workloads, want 3: %+v", len(got), got)
|
||||
}
|
||||
|
||||
if w := byID["Contoso"]; w.Kind != "unit" || w.Name != "Contoso Broker" || w.State != "running" {
|
||||
if w := byID["Contoso"]; w.Kind != "unit" || w.Name != "Contoso Broker" || w.State != "active" {
|
||||
t.Errorf("Contoso = %+v", w)
|
||||
}
|
||||
// Enabled but not running is exactly the row worth seeing.
|
||||
if byID["Fabrikam"].State != "stopped" {
|
||||
t.Errorf("Fabrikam state = %q, want stopped", byID["Fabrikam"].State)
|
||||
if byID["Fabrikam"].State != "inactive" {
|
||||
t.Errorf("Fabrikam state = %q, want inactive", byID["Fabrikam"].State)
|
||||
}
|
||||
// A non-zero exit code on a stopped service is a crash, not a clean stop.
|
||||
if byID["Crashed"].State != "failed" {
|
||||
@@ -80,15 +80,15 @@ func TestParseServicesExitCode1077(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("parseServices: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].State != "stopped" {
|
||||
t.Fatalf("got %+v, want one stopped workload", got)
|
||||
if len(got) != 1 || got[0].State != "inactive" {
|
||||
t.Fatalf("got %+v, want one inactive workload", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServicesSingleObjectAndEmpty(t *testing.T) {
|
||||
one := `{"Name":"Solo","DisplayName":"Solo","State":"Running","StartMode":"Auto","PathName":"C:\\Solo\\s.exe","ExitCode":0}`
|
||||
got, err := parseServices(one, `C:\WINDOWS`)
|
||||
if err != nil || len(got) != 1 {
|
||||
if err != nil || len(got) != 1 || got[0].State != "active" {
|
||||
t.Fatalf("single object: got %+v, err %v", got, err)
|
||||
}
|
||||
|
||||
@@ -130,6 +130,25 @@ func TestParseEventsFormatsAndOrders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A message containing a bare "\n" (no carriage return) must still collapse to
|
||||
// one line, or it silently multiplies into several output lines and throws
|
||||
// off the tail trim's count.
|
||||
func TestParseEventsCollapsesBareLF(t *testing.T) {
|
||||
in := `[{"t":"2026-08-13T10:00:00Z","l":"Error","p":"Contoso","m":"broker died\nstack trace here"}]`
|
||||
|
||||
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEvents: %v", err)
|
||||
}
|
||||
if strings.Count(got, "\n") != 0 {
|
||||
t.Fatalf("parseEvents did not collapse bare LF into one line: %q", got)
|
||||
}
|
||||
want := "2026-08-13T10:00:00Z Error broker died stack trace here"
|
||||
if got != want {
|
||||
t.Fatalf("parseEvents =\n%q\nwant\n%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Service Control Manager logs every service on the host under one provider, so
|
||||
// its rows must be filtered down to the target or the log is somebody else's.
|
||||
func TestParseEventsFiltersOtherServicesSCM(t *testing.T) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -94,6 +95,7 @@ ingress:
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -95,6 +95,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 +160,27 @@ needs `host`, `port`, `from` and `to`, and Telegram needs both `token` and
|
||||
| Instance degraded despite a valid-looking licence | It expired more than a few days ago. Pasting a new one still works, which is how you recover |
|
||||
| Cannot enrol another server | The server allowance is reached. Raise it in HQ or remove one |
|
||||
|
||||
## A status page 404s or shows no data
|
||||
|
||||
**404, and it should be published.** Check the **Published** toggle on the
|
||||
page's editor — an unpublished page answers *not found* for everyone,
|
||||
including you, with no session exemption. Also check the host: the public URL
|
||||
is `<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.
|
||||
|
||||
**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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export default function WorkloadsPage() {
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-text-primary">Workloads</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Containers and systemd services across the fleet, as last reported by each agent.</p>
|
||||
<p className="mt-1 text-sm text-text-secondary">Containers and services across the fleet, as last reported by each agent.</p>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
|
||||
@@ -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,112 @@
|
||||
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" };
|
||||
|
||||
async function fetchSnapshot(host: string, forwardedFor: string, pageId: string): Promise<FetchResult> {
|
||||
const base = process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
|
||||
|
||||
// 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 result = await fetchSnapshot(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>
|
||||
))}
|
||||
|
||||
@@ -82,7 +82,7 @@ export function MaintenanceTab({
|
||||
<span className="font-mono text-xs text-text-secondary">{u.current_version || "n/a"}</span>
|
||||
</Td>
|
||||
<Td label={isWindows ? "KB" : "Available"}>
|
||||
<span className="font-mono text-xs text-success">{u.new_version}</span>
|
||||
<span className="font-mono text-xs text-success">{u.new_version || "n/a"}</span>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -94,6 +94,7 @@ export function WorkloadList({ serverId, canControl, isWindows }: { serverId: st
|
||||
workload={w}
|
||||
canControl={canControl}
|
||||
busy={control.isPending}
|
||||
isWindows={isWindows}
|
||||
onAction={(action) => control.mutate({ w, action })}
|
||||
onLogs={() => setLogTarget(w)}
|
||||
/>
|
||||
|
||||
@@ -45,12 +45,14 @@ export function WorkloadRow({
|
||||
workload,
|
||||
canControl,
|
||||
busy,
|
||||
isWindows,
|
||||
onAction,
|
||||
onLogs,
|
||||
}: {
|
||||
workload: Workload;
|
||||
canControl: boolean;
|
||||
busy: boolean;
|
||||
isWindows: boolean;
|
||||
onAction: (action: WorkloadAction) => void;
|
||||
onLogs: () => void;
|
||||
}) {
|
||||
@@ -66,7 +68,7 @@ export function WorkloadRow({
|
||||
{!!w.restarts && w.restarts > 0 && <Badge variant="warning">{w.restarts} restarts</Badge>}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-text-secondary">
|
||||
{w.kind === "container" ? w.image || "no image" : "systemd unit"}
|
||||
{w.kind === "container" ? w.image || "no image" : isWindows ? "Windows service" : "systemd unit"}
|
||||
{w.ports && w.ports.length > 0 && <span className="ml-2 font-mono">{w.ports.join(" ")}</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+228
-1
@@ -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");
|
||||
},
|
||||
@@ -1122,7 +1280,7 @@ export const vulnerabilities = {
|
||||
export type WorkloadKind = "container" | "unit";
|
||||
export type WorkloadAction = "start" | "stop" | "restart";
|
||||
|
||||
/** One container or one systemd unit.
|
||||
/** One Docker container, one systemd unit, or one Windows service.
|
||||
*
|
||||
* `state` is deliberately not a shared vocabulary across the two kinds:
|
||||
* containers report running/exited/paused/restarting/created, units report
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,12 @@ const nextConfig: NextConfig = {
|
||||
source: "/update.ps1",
|
||||
destination: `${apiUrl}/update.ps1`,
|
||||
},
|
||||
{
|
||||
// The public status page refreshes itself in the browser, so
|
||||
// the public prefix has to be proxied the same way /api is.
|
||||
source: "/public/:path*",
|
||||
destination: `${apiUrl}/public/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user