From 21c2bb26467713b55d47000a1a8b90e44621f0a6 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 24 Aug 2026 14:07:10 +0000 Subject: [PATCH] feat: public status snapshot assembly and redaction boundary --- server/internal/services/statussnapshot.go | 373 ++++++++++++++++++ .../internal/services/statussnapshot_test.go | 177 +++++++++ 2 files changed, 550 insertions(+) create mode 100644 server/internal/services/statussnapshot.go create mode 100644 server/internal/services/statussnapshot_test.go diff --git a/server/internal/services/statussnapshot.go b/server/internal/services/statussnapshot.go new file mode 100644 index 0000000..ee5cde1 --- /dev/null +++ b/server/internal/services/statussnapshot.go @@ -0,0 +1,373 @@ +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 +} diff --git a/server/internal/services/statussnapshot_test.go b/server/internal/services/statussnapshot_test.go new file mode 100644 index 0000000..033c075 --- /dev/null +++ b/server/internal/services/statussnapshot_test.go @@ -0,0 +1,177 @@ +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) + } +}