summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-08 09:43:38 +0200
committerPaul Buetow <paul@buetow.org>2026-02-08 09:43:38 +0200
commita5ed6636ad668ae2b9c7e02ea7a6d0e809ba316a (patch)
tree66ba304ec80e0c3c076293c8e2869a9ac7465e0c
parent115f2b371bef591a114a072794116b160fc15907 (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.go198
-rw-r--r--internal/json_report_test.go86
-rw-r--r--internal/run.go3
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