feat: public status snapshot assembly and redaction boundary
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user