diff --git a/docs/superpowers/plans/2026-08-24-status-pages.md b/docs/superpowers/plans/2026-08-24-status-pages.md new file mode 100644 index 0000000..ef0e20a --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-status-pages.md @@ -0,0 +1,3020 @@ +# Public Status Pages Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Publish operator-configured, completely public status pages at `.vantage./status/`, showing chosen monitors plus hand-authored incidents and maintenance windows. + +**Architecture:** Two new instance-scoped MongoDB collections (`status_pages`, `status_incidents`) hold the page and its authored incidents. A pure assembly function combines them with existing monitor, incident and rollup data into a purpose-built public struct — that function is the redaction boundary and nothing else may serve monitor data to an anonymous caller. The public route is mounted on the gin root, outside `/api` and therefore outside authentication, scope enforcement and the licence gate; it is cached in Redis for 30s and rate limited per client address. + +**Tech Stack:** Go 1.x (gin, mongo-driver v2, go-redis), Next.js 16 App Router + React 18 + Tailwind 3 + TanStack Query. + +**Spec:** `docs/superpowers/specs/2026-08-24-status-pages-design.md` + +## Global Constraints + +- **Every new collection carries `instance_id` and MUST be listed in `services.ScopedCollections`** (`server/internal/services/migrate_instance.go:24`), or its rows outlive a deleted instance. +- **`models.Monitor` is never marshalled to an anonymous caller.** Only `services.assembleSnapshot` produces public monitor data, and only the fields named in Task 3. +- **No component in `web/` may carry a hex colour.** Use the existing CSS tokens. `web/` is dark-only; the status page inherits that. +- **Every `/api` route must have an entry in `routeScopes`** (`server/internal/api/scopes.go:27`) or `AssertScopeMapComplete` fails boot (`server/cmd/main.go:267`). +- **Every mutating `/api` handler writes an audit event** via `services.LogEvent(instanceID, eventType, actor, serverID, keyID, details)`. +- **`server/internal/api/docs/openapi.json` is generated and committed.** `server-deploy.yml` runs `git diff --exit-code` against it, so handler annotations and the committed file must agree. +- **Redis being unavailable must never deny a request** on the public path. Allow, exactly as `RateLimitTokens` does (`server/internal/api/ratelimit.go:38`). +- **This repo has no MongoDB test harness and no JS test runner.** Tests in this plan are Go unit tests over pure functions (`go test ./...`). Mongo-touching and UI work is verified by build plus the stated manual check. Do not invent a test harness as part of this plan. +- Slug rules, verbatim: `page_id` is 3-40 characters, `[a-z0-9-]`, first and last character alphanumeric. +- History window, verbatim: **90 days**, one cell per day, cell state one of `up`, `down`, `maintenance`, `no_data`. +- Cache TTL, verbatim: **30 seconds**. Rate limit, verbatim: **120 requests per minute per client address**, 429 with `Retry-After: 60`. + +--- + +## File Structure + +**Server — created:** + +| File | Responsibility | +| --- | --- | +| `server/internal/models/statuspage.go` | `StatusPage`, `StatusIncident` documents and their constants | +| `server/internal/services/statuspages.go` | Page CRUD, slug validation, index builder, cache invalidation | +| `server/internal/services/statusincidents.go` | Authored incident CRUD and update posting | +| `server/internal/services/statussnapshot.go` | Public struct types, `assembleSnapshot` (pure), `PublicStatusSnapshot` (reads + caches) | +| `server/internal/services/statussnapshot_test.go` | The redaction boundary, as tests | +| `server/internal/services/statuspages_test.go` | Slug validation, cache key, scoped-collection registration | +| `server/internal/api/statuspages.go` | Authoring handlers | +| `server/internal/api/publicstatus.go` | The one public handler plus its rate limiter | + +**Server — modified:** + +| File | Change | +| --- | --- | +| `shared/license/license.go:37` | add `FeatureStatusPages` | +| `server/internal/services/migrate_instance.go:24` | add both collections to `ScopedCollections` | +| `server/internal/api/handlers.go:29` | register the public route and the authoring group | +| `server/internal/api/scopes.go:27` | add the nine `status:*` route entries | +| `server/cmd/main.go` | `EnsureStatusPageIndexes`, `SetTrustedProxies` | + +**Web — created:** `web/app/status/[pageId]/page.tsx`, `web/app/status/[pageId]/StatusPageView.tsx`, `web/components/status/` (`ComponentRow.tsx`, `HistoryBar.tsx`, `IncidentCard.tsx`), `web/app/(app)/status-pages/page.tsx`, `web/app/(app)/status-pages/[pageId]/page.tsx`. + +**Web — modified:** `web/lib/api.ts` (types + methods), `web/components/Sidebar.tsx:198` (Instance group), `web/next.config.ts:29` (`/public` rewrite). + +**Docs — modified:** `docsite/docs/vantage/status-pages.md` (new), `docsite/sidebars.ts`, `CLAUDE.md`, `docsite/docs/reference/environment-variables.md`. + +--- + +### Task 1: Schema — models, feature constant, scoped collections, indexes + +**Files:** +- Create: `server/internal/models/statuspage.go` +- Create: `server/internal/services/statuspages_test.go` +- Modify: `shared/license/license.go:37` +- Modify: `server/internal/services/migrate_instance.go:24` +- Create: `server/internal/services/statuspages.go` (index builder only in this task) +- Modify: `server/cmd/main.go` (call the index builder) + +**Interfaces:** +- Consumes: nothing. +- Produces: `models.StatusPage`, `models.StatusPageSection`, `models.StatusPageEntry`, `models.StatusPageBanner`, `models.StatusIncident`, `models.StatusIncidentUpdate`; `license.FeatureStatusPages`; `services.EnsureStatusPageIndexes() error`. + +- [ ] **Step 1: Write the failing test** + +Create `server/internal/services/statuspages_test.go`: + +```go +package services + +import "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) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./server/internal/services/ -run TestStatusCollectionsAreScoped -v` +Expected: FAIL — `ScopedCollections is missing "status_pages"` and the same for `status_incidents`. + +- [ ] **Step 3: Add the licence feature constant** + +In `shared/license/license.go`, immediately after `FeatureVulnScanning`: + +```go + // 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" +``` + +- [ ] **Step 4: Add the models** + +Create `server/internal/models/statuspage.go`: + +```go +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"` +} +``` + +- [ ] **Step 5: Register the collections** + +In `server/internal/services/migrate_instance.go`, add to the `ScopedCollections` slice after `"server_workloads",`: + +```go + "status_pages", + "status_incidents", +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `go test ./server/internal/services/ -run TestStatusCollectionsAreScoped -v` +Expected: PASS + +- [ ] **Step 7: Add the index builder** + +Create `server/internal/services/statuspages.go`: + +```go +package services + +import ( + "context" + "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" +) + +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. +func EnsureStatusPageIndexes() error { + ctx, cancel := spCtx() + defer cancel() + + 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 { + return 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 { + return 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 { + return err + } + return nil +} +``` + +- [ ] **Step 8: Call it at boot** + +In `server/cmd/main.go`, immediately after the `EnsureWorkloadIndexes` block (around line 173), following its warn-not-fatal shape: + +```go + if err := services.EnsureStatusPageIndexes(); err != nil { + log.Printf("status page indexes: %v", err) + } +``` + +- [ ] **Step 9: Build and test** + +Run: `go build ./server/... && go build ./shared/... && go test ./server/internal/services/ -v` +Expected: build succeeds, `TestStatusCollectionsAreScoped` PASS. + +- [ ] **Step 10: Commit** + +```bash +git add server/internal/models/statuspage.go server/internal/services/statuspages.go \ + server/internal/services/statuspages_test.go server/internal/services/migrate_instance.go \ + shared/license/license.go server/cmd/main.go +git commit -m "feat: status page schema, licence feature and indexes" +``` + +--- + +### Task 2: Slug validation and cache key + +**Files:** +- Modify: `server/internal/services/statuspages.go` +- Modify: `server/internal/services/statuspages_test.go` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `services.ValidatePageID(id string) error`, `services.ErrInvalidPageID`, `services.statusCacheKey(instanceID, pageID string) string`. + +- [ ] **Step 1: Write the failing test** + +Append to `server/internal/services/statuspages_test.go`: + +```go +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) + } +} +``` + +Add `"strings"` to that file's imports. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./server/internal/services/ -run 'TestValidatePageID|TestStatusCacheKey' -v` +Expected: FAIL — `undefined: ValidatePageID`, `undefined: statusCacheKey`. + +- [ ] **Step 3: Implement** + +Add to `server/internal/services/statuspages.go`: + +```go +// 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 +} +``` + +Add `"errors"` and `"regexp"` to the imports. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./server/internal/services/ -run 'TestValidatePageID|TestStatusCacheKey' -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/services/statuspages.go server/internal/services/statuspages_test.go +git commit -m "feat: status page id validation and cache key" +``` + +--- + +### Task 3: The redaction boundary — `assembleSnapshot` + +This is the security-critical task. Everything else is plumbing around it. + +**Files:** +- Create: `server/internal/services/statussnapshot.go` +- Create: `server/internal/services/statussnapshot_test.go` + +**Interfaces:** +- Consumes: `models.StatusPage`, `models.StatusIncident`, `models.Monitor`, `models.Incident`, `models.Rollup` from Task 1 and the existing model package. +- Produces: `services.StatusSnapshot`, `services.PublicSection`, `services.PublicComponent`, `services.PublicDay`, `services.PublicIncident`, `services.PublicIncidentUpdate`, `services.PublicBanner`, `services.snapshotInput`, `services.assembleSnapshot(in snapshotInput) StatusSnapshot`. + +- [ ] **Step 1: Write the failing test** + +Create `server/internal/services/statussnapshot_test.go`: + +```go +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", + "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 override, and its monitor name is in the leak list above, + // 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) + } +} + +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) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./server/internal/services/ -run TestAssembleSnapshot -v` +Expected: FAIL — `undefined: snapshotInput`, `undefined: assembleSnapshot`. + +- [ ] **Step 3: Implement the public types and the assembler** + +Create `server/internal/services/statussnapshot.go`: + +```go +package services + +import ( + "sort" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" +) + +// 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 + + comp := PublicComponent{ + Name: name, + Days: buildDays(in.Rollups[entry.MonitorID], underMaintenance[entry.MonitorID], in.Now), + } + comp.Uptime90d = uptimeFromDays(comp.Days) + 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 + } + snap.History = append(snap.History, PublicIncident{ + ID: inc.IncidentID, + Kind: models.StatusKindIncident, + Title: name + " unavailable", + Status: autoStatus(inc), + Affected: []string{name}, + StartedAt: inc.StartedAt, + ResolvedAt: inc.ResolvedAt, + }) + } + + 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. +func buildDays(rollups []models.Rollup, inMaintenance bool, 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 + } + } + // Maintenance repaints today's cell only, and never touches Uptime. + if inMaintenance && i == 0 { + day.State = PublicMaintenance + } + days = append(days, day) + } + 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 + } +} + +func overallState(sections []PublicSection) string { + worst := PublicUp + anyDown, anyMaint, anyOther := false, false, false + for _, s := range sections { + for _, c := range s.Components { + switch c.Status { + case PublicDown: + anyDown = true + case PublicMaintenance: + anyMaint = true + case PublicPending, PublicNoData: + anyOther = true + } + } + } + switch { + case anyDown: + worst = PublicDown + case anyMaint: + worst = PublicMaintenance + case anyOther: + worst = PublicDegraded + } + return worst +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./server/internal/services/ -run TestAssembleSnapshot -v` +Expected: all five PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/services/statussnapshot.go server/internal/services/statussnapshot_test.go +git commit -m "feat: public status snapshot assembly and redaction boundary" +``` + +--- + +### Task 4: Page CRUD + +No unit tests: every function here talks to MongoDB and this repo has no Mongo test harness. Verification is a build plus the manual check in Step 6, which exercises the real path once the API exists in Task 8. Do not add a harness as part of this task. + +**Files:** +- Modify: `server/internal/services/statuspages.go` + +**Interfaces:** +- Consumes: `ValidatePageID`, `statusCacheKey`, `spCtx` (Task 2), `models.StatusPage` (Task 1). +- Produces: + - `services.ErrPageNotFound error` + - `services.ErrPageIDTaken error` + - `services.ListStatusPages(instanceID string) ([]models.StatusPage, error)` + - `services.GetStatusPage(instanceID, pageID string) (*models.StatusPage, error)` + - `services.CreateStatusPage(instanceID string, p *models.StatusPage) (*models.StatusPage, error)` + - `services.UpdateStatusPage(instanceID, pageID string, p *models.StatusPage) (*models.StatusPage, error)` + - `services.DeleteStatusPage(instanceID, pageID string) error` + - `services.InvalidateStatusCache(instanceID, pageID string)` + +- [ ] **Step 1: Add the errors and the injected Redis client** + +`services` may **not** import `auth`: `auth/instancehost.go` already calls `services.GetInstanceBySlug`, and Go has no cycles. The Redis client is therefore injected at boot rather than reached for, the same way `workflowsched` takes its `Deps`. + +Append to `server/internal/services/statuspages.go`: + +```go +var ( + ErrPageNotFound = errors.New("status page not found") + ErrPageIDTaken = errors.New("that page id is already in use") +) + +// 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) + } +} +``` + +Add imports `"log"` and `"github.com/redis/go-redis/v9"` (the version already in `server/go.mod` is `v9.20.1`). + +In `server/cmd/main.go`, after Redis is initialised and before `api.RegisterRoutes(r)`: + +```go + services.SetStatusRedis(auth.Redis()) +``` + +- [ ] **Step 2: Implement the reads** + +```go +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 +} +``` + +- [ ] **Step 3: Implement create** + +```go +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, errors.New("title is required") + } + 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 +} +``` + +- [ ] **Step 4: Implement update and delete** + +```go +// 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, errors.New("title is required") + } + 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 +} +``` + +- [ ] **Step 5: Build** + +Run: `go build ./server/... && go vet ./server/internal/services/ && go test ./server/internal/services/` +Expected: build clean, existing tests still PASS. + +- [ ] **Step 6: Manual check (deferred)** + +These functions have no caller until Task 8. Note in the commit message that the manual check runs there; do not hand-invoke them from a scratch `main`. + +- [ ] **Step 7: Commit** + +```bash +git add server/internal/services/statuspages.go server/cmd/main.go +git commit -m "feat: status page CRUD and cache invalidation" +``` + +--- + +### Task 5: Authored incident CRUD + +**Files:** +- Create: `server/internal/services/statusincidents.go` + +**Interfaces:** +- Consumes: `spCtx`, `InvalidateStatusCache`, `ErrPageNotFound` (Tasks 2 and 4), `models.StatusIncident` (Task 1). +- Produces: + - `services.ErrIncidentNotFound error` + - `services.ListStatusIncidents(instanceID, pageID string) ([]models.StatusIncident, error)` + - `services.CreateStatusIncident(instanceID string, inc *models.StatusIncident) (*models.StatusIncident, error)` + - `services.UpdateStatusIncident(instanceID, incidentID string, inc *models.StatusIncident) (*models.StatusIncident, error)` + - `services.AppendStatusIncidentUpdate(instanceID, incidentID, status, body, author string) (*models.StatusIncident, error)` + - `services.DeleteStatusIncident(instanceID, incidentID string) error` + - `services.ListStatusIncidentsForPage(instanceID, pageID string, since time.Time) ([]models.StatusIncident, error)` + +- [ ] **Step 1: Implement validation and create** + +Create `server/internal/services/statusincidents.go`: + +```go +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 errors.New("title is required") + } + if len(inc.PageIDs) == 0 { + return errors.New("at least one page is required") + } + switch inc.Kind { + case models.StatusKindIncident: + if !incidentStatuses[inc.Status] { + return fmt.Errorf("invalid incident status %q", inc.Status) + } + case models.StatusKindMaintenance: + if !maintenanceStatuses[inc.Status] { + return fmt.Errorf("invalid maintenance status %q", inc.Status) + } + if inc.ScheduledStart == nil || inc.ScheduledEnd == nil { + return errors.New("maintenance needs a scheduled start and end") + } + if !inc.ScheduledEnd.After(*inc.ScheduledStart) { + return errors.New("maintenance must end after it starts") + } + default: + return fmt.Errorf("invalid kind %q", inc.Kind) + } + if inc.Impact == "" { + inc.Impact = models.ImpactNone + } + if !impacts[inc.Impact] { + return fmt.Errorf("invalid impact %q", 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) + } +} +``` + +- [ ] **Step 2: Implement read, update, append and delete** + +```go +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, errors.New("update body is required") + } + valid := incidentStatuses + if existing.Kind == models.StatusKindMaintenance { + valid = maintenanceStatuses + } + if !valid[status] { + return nil, fmt.Errorf("invalid status %q for a %s", 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 + } + + 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 +} +``` + +- [ ] **Step 3: Build** + +Run: `go build ./server/... && go vet ./server/internal/services/ && go test ./server/internal/services/` +Expected: clean, existing tests PASS. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/services/statusincidents.go +git commit -m "feat: authored status incidents and maintenance windows" +``` + +--- + +### Task 6: `PublicStatusSnapshot` — reads, feature gate, Redis cache + +**Files:** +- Modify: `server/internal/services/statussnapshot.go` + +**Interfaces:** +- Consumes: `assembleSnapshot`, `snapshotInput`, `StatusSnapshot` (Task 3); `GetStatusPage`, `ErrPageNotFound`, `statusCacheKey`, `statusRedis` (Tasks 2 and 4); `ListStatusIncidentsForPage` (Task 5); `GetLicenseState` (existing, `server/internal/services/licence.go:79`). +- Produces: `services.PublicStatusSnapshot(instanceID, pageID string) (*StatusSnapshot, error)`, returning `ErrPageNotFound` for missing **and** unpublished pages. + +- [ ] **Step 1: Implement the cached read** + +Append to `server/internal/services/statussnapshot.go`: + +```go +const statusCacheTTL = 30 * time.Second + +// 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 { + return nil, ErrPageNotFound + } + if snap := cachedSnapshot(instanceID, pageID); snap != nil { + return snap, nil + } + + page, err := GetStatusPage(instanceID, pageID) + if err != nil { + return nil, err + } + if !page.Published { + 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 &StatusSnapshot{Available: false, Reason: "licence_inactive", Title: page.Title}, nil + } + if !st.Feature(license.FeatureStatusPages) { + return &StatusSnapshot{Available: false, Reason: "feature_unavailable", Title: page.Title}, nil + } + + in, err := loadSnapshotInput(instanceID, *page) + if err != nil { + return nil, err + } + snap := assembleSnapshot(in) + storeSnapshot(instanceID, pageID, snap) + return &snap, nil +} +``` + +`Active()` and `Feature()` are methods on `services.LicenseState` (`server/internal/services/licence.go:31,34`), not fields. Import `"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"` for the feature constant. + +- [ ] **Step 2: Implement the loader** + +```go +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 +} +``` + +Add imports: `"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"`, `"go.mongodb.org/mongo-driver/v2/bson"`. + +- [ ] **Step 3: Implement the cache helpers** + +```go +// 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) + } +} +``` + +Add imports `"encoding/json"` and `"log"`. + +Note the `available:false` snapshots are **not** cached. They are cheap to produce, and caching them means a licence paste takes up to 30 seconds to bring pages back. + +- [ ] **Step 4: Build and test** + +Run: `go build ./server/... && go test ./server/internal/services/ -v` +Expected: build clean, all Task 1-3 tests still PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/services/statussnapshot.go +git commit -m "feat: cached public status snapshot with licence gate" +``` + +--- + +### Task 7: The public route, its rate limiter, and trusted proxies + +**Files:** +- Create: `server/internal/api/publicstatus.go` +- Modify: `server/internal/api/handlers.go:29` +- Modify: `server/cmd/main.go:261` +- Modify: `docsite/docs/reference/environment-variables.md` + +**Interfaces:** +- Consumes: `services.PublicStatusSnapshot`, `services.ErrPageNotFound` (Task 6); `auth.InstanceFromHost` (existing, `server/internal/auth/instancehost.go:53`). +- Produces: `api.RateLimitPublicStatus() gin.HandlerFunc`, the route `GET /public/status/:pageId`. + +- [ ] **Step 1: Write the handler and limiter** + +Create `server/internal/api/publicstatus.go`: + +```go +package api + +import ( + "errors" + "net/http" + "strconv" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" + "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. +// +// @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 +// @Router /public/status/{pageId} [get] +func getPublicStatusPage(c *gin.Context) { + inst, ok := auth.InstanceFromHost(c) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + snap, err := services.PublicStatusSnapshot(inst.InstanceID, c.Param("pageId")) + if errors.Is(err, services.ErrPageNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + if err != nil { + 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) +} +``` + +The handler returns a bare `"internal error"` rather than `err.Error()`. Every other handler in this package returns the error text; this one must not, because a Mongo error message on an unauthenticated endpoint describes the schema to a stranger. + +- [ ] **Step 2: Register the route** + +In `server/internal/api/handlers.go`, inside `RegisterRoutes`, after the `/auth/providers` line and **before** `apiGroup := r.Group("/api")`: + +```go + // 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) +``` + +- [ ] **Step 3: Configure trusted proxies** + +Nothing calls `SetTrustedProxies` today, so gin trusts every proxy and `c.ClientIP()` returns whatever `X-Forwarded-For` says — spoofable per request, which would make the limiter above decorative. + +In `server/cmd/main.go`, immediately after `r := gin.New()`: + +```go + // 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(trustedProxies()); err != nil { + log.Fatalf("trusted proxies: %v", err) + } +``` + +and add: + +```go +// 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). +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 +} +``` + +Ensure `"os"` and `"strings"` are imported in `main.go`. + +- [ ] **Step 4: Document the variable** + +Add a row to the server table in `docsite/docs/reference/environment-variables.md`: + +| `TRUSTED_PROXIES` | no | Comma-separated CIDRs or addresses of proxies allowed to set `X-Forwarded-For`. Unset trusts none, so the client address is the direct peer — behind a reverse proxy that makes every visitor share one address for rate-limiting purposes. Set it to your proxy's range. | + +- [ ] **Step 5: Build** + +Run: `go build ./server/... && go vet ./server/...` +Expected: clean. + +- [ ] **Step 6: Manual check** + +Start the stack, then: + +```bash +# unknown page on a valid instance host -> 404 +curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: acme.vantage.localhost:8080' \ + http://localhost:8080/public/status/nope + +# no instance label in the host -> 404, and never a 500 +curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/public/status/api + +# rate limit fires and names itself +for i in $(seq 1 130); do + curl -s -o /dev/null -w '%{http_code} ' -H 'Host: acme.vantage.localhost:8080' \ + http://localhost:8080/public/status/api +done; echo +``` + +Expected: `404`, `404`, and the tail of the third run showing `429`. + +- [ ] **Step 7: Commit** + +```bash +git add server/internal/api/publicstatus.go server/internal/api/handlers.go \ + server/cmd/main.go docsite/docs/reference/environment-variables.md +git commit -m "feat: public status page endpoint with per-address rate limit" +``` + +--- + +### Task 8: Authoring API + +**Files:** +- Create: `server/internal/api/statuspages.go` +- Modify: `server/internal/api/handlers.go` +- Modify: `server/internal/api/scopes.go:27` +- Modify: `server/internal/services/scopes.go` (`ScopeResources`) +- Modify: `server/internal/api/docs/openapi.json` (regenerated) + +**Interfaces:** +- Consumes: every `services.*StatusPage*` and `services.*StatusIncident*` function from Tasks 4 and 5; `RequireFeature` (existing, `server/internal/api/licence.go:80`); `auth.RequireRole`, `auth.InstanceID`, `actorFromCtx` (existing). +- Produces: ten registered routes under `/api/status-pages`. + +- [ ] **Step 1: Add the scope resource** + +In `server/internal/services/scopes.go`, add `"status"` to `ScopeResources`. This widens the vocabulary `GET /tokens/scopes` advertises, so the UI picks it up with no change. + +- [ ] **Step 2: Add the route-scope entries** + +In `server/internal/api/scopes.go`, append to `routeScopes`: + +```go + // 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", +``` + +- [ ] **Step 3: Write the handlers** + +Create `server/internal/api/statuspages.go`: + +```go +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. +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): + 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 { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + 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) +} +``` + +- [ ] **Step 4: Add the request type** + +In `server/internal/api/types.go`: + +```go +// 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"` +} +``` + +- [ ] **Step 5: Register the group** + +In `server/internal/api/handlers.go`, inside the `apiGroup` block alongside the other `register*Routes` calls: + +```go + registerStatusPageRoutes(apiGroup) +``` + +- [ ] **Step 6: Build and confirm the scope map is complete** + +Run: `go build ./server/... && go run ./server/cmd 2>&1 | head -20` +Expected: no `api scope map:` fatal. If one appears it names the route missing from `routeScopes` — add it rather than removing the assertion. Stop the process once it reports listening. + +- [ ] **Step 7: Regenerate the OpenAPI document** + +Run the same command `server-deploy.yml` uses (check the workflow for the exact invocation, it is `swag v2`), then: + +Run: `git diff --stat server/internal/api/docs/openapi.json` +Expected: the ten new paths appear. Commit the regenerated file — CI runs `git diff --exit-code` against it. + +- [ ] **Step 8: Manual check** + +With a signed-in owner session: + +```bash +curl -s -b cookies.txt -X POST localhost:8080/api/status-pages \ + -H 'Content-Type: application/json' \ + -d '{"page_id":"api","title":"Acme Status","published":true, + "sections":[{"name":"API","entries":[{"monitor_id":"","display_name":"Public API"}]}]}' + +curl -s -H 'Host: .vantage.localhost:8080' localhost:8080/public/status/api | jq . +``` + +Expected: 201 then a snapshot with `available: true`, one section, one component and a 90-cell `days` array. Confirm by eye that the response contains no URL, host or port from the monitor. + +- [ ] **Step 9: Commit** + +```bash +git add server/internal/api/statuspages.go server/internal/api/handlers.go \ + server/internal/api/scopes.go server/internal/api/types.go \ + server/internal/services/scopes.go server/internal/api/docs/openapi.json +git commit -m "feat: status page authoring API" +``` + +--- + +### Task 9: The public page in `web/` + +No test runner exists in `web/`. Verification is `npm run build`, `npm run lint`, and the stated browser check. + +**Files:** +- Create: `web/app/status/[pageId]/page.tsx` +- Create: `web/app/status/[pageId]/StatusPageView.tsx` +- Create: `web/components/status/HistoryBar.tsx` +- Create: `web/components/status/ComponentRow.tsx` +- Create: `web/components/status/IncidentCard.tsx` +- Modify: `web/next.config.ts:29` + +**Interfaces:** +- Consumes: `GET /public/status/:pageId` from Task 7. +- Produces: TypeScript types `StatusSnapshot`, `PublicSection`, `PublicComponent`, `PublicDay`, `PublicIncident` exported from `web/lib/api.ts` (added in Task 10; declare them locally in `StatusPageView.tsx` for this task and move them in Task 10 — or do Task 10's type block first if executing in order). + +- [ ] **Step 1: Add the rewrite** + +In `web/next.config.ts`, add to the `rewrites()` array: + +```ts + { + // 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*`, + }, +``` + +- [ ] **Step 2: Write the server component** + +Create `web/app/status/[pageId]/page.tsx`: + +```tsx +import { notFound } from "next/navigation"; +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"; + +async function fetchSnapshot(host: string, pageId: string): Promise { + const base = process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080"; + // The instance is resolved server-side from the Host header, so it has to + // be forwarded explicitly — the server-to-server fetch does not carry it. + const res = await fetch(`${base}/public/status/${encodeURIComponent(pageId)}`, { + headers: { Host: host }, + cache: "no-store", + }); + if (!res.ok) return null; + return res.json(); +} + +export default async function PublicStatusPage({ + params, +}: { + params: Promise<{ pageId: string }>; +}) { + const { pageId } = await params; + const { headers } = await import("next/headers"); + const h = await headers(); + const host = h.get("x-forwarded-host") ?? h.get("host") ?? ""; + + const snapshot = await fetchSnapshot(host, pageId); + if (!snapshot) notFound(); + + return ; +} + +export async function generateMetadata({ params }: { params: Promise<{ pageId: string }> }) { + const { pageId } = await params; + return { title: `Status — ${pageId}` }; +} +``` + +- [ ] **Step 3: Write the client view** + +Create `web/app/status/[pageId]/StatusPageView.tsx`: + +```tsx +"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 = { + 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. +const OVERALL_TONE: Record = { + 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", +}; + +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 ( +
+

{snap.title || "Status"}

+

+ {snap.reason === "licence_inactive" + ? "This status page is temporarily unavailable." + : "Status pages are not enabled on this instance."} +

+
+ ); + } + + return ( +
+
+ {snap.logo_url ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : null} +
+

{snap.title}

+ {snap.description ? ( +

{snap.description}

+ ) : null} +
+
+ + {snap.banner ? ( +
+ {snap.banner.text} +
+ ) : null} + +
+ {OVERALL_COPY[snap.overall] ?? "Status unknown"} +
+ + {snap.active_incidents.length > 0 ? ( +
+

+ Active +

+
+ {snap.active_incidents.map((i) => ( + + ))} +
+
+ ) : null} + + {snap.upcoming_maintenance.length > 0 ? ( +
+

+ Scheduled maintenance +

+
+ {snap.upcoming_maintenance.map((i) => ( + + ))} +
+
+ ) : null} + + {snap.sections.map((section) => ( +
+

+ {section.name} +

+
+ {section.components.map((c) => ( + + ))} +
+
+ ))} + + {snap.history.length > 0 ? ( +
+

+ Past incidents +

+
+ {snap.history.map((i) => ( + + ))} +
+
+ ) : null} + +
+ Updated {new Date(snap.generated_at).toLocaleString()} +
+
+ ); +} +``` + +**Before writing this, confirm the token class names.** Run `grep -n "^\s*--" web/app/globals.css | head -40` and use the names that are actually defined (`--fg`, `--fg-muted`, `--panel`, `--border`, `--success`, `--warning`, `--danger`, `--accent` above are the expected ones). Do not introduce a hex value if a name differs — fix the class name. + +- [ ] **Step 4: Write the history bar** + +Create `web/components/status/HistoryBar.tsx`: + +```tsx +import type { PublicDay } from "@/lib/api"; + +const TONE: Record = { + up: "bg-success", + down: "bg-danger", + maintenance: "bg-accent", + no_data: "bg-border", +}; + +export default function HistoryBar({ days }: { days: PublicDay[] }) { + return ( + + ); +} +``` + +The bar is `aria-hidden` because the uptime percentage beside it carries the same information in text; 90 announced cells is noise, not accessibility. + +- [ ] **Step 5: Write the component row** + +Create `web/components/status/ComponentRow.tsx`: + +```tsx +import type { PublicComponent } from "@/lib/api"; +import HistoryBar from "./HistoryBar"; + +const LABEL: Record = { + up: "Operational", + down: "Down", + maintenance: "Maintenance", + pending: "Pending", + no_data: "Unknown", +}; + +const DOT: Record = { + up: "bg-success", + down: "bg-danger", + maintenance: "bg-accent", + pending: "bg-warning", + no_data: "bg-border", +}; + +export default function ComponentRow({ component }: { component: PublicComponent }) { + return ( +
+
+ {component.name} + + + {LABEL[component.status] ?? "Unknown"} + +
+
+ +
+
+ 90 days ago + {component.uptime_90d.toFixed(2)}% uptime + Today +
+
+ ); +} +``` + +- [ ] **Step 6: Write the incident card** + +Create `web/components/status/IncidentCard.tsx`: + +```tsx +import type { PublicIncident } from "@/lib/api"; + +export default function IncidentCard({ incident }: { incident: PublicIncident }) { + return ( +
+
+

{incident.title}

+ + {incident.status.replace("_", " ")} + +
+ {incident.affected && incident.affected.length > 0 ? ( +

+ Affects {incident.affected.join(", ")} +

+ ) : null} +

+ {new Date(incident.started_at).toLocaleString()} + {incident.resolved_at + ? ` — resolved ${new Date(incident.resolved_at).toLocaleString()}` + : ""} +

+ {incident.updates && incident.updates.length > 0 ? ( +
    + {incident.updates + .slice() + .reverse() + .map((u, i) => ( +
  1. + + {u.status.replace("_", " ")} + + + {new Date(u.at).toLocaleString()} + +

    {u.body}

    +
  2. + ))} +
+ ) : null} +
+ ); +} +``` + +- [ ] **Step 7: Build and lint** + +Run: `cd web && npm run lint && npm run build` +Expected: both clean. + +- [ ] **Step 8: Browser check** + +Visit `http://.vantage.localhost:3000/status/api`. Confirm: no sidebar, no redirect to `/login`, components render with a 90-cell bar, and the page still renders when signed out in a private window. + +- [ ] **Step 9: Commit** + +```bash +git add web/app/status web/components/status web/next.config.ts +git commit -m "feat: public status page" +``` + +--- + +### Task 10: Authoring UI and API client + +**Files:** +- Modify: `web/lib/api.ts` +- Create: `web/app/(app)/status-pages/page.tsx` +- Create: `web/app/(app)/status-pages/[pageId]/page.tsx` +- Modify: `web/components/Sidebar.tsx:198` + +**Interfaces:** +- Consumes: the ten routes from Task 8; the snapshot types used by Task 9. +- Produces: `api.listStatusPages`, `api.getStatusPage`, `api.createStatusPage`, `api.updateStatusPage`, `api.deleteStatusPage`, `api.listStatusIncidents`, `api.createStatusIncident`, `api.updateStatusIncident`, `api.deleteStatusIncident`, `api.postStatusIncidentUpdate`, and the exported types. + +- [ ] **Step 1: Add the types** + +In `web/lib/api.ts`, near the monitor types: + +```ts +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; +} + +// The public shapes. These mirror services.StatusSnapshot and must change with +// it — the public endpoint is the contract between them. +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 PublicIncident { + id: string; + kind: StatusPageKind; + title: string; + impact?: StatusImpact; + status: string; + affected?: string[]; + started_at: string; + resolved_at?: string; + scheduled_start?: string; + scheduled_end?: string; + updates?: { at: string; status: string; body: string }[]; +} + +export interface StatusSnapshot { + available: boolean; + reason?: string; + title: string; + description?: string; + logo_url?: string; + banner?: { level: string; text: string }; + overall: "up" | "degraded" | "maintenance" | "down"; + sections: PublicSection[]; + active_incidents: PublicIncident[]; + upcoming_maintenance: PublicIncident[]; + history: PublicIncident[]; + generated_at: string; +} +``` + +- [ ] **Step 2: Add the client methods** + +In the `api` object in `web/lib/api.ts`: + +```ts + listStatusPages(): Promise { + return request("/status-pages"); + }, + + getStatusPage(pageId: string): Promise { + return request(`/status-pages/${pageId}`); + }, + + createStatusPage(input: Partial): Promise { + return request("/status-pages", { method: "POST", body: JSON.stringify(input) }); + }, + + updateStatusPage(pageId: string, input: Partial): Promise { + return request(`/status-pages/${pageId}`, { + method: "PUT", + body: JSON.stringify(input), + }); + }, + + deleteStatusPage(pageId: string): Promise { + return request(`/status-pages/${pageId}`, { method: "DELETE" }); + }, + + listStatusIncidents(pageId: string): Promise { + return request(`/status-pages/${pageId}/incidents`); + }, + + createStatusIncident(pageId: string, input: Partial): Promise { + return request(`/status-pages/${pageId}/incidents`, { + method: "POST", + body: JSON.stringify(input), + }); + }, + + updateStatusIncident( + pageId: string, + incidentId: string, + input: Partial, + ): Promise { + return request(`/status-pages/${pageId}/incidents/${incidentId}`, { + method: "PUT", + body: JSON.stringify(input), + }); + }, + + deleteStatusIncident(pageId: string, incidentId: string): Promise { + return request(`/status-pages/${pageId}/incidents/${incidentId}`, { + method: "DELETE", + }); + }, + + postStatusIncidentUpdate( + pageId: string, + incidentId: string, + status: string, + body: string, + ): Promise { + return request(`/status-pages/${pageId}/incidents/${incidentId}/updates`, { + method: "POST", + body: JSON.stringify({ status, body }), + }); + }, +``` + +- [ ] **Step 3: Build the list page** + +Create `web/app/(app)/status-pages/page.tsx`: a TanStack Query list of `api.listStatusPages()` following the structure of `web/app/(app)/monitors/page.tsx`. Each row shows title, `page_id`, a Published or Draft pill, the component count, and a copy-to-clipboard link to `https:///status/`. A "New status page" button opens a modal with fields for `page_id` (validated client-side against `/^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$/`, the same rule as `ValidatePageID`), title, description and logo URL. + +Read `web/app/(app)/monitors/page.tsx` first and follow its query keys, panel classes and modal pattern rather than inventing new ones. + +- [ ] **Step 4: Build the editor page** + +Create `web/app/(app)/status-pages/[pageId]/page.tsx` with three panels: + +1. **Details** — title, description, logo URL, published toggle, banner (enabled, level, text). Saves via `api.updateStatusPage`. +2. **Sections** — add or remove a named section; within each, add monitors from a picker fed by `api.listMonitors()`, with an optional display-name field per entry. Reorder is out of scope for v1; adding to the end is enough. +3. **Incidents** — list from `api.listStatusIncidents(pageId)`, a form to open an incident or schedule maintenance, and a "post update" control on each open one calling `api.postStatusIncidentUpdate`. + +The monitor picker must show the monitor's real name (this is the authenticated side) while making clear the display name is what gets published — label the field "Public name" with the monitor name as its placeholder. + +- [ ] **Step 5: Add the sidebar entry** + +In `web/components/Sidebar.tsx`, in the `Instance` group, above `Audit Log`: + +```tsx + { href: "/status-pages", label: "Status Pages", icon: , adminOnly: true }, +``` + +Add a `StatusIcon` alongside the other icon components in that file, following their `viewBox` and `strokeWidth` conventions. + +- [ ] **Step 6: Build and lint** + +Run: `cd web && npm run lint && npm run build` +Expected: both clean. + +- [ ] **Step 7: Browser check** + +As an owner: create a page, add a section with one monitor and a public name, publish it, open the public URL in a private window and confirm the public name appears rather than the monitor's own. Open an incident, post an update, and confirm it appears on the public page within a few seconds — that verifies cache invalidation. + +As a member: confirm `/status-pages` is absent from the sidebar and that visiting it directly is refused by the API. + +- [ ] **Step 8: Commit** + +```bash +git add web/lib/api.ts web/app/\(app\)/status-pages web/components/Sidebar.tsx +git commit -m "feat: status page authoring UI" +``` + +--- + +### Task 11: Documentation + +**Files:** +- Create: `docsite/docs/vantage/status-pages.md` +- Modify: `docsite/sidebars.ts` +- Modify: `docsite/docs/reference/troubleshooting.md` +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Write the user documentation** + +Create `docsite/docs/vantage/status-pages.md` with front matter matching its neighbours (`id`, `title`, `sidebar_label`). Cover: what a status page is, creating one, the page id and the URL it produces, publishing versus drafting, sections and public names, opening an incident and posting updates, scheduling maintenance, and the fact that the page is completely public and shows no addresses, no error text and no latency. + +State plainly that the licence feature is required, and that a lapsed licence leaves the page serving an explanation rather than data. + +- [ ] **Step 2: Add it to the sidebar** + +In `docsite/sidebars.ts`, add `vantage/status-pages` to the Vantage section, after `vantage/notification-channels`. The sidebar is authored by hand so ordering is a decision. + +- [ ] **Step 3: Add a troubleshooting entry** + +In `docsite/docs/reference/troubleshooting.md`, add a `## A status page 404s or shows no data` section covering: the page is not published; the host is not the instance's own subdomain; the licence lapsed or the tier lacks the feature (page renders an explanation, which is not a fault); a monitor was deleted while still listed on the page, so the component reads Unknown. + +- [ ] **Step 4: Update `CLAUDE.md`** + +Add a `### Status pages` subsection under Subsystems, covering: the two collections; that `assembleSnapshot` is the redaction boundary and `models.Monitor` never reaches an anonymous caller; that the route is mounted outside `/api` on purpose and why; the 30s Redis cache and the per-address limit; that `TRUSTED_PROXIES` is now load-bearing rather than cosmetic; that maintenance does not rewrite rollups; and that auto-incidents are derived rather than copied. + +Add `status_pages` and `status_incidents` to the MongoDB Collections list, and `TRUSTED_PROXIES` to the server environment variable table. + +- [ ] **Step 5: Commit** + +```bash +git add docsite/docs/vantage/status-pages.md docsite/sidebars.ts \ + docsite/docs/reference/troubleshooting.md CLAUDE.md +git commit -m "docs: status pages" +``` + +--- + +## Deferred to Vantage HQ + +The `status_pages` feature must be added to admin's `plans` rows per `(deployment, tier)`. Until that happens every instance reads the feature as absent and every status page renders "not enabled" — the feature ships dark. That work is in the admin service and its plan seeding, not in this plan.