summaryrefslogtreecommitdiff
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
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>
-rw-r--r--cmd/gogios/main.go2
-rw-r--r--internal/config.go7
-rw-r--r--internal/html.go290
-rw-r--r--internal/html_test.go496
-rw-r--r--internal/run.go7
5 files changed, 801 insertions, 1 deletions
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(`<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)
+}
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, `<span class="OK">OK</span>`},
+ {nagiosWarning, `<span class="WARNING">WARNING</span>`},
+ {nagiosCritical, `<span class="CRITICAL">CRITICAL</span>`},
+ {nagiosUnknown, `<span class="UNKNOWN">UNKNOWN</span>`},
+ }
+
+ 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{
+ "<!DOCTYPE html>",
+ `<html lang="en">`,
+ "<head>",
+ `<meta charset="UTF-8">`,
+ `<meta name="viewport"`,
+ "<title>",
+ subject,
+ `<meta http-equiv="refresh" content="300">`,
+ "<style>",
+ ".CRITICAL { color: #dc3545; }",
+ ".WARNING { color: #ff8c00; }",
+ "Gogios Status Report",
+ "C:1 W:2 U:3 S:4 OK:5",
+ "Last Updated:",
+ }
+
+ for _, elem := range expectedElements {
+ if !strings.Contains(result, elem) {
+ t.Errorf("htmlHeader() missing expected element: %q", elem)
+ }
+ }
+}
+
+// TestHtmlFooter tests the htmlFooter function.
+func TestHtmlFooter(t *testing.T) {
+ result := htmlFooter()
+
+ expectedElements := []string{
+ "Generated by Gogios at",
+ "</div>",
+ "</body>",
+ "</html>",
+ }
+
+ for _, elem := range expectedElements {
+ if !strings.Contains(result, elem) {
+ t.Errorf("htmlFooter() missing expected element: %q", elem)
+ }
+ }
+}
+
+// TestHtmlReportBy tests the htmlReportBy method with various filters.
+func TestHtmlReportBy(t *testing.T) {
+ now := time.Now().Unix()
+ staleEpoch := now - 7200 // 2 hours ago
+
+ s := state{
+ staleEpoch: staleEpoch,
+ checks: map[string]checkState{
+ "check1": {
+ Status: nagiosCritical,
+ PrevStatus: nagiosOk,
+ Epoch: now,
+ output: "Service is down",
+ },
+ "check2": {
+ Status: nagiosWarning,
+ PrevStatus: nagiosWarning,
+ Epoch: now,
+ output: "High load",
+ },
+ "check3": {
+ Status: nagiosOk,
+ PrevStatus: nagiosOk,
+ Epoch: staleEpoch - 100, // stale
+ output: "All good",
+ },
+ },
+ }
+
+ // Test critical filter
+ var sb strings.Builder
+ count := s.htmlReportBy(&sb, false, false, func(cs checkState) bool {
+ return cs.Status == nagiosCritical
+ })
+
+ if count != 1 {
+ t.Errorf("htmlReportBy(critical filter) count = %d, want 1", count)
+ }
+
+ result := sb.String()
+ if !strings.Contains(result, "check1") {
+ t.Error("htmlReportBy(critical filter) missing check1")
+ }
+ if !strings.Contains(result, "Service is down") {
+ t.Error("htmlReportBy(critical filter) missing output text")
+ }
+ if !strings.Contains(result, "CRITICAL") {
+ t.Error("htmlReportBy(critical filter) missing CRITICAL status")
+ }
+
+ // Test status change filter
+ sb.Reset()
+ count = s.htmlReportBy(&sb, true, false, func(cs checkState) bool {
+ return cs.Status == nagiosCritical && cs.changed()
+ })
+
+ if count != 1 {
+ t.Errorf("htmlReportBy(changed filter) count = %d, want 1", count)
+ }
+
+ result = sb.String()
+ if !strings.Contains(result, "→") {
+ t.Error("htmlReportBy(changed filter) missing status change arrow")
+ }
+ if !strings.Contains(result, "OK") {
+ t.Error("htmlReportBy(changed filter) missing previous OK status")
+ }
+}
+
+// TestHtmlReportChanged tests the htmlReportChanged method.
+func TestHtmlReportChanged(t *testing.T) {
+ now := time.Now().Unix()
+
+ s := state{
+ staleEpoch: now - 3600,
+ checks: map[string]checkState{
+ "check1": {
+ Status: nagiosCritical,
+ PrevStatus: nagiosOk,
+ Epoch: now,
+ output: "Service failed",
+ },
+ "check2": {
+ Status: nagiosOk,
+ PrevStatus: nagiosOk,
+ Epoch: now,
+ output: "Still OK",
+ },
+ },
+ }
+
+ var sb strings.Builder
+ changed := s.htmlReportChanged(&sb)
+
+ if !changed {
+ t.Error("htmlReportChanged() returned false, want true")
+ }
+
+ result := sb.String()
+ if !strings.Contains(result, "check1") {
+ t.Error("htmlReportChanged() missing changed check")
+ }
+ if strings.Contains(result, "check2") {
+ t.Error("htmlReportChanged() should not include unchanged check")
+ }
+}
+
+// TestHtmlReport tests the complete HTML report generation.
+func TestHtmlReport(t *testing.T) {
+ now := time.Now().Unix()
+ staleEpoch := now - 3600
+
+ s := state{
+ staleEpoch: staleEpoch,
+ checks: map[string]checkState{
+ "critical_check": {
+ Status: nagiosCritical,
+ PrevStatus: nagiosOk,
+ Epoch: now,
+ output: "Service is down",
+ },
+ "warning_check": {
+ Status: nagiosWarning,
+ PrevStatus: nagiosWarning,
+ Epoch: now,
+ output: "High CPU usage",
+ },
+ "ok_check": {
+ Status: nagiosOk,
+ PrevStatus: nagiosOk,
+ Epoch: now,
+ output: "Everything fine",
+ },
+ "stale_check": {
+ Status: nagiosOk,
+ PrevStatus: nagiosOk,
+ Epoch: staleEpoch - 100,
+ output: "Not updated recently",
+ },
+ },
+ }
+
+ subject := "GOGIOS Report [C:1 W:1 U:0 S:1 OK:2]"
+ result := s.htmlReport(subject)
+
+ // Check that all major sections are present
+ expectedSections := []string{
+ "<!DOCTYPE html>",
+ "Gogios Status Report",
+ "Alerts with status changed",
+ "Unhandled alerts",
+ "Stale alerts",
+ "Generated by Gogios",
+ "</html>",
+ }
+
+ for _, section := range expectedSections {
+ if !strings.Contains(result, section) {
+ t.Errorf("htmlReport() missing section: %q", section)
+ }
+ }
+
+ // Check that check names appear
+ if !strings.Contains(result, "critical_check") {
+ t.Error("htmlReport() missing critical_check")
+ }
+ if !strings.Contains(result, "warning_check") {
+ t.Error("htmlReport() missing warning_check")
+ }
+
+ // Check status summary
+ if !strings.Contains(result, "C:1 W:1 U:0 S:1 OK:2") {
+ t.Error("htmlReport() missing correct status summary")
+ }
+}
+
+// TestHtmlEscaping tests that HTML special characters are properly escaped.
+func TestHtmlEscaping(t *testing.T) {
+ now := time.Now().Unix()
+
+ s := state{
+ staleEpoch: now - 3600,
+ checks: map[string]checkState{
+ "<script>alert('xss')</script>": {
+ Status: nagiosCritical,
+ PrevStatus: nagiosOk,
+ Epoch: now,
+ output: "Output with <tags> & \"quotes\"",
+ },
+ },
+ }
+
+ var sb strings.Builder
+ s.htmlReportBy(&sb, false, false, func(cs checkState) bool {
+ return cs.Status == nagiosCritical
+ })
+
+ result := sb.String()
+
+ // Check that HTML is escaped
+ if strings.Contains(result, "<script>") {
+ t.Error("htmlReportBy() did not escape <script> tag in check name")
+ }
+ if !strings.Contains(result, "&lt;script&gt;") {
+ t.Error("htmlReportBy() missing escaped <script> tag")
+ }
+ if strings.Contains(result, "<tags>") && !strings.Contains(result, "&lt;tags&gt;") {
+ t.Error("htmlReportBy() did not escape <tags> in output")
+ }
+}
+
+// TestPersistHTMLReport tests the file creation and atomic write.
+func TestPersistHTMLReport(t *testing.T) {
+ // Create a temporary directory for testing
+ tmpDir := t.TempDir()
+ htmlFile := filepath.Join(tmpDir, "subdir", "status.html")
+
+ conf := config{
+ HTMLStatusFile: htmlFile,
+ }
+
+ now := time.Now().Unix()
+ s := state{
+ staleEpoch: now - 3600,
+ checks: map[string]checkState{
+ "test_check": {
+ Status: nagiosCritical,
+ PrevStatus: nagiosOk,
+ Epoch: now,
+ output: "Test output",
+ },
+ },
+ }
+
+ subject := "GOGIOS Report [C:1 W:0 U:0 S:0 OK:0]"
+
+ // Test that the function creates the directory and file
+ err := persistHTMLReport(s, subject, conf)
+ if err != nil {
+ t.Fatalf("persistHTMLReport() error = %v", err)
+ }
+
+ // Check that the file was created
+ if _, err := os.Stat(htmlFile); os.IsNotExist(err) {
+ t.Error("persistHTMLReport() did not create the HTML file")
+ }
+
+ // Check that the temp file was removed
+ tmpFile := htmlFile + ".tmp"
+ if _, err := os.Stat(tmpFile); !os.IsNotExist(err) {
+ t.Error("persistHTMLReport() did not clean up temporary file")
+ }
+
+ // Read and verify the content
+ content, err := os.ReadFile(htmlFile)
+ if err != nil {
+ t.Fatalf("Failed to read HTML file: %v", err)
+ }
+
+ contentStr := string(content)
+ if !strings.Contains(contentStr, "<!DOCTYPE html>") {
+ t.Error("HTML file missing DOCTYPE declaration")
+ }
+ if !strings.Contains(contentStr, "test_check") {
+ t.Error("HTML file missing test check")
+ }
+ if !strings.Contains(contentStr, "Test output") {
+ t.Error("HTML file missing check output")
+ }
+ if !strings.Contains(contentStr, "C:1 W:0 U:0 S:0 OK:0") {
+ t.Error("HTML file missing status summary")
+ }
+}
+
+// TestFederatedChecks tests HTML generation for federated checks.
+func TestFederatedChecks(t *testing.T) {
+ now := time.Now().Unix()
+
+ s := state{
+ staleEpoch: now - 3600,
+ checks: map[string]checkState{
+ "remote_check": {
+ Status: nagiosCritical,
+ PrevStatus: nagiosOk,
+ Epoch: now,
+ output: "Remote service down",
+ federatedFrom: "remote.example.com",
+ },
+ },
+ }
+
+ var sb strings.Builder
+ s.htmlReportBy(&sb, false, false, func(cs checkState) bool {
+ return cs.Status == nagiosCritical
+ })
+
+ result := sb.String()
+
+ if !strings.Contains(result, "federated from") {
+ t.Error("htmlReportBy() missing federated indicator")
+ }
+ if !strings.Contains(result, "remote.example.com") {
+ t.Error("htmlReportBy() missing federated hostname")
+ }
+}
+
+// TestW3CCompliance tests that generated HTML meets W3C HTML5 standards.
+func TestW3CCompliance(t *testing.T) {
+ now := time.Now().Unix()
+
+ s := state{
+ staleEpoch: now - 3600,
+ checks: map[string]checkState{
+ "test_check": {
+ Status: nagiosCritical,
+ PrevStatus: nagiosOk,
+ Epoch: now,
+ output: "Test output",
+ },
+ },
+ }
+
+ subject := "GOGIOS Report [C:1 W:0 U:0 S:0 OK:0]"
+ html := s.htmlReport(subject)
+
+ // W3C HTML5 Required Elements
+ requiredElements := map[string]string{
+ "DOCTYPE": "<!DOCTYPE html>",
+ "html with lang": `<html lang="en">`,
+ "charset meta": `<meta charset="UTF-8">`,
+ "viewport meta": `<meta name="viewport"`,
+ "title": "<title>",
+ "closing html": "</html>",
+ "closing head": "</head>",
+ "closing body": "</body>",
+ "proper html close": "</html>",
+ }
+
+ for name, elem := range requiredElements {
+ if !strings.Contains(html, elem) {
+ t.Errorf("W3C compliance check failed: missing %s (%q)", name, elem)
+ }
+ }
+
+ // Check that DOCTYPE is at the very beginning
+ if !strings.HasPrefix(html, "<!DOCTYPE html>") {
+ t.Error("W3C compliance: DOCTYPE must be the first line")
+ }
+
+ // Check proper tag nesting (basic validation)
+ htmlTagStart := strings.Index(html, "<html")
+ htmlTagEnd := strings.LastIndex(html, "</html>")
+ if htmlTagStart == -1 || htmlTagEnd == -1 {
+ t.Error("W3C compliance: missing <html> or </html> tag")
+ }
+ if htmlTagStart > htmlTagEnd {
+ t.Error("W3C compliance: </html> appears before <html>")
+ }
+
+ headStart := strings.Index(html, "<head>")
+ headEnd := strings.Index(html, "</head>")
+ if headStart == -1 || headEnd == -1 {
+ t.Error("W3C compliance: missing <head> or </head> tag")
+ }
+ if headStart > headEnd {
+ t.Error("W3C compliance: </head> appears before <head>")
+ }
+
+ bodyStart := strings.Index(html, "<body>")
+ bodyEnd := strings.LastIndex(html, "</body>")
+ if bodyStart == -1 || bodyEnd == -1 {
+ t.Error("W3C compliance: missing <body> or </body> tag")
+ }
+ if bodyStart > bodyEnd {
+ t.Error("W3C compliance: </body> appears before <body>")
+ }
+
+ // Head should come before body
+ if headEnd > bodyStart {
+ t.Error("W3C compliance: <head> section should be closed before <body> starts")
+ }
+
+ // Check for proper character encoding in meta tag
+ if !strings.Contains(html, `charset="UTF-8"`) {
+ t.Error("W3C compliance: charset should be UTF-8")
+ }
+
+ // Verify no common HTML errors
+ commonErrors := []string{
+ "<<", // Double tag opening
+ ">>", // Double tag closing
+ "< ", // Space after tag opening
+ " >", // Space before tag closing (in tag name)
+ "<//>", // Malformed closing tag
+ "</ >", // Empty closing tag
+ "< >", // Empty tag
+ }
+
+ for _, err := range commonErrors {
+ if strings.Contains(html, err) {
+ t.Errorf("W3C compliance: found malformed HTML pattern: %q", err)
+ }
+ }
+
+ // Verify all div tags are properly closed
+ divOpen := strings.Count(html, "<div")
+ divClose := strings.Count(html, "</div>")
+ if divOpen != divClose {
+ t.Errorf("W3C compliance: mismatched div tags (open: %d, close: %d)", divOpen, divClose)
+ }
+
+ // Verify all span tags are properly closed
+ spanOpen := strings.Count(html, "<span")
+ spanClose := strings.Count(html, "</span>")
+ if spanOpen != spanClose {
+ t.Errorf("W3C compliance: mismatched span tags (open: %d, close: %d)", spanOpen, spanClose)
+ }
+}
diff --git a/internal/run.go b/internal/run.go
index 91fe323..eed8ad5 100644
--- a/internal/run.go
+++ b/internal/run.go
@@ -39,6 +39,13 @@ func Run(ctx context.Context, configFile string, renotify, force bool) {
if err := persistReport(subject, body, conf); err != nil {
notifyError(conf, err)
}
+
+ // Generate HTML status page (unless disabled)
+ if !conf.HTMLDisable {
+ if err := persistHTMLReport(state, subject, conf); err != nil {
+ notifyError(conf, err)
+ }
+ }
}
func persistReport(subject, body string, conf config) error {