From f5cffe240c44045684d4f74981235b060828550e Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Tue, 6 Jan 2026 10:03:43 +0200 Subject: Add HTML status page generation (v1.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add configurable HTML status page generation after each check-run - Default output: /var/www/htdocs/buetow.org/self/gogios/index.html - New config options: HTMLStatusFile (path) and HTMLDisable (bool) - Auto-creates output directory if it doesn't exist - Uses atomic writes (tmp file + rename) to prevent corruption - Auto-refresh every 5 minutes via meta tag - Minimal, clean styling based on f3s_fallback template - W3C HTML5 compliant output - Email notifications on I/O errors via existing notifyError mechanism - Mirrors email report structure (status changes, unhandled alerts, stale alerts) - Comprehensive unit tests including W3C compliance validation - All user-generated content properly HTML-escaped for security 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- cmd/gogios/main.go | 2 +- internal/config.go | 7 + internal/html.go | 290 +++++++++++++++++++++++++++++ internal/html_test.go | 496 ++++++++++++++++++++++++++++++++++++++++++++++++++ internal/run.go | 7 + 5 files changed, 801 insertions(+), 1 deletion(-) create mode 100644 internal/html.go create mode 100644 internal/html_test.go diff --git a/cmd/gogios/main.go b/cmd/gogios/main.go index d223eb6..580d150 100644 --- a/cmd/gogios/main.go +++ b/cmd/gogios/main.go @@ -9,7 +9,7 @@ import ( "codeberg.org/snonux/gogios/internal" ) -const versionStr = "v1.2.1" +const versionStr = "v1.3.0" func main() { configFile := flag.String("cfg", "/etc/gogios.json", "The config file") diff --git a/internal/config.go b/internal/config.go index 740b19b..2ade802 100644 --- a/internal/config.go +++ b/internal/config.go @@ -14,6 +14,8 @@ type config struct { SMTPServer string `json:"SMTPServer,omitempty"` SMTPDisable bool `json:"SMTPDisable,omitempty"` // TODO: Document this option StateDir string `json:"StateDir,omitempty"` + HTMLStatusFile string `json:"HTMLStatusFile,omitempty"` // Path to HTML status file + HTMLDisable bool `json:"HTMLDisable,omitempty"` // Disable HTML status page generation CheckTimeoutS int CheckConcurrency int StaleThreshold int `json:"StaleThreshold,omitempty"` @@ -58,6 +60,11 @@ func newConfig(configFile string) (config, error) { conf.StaleThreshold = 3600 // Default to 1 hour } + if !conf.HTMLDisable && conf.HTMLStatusFile == "" { + conf.HTMLStatusFile = "/var/www/htdocs/buetow.org/self/gogios/index.html" + log.Println("Set HTMLStatusFile to " + conf.HTMLStatusFile) + } + return conf, nil } diff --git a/internal/html.go b/internal/html.go new file mode 100644 index 0000000..d453124 --- /dev/null +++ b/internal/html.go @@ -0,0 +1,290 @@ +package internal + +import ( + "fmt" + "html" + "os" + "path/filepath" + "strings" + "time" +) + +// persistHTMLReport generates and persists the HTML status page. +// Mirrors persistReport() pattern from run.go with atomic write. +func persistHTMLReport(state state, subject string, conf config) error { + htmlFile := conf.HTMLStatusFile + htmlDir := filepath.Dir(htmlFile) + + // Auto-create directory if it doesn't exist + // CLAUDE: Only create it when it doesnt exist yet + if err := os.MkdirAll(htmlDir, 0o755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", htmlDir, err) + } + + tmpFile := htmlFile + ".tmp" + + f, err := os.Create(tmpFile) + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + defer f.Close() + + htmlContent := state.htmlReport(subject) + if _, err = f.WriteString(htmlContent); err != nil { + return fmt.Errorf("failed to write HTML: %w", err) + } + + return os.Rename(tmpFile, htmlFile) +} + +// htmlReport generates the complete HTML status page. +// Mirrors state.report() pattern from state.go:133-163. +func (s state) htmlReport(subject string) string { + var sb strings.Builder + + // Calculate counts for header summary (without generating HTML yet) + numCriticals := s.countBy(func(cs checkState) bool { + return cs.Status == nagiosCritical + }) + numWarnings := s.countBy(func(cs checkState) bool { + return cs.Status == nagiosWarning + }) + numUnknown := s.countBy(func(cs checkState) bool { + return cs.Status == nagiosUnknown + }) + numOK := s.countBy(func(cs checkState) bool { + return cs.Status == nagiosOk + }) + numStale := s.countStale() + + // Write HTML header with summary + sb.WriteString(htmlHeader(subject, numCriticals, numWarnings, numUnknown, numStale, numOK)) + + // Alerts with status changed section + sb.WriteString(`
` + "\n") + sb.WriteString(`

Alerts with status changed

` + "\n") + changed := s.htmlReportChanged(&sb) + if !changed { + sb.WriteString(`

There were no status changes...

` + "\n") + } + sb.WriteString(`
` + "\n\n") + + // Unhandled alerts section + sb.WriteString(`
` + "\n") + sb.WriteString(`

Unhandled alerts

` + "\n") + hasUnhandled := (numCriticals + numWarnings + numUnknown) > 0 + if hasUnhandled { + s.htmlReportUnhandledContent(&sb) + } else { + sb.WriteString(`

There are no unhandled alerts...

` + "\n") + } + sb.WriteString(`
` + "\n\n") + + // Stale alerts section + sb.WriteString(`
` + "\n") + sb.WriteString(`

Stale alerts

` + "\n") + if numStale == 0 { + sb.WriteString(`

There are no stale alerts...

` + "\n") + } else { + s.htmlReportStaleAlerts(&sb) + } + sb.WriteString(`
` + "\n\n") + + sb.WriteString(htmlFooter()) + + return sb.String() +} + +// htmlReportChanged generates HTML for checks with status changes. +// Mirrors state.reportChanged() from state.go:166-192. +func (s state) htmlReportChanged(sb *strings.Builder) (changed bool) { + if 0 < s.htmlReportBy(sb, true, false, func(cs checkState) bool { + return cs.Status == nagiosCritical && cs.changed() + }) { + changed = true + } + + if 0 < s.htmlReportBy(sb, true, false, func(cs checkState) bool { + return cs.Status == nagiosWarning && cs.changed() + }) { + changed = true + } + + if 0 < s.htmlReportBy(sb, true, false, func(cs checkState) bool { + return cs.Status == nagiosUnknown && cs.changed() + }) { + changed = true + } + + if 0 < s.htmlReportBy(sb, true, false, func(cs checkState) bool { + return cs.Status == nagiosOk && cs.changed() + }) { + changed = true + } + + return +} + +// htmlReportUnhandledContent generates HTML content for unhandled alerts section. +// Mirrors state.reportUnhandled() from state.go:194-214. +func (s state) htmlReportUnhandledContent(sb *strings.Builder) { + s.htmlReportBy(sb, false, false, func(cs checkState) bool { + return cs.Status == nagiosCritical + }) + + s.htmlReportBy(sb, false, false, func(cs checkState) bool { + return cs.Status == nagiosWarning + }) + + s.htmlReportBy(sb, false, false, func(cs checkState) bool { + return cs.Status == nagiosUnknown + }) +} + +// htmlReportStaleAlerts generates HTML for stale checks. +// Mirrors state.reportStaleAlerts() from state.go:216-220. +func (s state) htmlReportStaleAlerts(sb *strings.Builder) int { + return s.htmlReportBy(sb, false, true, func(cs checkState) bool { + return cs.Epoch < s.staleEpoch + }) +} + +// htmlReportBy is the generic HTML generator for check items. +// Mirrors state.reportBy() from state.go:222-262 but outputs HTML. +func (s state) htmlReportBy(sb *strings.Builder, showStatusChange, isStaleReport bool, + filter func(cs checkState) bool, +) (count int) { + for name, cs := range s.checks { + if !filter(cs) { + continue + } + if !isStaleReport && cs.Epoch < s.staleEpoch { + continue // skip stale checks in non-stale report + } + count++ + + sb.WriteString(`
` + "\n") + + // Show status change if applicable + if showStatusChange && cs.changed() { + sb.WriteString(htmlStatusBadge(nagiosCode(cs.PrevStatus))) + sb.WriteString(` → `) + } + + // Show current status + sb.WriteString(htmlStatusBadge(nagiosCode(cs.Status))) + sb.WriteString(": ") + sb.WriteString(html.EscapeString(name)) + sb.WriteString(": ") + sb.WriteString(html.EscapeString(cs.output)) + + // Show federated source if applicable + if cs.federated() { + sb.WriteString(" [federated from ") + sb.WriteString(html.EscapeString(cs.federatedFrom)) + sb.WriteString("]") + } + + // Show stale duration if applicable + if isStaleReport { + lastCheckedAgo := time.Since(time.Unix(cs.Epoch, 0)) + sb.WriteString(fmt.Sprintf(" (last checked %v ago)", lastCheckedAgo)) + } + + sb.WriteString("\n
\n") + } + + return +} + +// countStale counts the number of stale checks. +// Helper function for generating summary counts. +func (s state) countStale() int { + return s.countBy(func(cs checkState) bool { + return cs.Epoch < s.staleEpoch + }) +} + +// htmlHeader generates the HTML document header with embedded CSS and status summary. +func htmlHeader(subject string, numCriticals, numWarnings, numUnknown, numStale, numOK int) string { + var sb strings.Builder + + sb.WriteString(` + + + + + + `) + sb.WriteString(html.EscapeString(subject)) + sb.WriteString(` + + + +
+

Gogios Status Report

+
C:`) + sb.WriteString(fmt.Sprintf("%d W:%d U:%d S:%d OK:%d", numCriticals, numWarnings, numUnknown, numStale, numOK)) + sb.WriteString(`
+

Last Updated: `) + sb.WriteString(time.Now().Format("2006-01-02 15:04:05 MST")) + sb.WriteString(`

+ +`) + + return sb.String() +} + +// htmlFooter generates the HTML document footer. +func htmlFooter() string { + var sb strings.Builder + + sb.WriteString(` +
+ + +`) + + return sb.String() +} + +// htmlStatusBadge generates a colored HTML span for a status code. +func htmlStatusBadge(status nagiosCode) string { + statusStr := status.Str() + return fmt.Sprintf(`%s`, statusStr, statusStr) +} diff --git a/internal/html_test.go b/internal/html_test.go new file mode 100644 index 0000000..b22eea4 --- /dev/null +++ b/internal/html_test.go @@ -0,0 +1,496 @@ +package internal + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestHtmlStatusBadge tests the htmlStatusBadge function. +func TestHtmlStatusBadge(t *testing.T) { + tests := []struct { + status nagiosCode + expected string + }{ + {nagiosOk, `OK`}, + {nagiosWarning, `WARNING`}, + {nagiosCritical, `CRITICAL`}, + {nagiosUnknown, `UNKNOWN`}, + } + + for _, tt := range tests { + result := htmlStatusBadge(tt.status) + if result != tt.expected { + t.Errorf("htmlStatusBadge(%v) = %q, want %q", tt.status, result, tt.expected) + } + } +} + +// TestHtmlHeader tests the htmlHeader function. +func TestHtmlHeader(t *testing.T) { + subject := "GOGIOS Report [C:1 W:2 U:3 S:4 OK:5]" + result := htmlHeader(subject, 1, 2, 3, 4, 5) + + // Check that the header contains expected elements + expectedElements := []string{ + "", + ``, + "", + ``, + `", + subject, + ``, + "