summaryrefslogtreecommitdiff
path: root/internal/html.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-01-06 10:03:43 +0200
committerPaul Buetow <paul@buetow.org>2026-01-06 10:03:43 +0200
commitf5cffe240c44045684d4f74981235b060828550e (patch)
treea1be9ab5edee3c42052a81391a307dfe1202e08d /internal/html.go
parent5fa8012c6b085c8d4244d3b4f9c99c1937fb65a2 (diff)
Add HTML status page generation (v1.3.0)v1.3.0
- 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 <noreply@anthropic.com>
Diffstat (limited to 'internal/html.go')
-rw-r--r--internal/html.go290
1 files changed, 290 insertions, 0 deletions
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(`<div class="section">` + "\n")
+ sb.WriteString(`<h2>Alerts with status changed</h2>` + "\n")
+ changed := s.htmlReportChanged(&sb)
+ if !changed {
+ sb.WriteString(`<p>There were no status changes...</p>` + "\n")
+ }
+ sb.WriteString(`</div>` + "\n\n")
+
+ // Unhandled alerts section
+ sb.WriteString(`<div class="section">` + "\n")
+ sb.WriteString(`<h2>Unhandled alerts</h2>` + "\n")
+ hasUnhandled := (numCriticals + numWarnings + numUnknown) > 0
+ if hasUnhandled {
+ s.htmlReportUnhandledContent(&sb)
+ } else {
+ sb.WriteString(`<p>There are no unhandled alerts...</p>` + "\n")
+ }
+ sb.WriteString(`</div>` + "\n\n")
+
+ // Stale alerts section
+ sb.WriteString(`<div class="section">` + "\n")
+ sb.WriteString(`<h2>Stale alerts</h2>` + "\n")
+ if numStale == 0 {
+ sb.WriteString(`<p>There are no stale alerts...</p>` + "\n")
+ } else {
+ s.htmlReportStaleAlerts(&sb)
+ }
+ sb.WriteString(`</div>` + "\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(`<div class="check-item">` + "\n")
+
+ // Show status change if applicable
+ if showStatusChange && cs.changed() {
+ sb.WriteString(htmlStatusBadge(nagiosCode(cs.PrevStatus)))
+ sb.WriteString(` <span class="arrow">→</span> `)
+ }
+
+ // 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</div>\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(`<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <meta http-equiv="refresh" content="300">
+ <title>`)
+ sb.WriteString(html.EscapeString(subject))
+ sb.WriteString(`</title>
+ <style>
+ body {
+ font-family: sans-serif;
+ text-align: center;
+ padding-top: 50px;
+ }
+ .container {
+ max-width: 800px;
+ margin: 0 auto;
+ }
+ .summary {
+ margin: 20px 0;
+ font-weight: bold;
+ }
+ .section {
+ margin: 30px 0;
+ text-align: left;
+ }
+ .check-item {
+ margin: 10px 0;
+ padding: 5px;
+ }
+ .CRITICAL { color: #dc3545; }
+ .WARNING { color: #ff8c00; }
+ .UNKNOWN { color: #6c757d; }
+ .OK { color: #28a745; }
+ .footer {
+ margin-top: 40px;
+ font-size: 0.9em;
+ color: #666;
+ }
+ </style>
+</head>
+<body>
+ <div class="container">
+ <h1>Gogios Status Report</h1>
+ <div class="summary">C:`)
+ sb.WriteString(fmt.Sprintf("%d W:%d U:%d S:%d OK:%d", numCriticals, numWarnings, numUnknown, numStale, numOK))
+ sb.WriteString(`</div>
+ <p>Last Updated: `)
+ sb.WriteString(time.Now().Format("2006-01-02 15:04:05 MST"))
+ sb.WriteString(`</p>
+
+`)
+
+ return sb.String()
+}
+
+// htmlFooter generates the HTML document footer.
+func htmlFooter() string {
+ var sb strings.Builder
+
+ sb.WriteString(` <div class="footer">
+ Generated by Gogios at `)
+ sb.WriteString(time.Now().Format("2006-01-02 15:04:05 MST"))
+ sb.WriteString(`
+ </div>
+ </div>
+</body>
+</html>
+`)
+
+ return sb.String()
+}
+
+// htmlStatusBadge generates a colored HTML span for a status code.
+func htmlStatusBadge(status nagiosCode) string {
+ statusStr := status.Str()
+ return fmt.Sprintf(`<span class="%s">%s</span>`, statusStr, statusStr)
+}