diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-08 09:43:38 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-08 09:43:38 +0200 |
| commit | a5ed6636ad668ae2b9c7e02ea7a6d0e809ba316a (patch) | |
| tree | 66ba304ec80e0c3c076293c8e2869a9ac7465e0c | |
| parent | 115f2b371bef591a114a072794116b160fc15907 (diff) | |
feat: write JSON status report next to HTML
Add a JSON report alongside the HTML status page with matching sections and
summary counts, plus a last-updated timestamp for remote consumption.
Co-authored-by: Cursor <cursoragent@cursor.com>
| -rw-r--r-- | internal/json_report.go | 198 | ||||
| -rw-r--r-- | internal/json_report_test.go | 86 | ||||
| -rw-r--r-- | internal/run.go | 3 |
3 files changed, 287 insertions, 0 deletions
diff --git a/internal/json_report.go b/internal/json_report.go new file mode 100644 index 0000000..411fd1b --- /dev/null +++ b/internal/json_report.go @@ -0,0 +1,198 @@ +package internal + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +type jsonReport struct { + LastUpdated string `json:"lastUpdated"` + Subject string `json:"subject"` + Summary jsonSummary `json:"summary"` + Sections jsonSections `json:"sections"` +} + +type jsonSummary struct { + Critical int `json:"critical"` + Warning int `json:"warning"` + Unknown int `json:"unknown"` + Stale int `json:"stale"` + Suppressed int `json:"suppressed"` + Ok int `json:"ok"` +} + +type jsonSections struct { + StatusChanged []jsonCheck `json:"statusChanged"` + Unhandled []jsonCheck `json:"unhandled"` + Stale []jsonCheck `json:"stale"` + Suppressed []jsonCheck `json:"suppressed"` + Ok []jsonCheck `json:"ok"` +} + +type jsonCheck struct { + Name string `json:"name"` + Status string `json:"status"` + PrevStatus string `json:"prevStatus,omitempty"` + Output string `json:"output"` + FederatedFrom string `json:"federatedFrom,omitempty"` + Epoch int64 `json:"epoch"` + LastCheckedAgeSeconds int64 `json:"lastCheckedAgeSeconds,omitempty"` +} + +func persistJSONReport(state state, subject string, conf config) error { + htmlFile := conf.HTMLStatusFile + if htmlFile == "" { + return nil + } + + jsonFile := jsonReportPath(htmlFile) + jsonDir := filepath.Dir(jsonFile) + if err := os.MkdirAll(jsonDir, 0o755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", jsonDir, err) + } + + tmpFile := jsonFile + ".tmp" + f, err := os.Create(tmpFile) + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + defer f.Close() + + report := state.jsonReport(subject, conf) + encoder := json.NewEncoder(f) + encoder.SetIndent("", " ") + if err := encoder.Encode(report); err != nil { + return fmt.Errorf("failed to write JSON: %w", err) + } + + return os.Rename(tmpFile, jsonFile) +} + +func jsonReportPath(htmlPath string) string { + ext := filepath.Ext(htmlPath) + if ext == "" { + return htmlPath + ".json" + } + return strings.TrimSuffix(htmlPath, ext) + ".json" +} + +func (s state) jsonReport(subject string, conf config) jsonReport { + now := time.Now() + + summary := jsonSummary{ + Critical: s.countBy(conf, func(cs checkState) bool { return cs.Status == nagiosCritical }), + Warning: s.countBy(conf, func(cs checkState) bool { return cs.Status == nagiosWarning }), + Unknown: s.countBy(conf, func(cs checkState) bool { return cs.Status == nagiosUnknown }), + Stale: s.countStale(conf), + Suppressed: s.countSuppressed(conf), + Ok: s.countBy(conf, func(cs checkState) bool { return cs.Status == nagiosOk }), + } + + sections := jsonSections{ + StatusChanged: s.jsonReportChanged(now, conf), + Unhandled: s.jsonReportUnhandled(now, conf), + Stale: s.jsonReportStale(now, conf), + Suppressed: s.jsonReportSuppressed(conf), + Ok: s.jsonReportBy(now, false, false, conf, func(cs checkState) bool { + return cs.Status == nagiosOk + }), + } + + return jsonReport{ + LastUpdated: now.Format(time.RFC3339), + Subject: subject, + Summary: summary, + Sections: sections, + } +} + +func (s state) jsonReportChanged(now time.Time, conf config) []jsonCheck { + var checks []jsonCheck + checks = append(checks, s.jsonReportBy(now, true, false, conf, func(cs checkState) bool { + return cs.Status == nagiosCritical && cs.changed() + })...) + checks = append(checks, s.jsonReportBy(now, true, false, conf, func(cs checkState) bool { + return cs.Status == nagiosWarning && cs.changed() + })...) + checks = append(checks, s.jsonReportBy(now, true, false, conf, func(cs checkState) bool { + return cs.Status == nagiosUnknown && cs.changed() + })...) + checks = append(checks, s.jsonReportBy(now, true, false, conf, func(cs checkState) bool { + return cs.Status == nagiosOk && cs.changed() + })...) + return checks +} + +func (s state) jsonReportUnhandled(now time.Time, conf config) []jsonCheck { + var checks []jsonCheck + checks = append(checks, s.jsonReportBy(now, false, false, conf, func(cs checkState) bool { + return cs.Status == nagiosCritical + })...) + checks = append(checks, s.jsonReportBy(now, false, false, conf, func(cs checkState) bool { + return cs.Status == nagiosWarning + })...) + checks = append(checks, s.jsonReportBy(now, false, false, conf, func(cs checkState) bool { + return cs.Status == nagiosUnknown + })...) + return checks +} + +func (s state) jsonReportStale(now time.Time, conf config) []jsonCheck { + return s.jsonReportBy(now, false, true, conf, func(cs checkState) bool { + return cs.Epoch < s.staleEpoch && cs.Status != nagiosOk + }) +} + +func (s state) jsonReportSuppressed(conf config) []jsonCheck { + var checks []jsonCheck + for name, cs := range s.checks { + if cs.Status == nagiosOk || !isCheckSuppressed(name, conf) { + continue + } + checks = append(checks, jsonCheck{ + Name: name, + Status: nagiosCode(cs.Status).Str(), + Output: cs.Output, + FederatedFrom: cs.FederatedFrom, + Epoch: cs.Epoch, + }) + } + return checks +} + +func (s state) jsonReportBy(now time.Time, showStatusChange, isStaleReport bool, conf config, + filter func(cs checkState) bool, +) []jsonCheck { + var checks []jsonCheck + for name, cs := range s.checks { + if !filter(cs) { + continue + } + if !isStaleReport && cs.Epoch < s.staleEpoch { + continue + } + if cs.Status != nagiosOk && isCheckSuppressed(name, conf) { + continue + } + + entry := jsonCheck{ + Name: name, + Status: nagiosCode(cs.Status).Str(), + Output: cs.Output, + FederatedFrom: cs.FederatedFrom, + Epoch: cs.Epoch, + } + if showStatusChange && cs.changed() { + entry.PrevStatus = nagiosCode(cs.PrevStatus).Str() + } + if isStaleReport { + entry.LastCheckedAgeSeconds = int64(now.Sub(time.Unix(cs.Epoch, 0)).Seconds()) + } + checks = append(checks, entry) + } + return checks +} diff --git a/internal/json_report_test.go b/internal/json_report_test.go new file mode 100644 index 0000000..1d79c51 --- /dev/null +++ b/internal/json_report_test.go @@ -0,0 +1,86 @@ +package internal + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestPersistJSONReport(t *testing.T) { + tmpDir := t.TempDir() + htmlFile := filepath.Join(tmpDir, "status.html") + conf := config{HTMLStatusFile: htmlFile} + + now := time.Now().Unix() + s := state{ + checks: map[string]checkState{ + "CriticalCheck": { + Status: nagiosCritical, + PrevStatus: nagiosOk, + Epoch: now, + Output: "boom", + }, + "StaleCheck": { + Status: nagiosWarning, + PrevStatus: nagiosWarning, + Epoch: now - 1000, + Output: "stale warning", + }, + "OkCheck": { + Status: nagiosOk, + PrevStatus: nagiosOk, + Epoch: now, + Output: "all good", + }, + }, + staleEpoch: now - 100, + } + + subject := "GOGIOS Report [C:1 W:1 U:0 S:1 SU:0 OK:1]" + if err := persistJSONReport(s, subject, conf); err != nil { + t.Fatalf("persistJSONReport() error = %v", err) + } + + jsonFile := filepath.Join(tmpDir, "status.json") + data, err := os.ReadFile(jsonFile) + if err != nil { + t.Fatalf("failed to read JSON file: %v", err) + } + + var report jsonReport + if err := json.Unmarshal(data, &report); err != nil { + t.Fatalf("failed to unmarshal JSON report: %v", err) + } + + if report.LastUpdated == "" { + t.Fatal("lastUpdated is empty") + } + if _, err := time.Parse(time.RFC3339, report.LastUpdated); err != nil { + t.Fatalf("lastUpdated is not RFC3339: %v", err) + } + if report.Subject != subject { + t.Fatalf("subject = %q, want %q", report.Subject, subject) + } + + if report.Summary.Critical != 1 || report.Summary.Warning != 1 || report.Summary.Stale != 1 || report.Summary.Ok != 1 { + t.Fatalf("unexpected summary: %+v", report.Summary) + } + + if len(report.Sections.StatusChanged) != 1 || report.Sections.StatusChanged[0].Name != "CriticalCheck" { + t.Fatalf("unexpected statusChanged: %+v", report.Sections.StatusChanged) + } + if len(report.Sections.Unhandled) != 1 || report.Sections.Unhandled[0].Name != "CriticalCheck" { + t.Fatalf("unexpected unhandled: %+v", report.Sections.Unhandled) + } + if len(report.Sections.Stale) != 1 || report.Sections.Stale[0].Name != "StaleCheck" { + t.Fatalf("unexpected stale: %+v", report.Sections.Stale) + } + if len(report.Sections.Ok) != 1 || report.Sections.Ok[0].Name != "OkCheck" { + t.Fatalf("unexpected ok: %+v", report.Sections.Ok) + } + if len(report.Sections.Suppressed) != 0 { + t.Fatalf("unexpected suppressed: %+v", report.Sections.Suppressed) + } +} diff --git a/internal/run.go b/internal/run.go index 63ee16f..21b9b6d 100644 --- a/internal/run.go +++ b/internal/run.go @@ -76,6 +76,9 @@ func Run(ctx context.Context, configFile string, renotify, force bool) error { if err := persistHTMLReport(state, subject, conf); err != nil { notifyError(conf, err) } + if err := persistJSONReport(state, subject, conf); err != nil { + notifyError(conf, err) + } } return nil |
