diff options
| author | Paul Buetow <paul@buetow.org> | 2026-01-06 10:03:43 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-01-06 10:03:43 +0200 |
| commit | f5cffe240c44045684d4f74981235b060828550e (patch) | |
| tree | a1be9ab5edee3c42052a81391a307dfe1202e08d | |
| parent | 5fa8012c6b085c8d4244d3b4f9c99c1937fb65a2 (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.go | 2 | ||||
| -rw-r--r-- | internal/config.go | 7 | ||||
| -rw-r--r-- | internal/html.go | 290 | ||||
| -rw-r--r-- | internal/html_test.go | 496 | ||||
| -rw-r--r-- | internal/run.go | 7 |
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, "<script>") { + t.Error("htmlReportBy() missing escaped <script> tag") + } + if strings.Contains(result, "<tags>") && !strings.Contains(result, "<tags>") { + 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 { |
