feat: public status snapshot assembly and redaction boundary

This commit is contained in:
2026-08-24 14:07:10 +00:00
parent 9c0bbd13dd
commit 21c2bb2646
2 changed files with 550 additions and 0 deletions
+373
View File
@@ -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
}
@@ -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)
}
}