package internal import ( "fmt" "html" "log" "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 if htmlFile == "" { log.Println("debug: HTMLStatusFile is empty, skipping HTML report generation") return nil } log.Println("debug: HTMLStatusFile set to", htmlFile) 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 { log.Println("debug: error creating directory:", err) return fmt.Errorf("failed to create directory %s: %w", htmlDir, err) } log.Println("debug: directory ensured at", htmlDir) tmpFile := htmlFile + ".tmp" log.Println("debug: writing to temp file", tmpFile) f, err := os.Create(tmpFile) if err != nil { log.Println("debug: error creating temp file:", err) return fmt.Errorf("failed to create temp file: %w", err) } defer f.Close() htmlContent := state.htmlReport(subject, conf) if _, err = f.WriteString(htmlContent); err != nil { log.Println("debug: error writing HTML:", err) return fmt.Errorf("failed to write HTML: %w", err) } log.Println("debug: successfully wrote HTML to temp file") err = os.Rename(tmpFile, htmlFile) if err != nil { log.Println("debug: error renaming temp file to final location:", err) return err } log.Println("debug: successfully renamed and persisted HTML report to", htmlFile) return nil } // htmlReport generates the complete HTML status page. // Mirrors state.report() pattern from state.go:133-163. // Suppressed checks are excluded from main sections but shown in "Suppressed alerts" section. func (s state) htmlReport(subject string, conf config) string { var sb strings.Builder // Calculate counts for header summary (respecting suppression) numCriticals := s.countBy(conf, func(cs checkState) bool { return cs.Status == nagiosCritical }) numWarnings := s.countBy(conf, func(cs checkState) bool { return cs.Status == nagiosWarning }) numUnknown := s.countBy(conf, func(cs checkState) bool { return cs.Status == nagiosUnknown }) numOK := s.countBy(conf, func(cs checkState) bool { return cs.Status == nagiosOk }) numStale := s.countStale(conf) numSuppressed := s.countSuppressed(conf) // Write HTML header with summary sb.WriteString(htmlHeader(subject, numCriticals, numWarnings, numUnknown, numStale, numSuppressed, numOK)) // Alerts with status changed section sb.WriteString(`
` + "\n") sb.WriteString(`

Alerts with status changed

` + "\n") changed := s.htmlReportChanged(&sb, conf) 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, conf) } 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, conf) } sb.WriteString(`
` + "\n\n") // Suppressed alerts section sb.WriteString(`
` + "\n") sb.WriteString(`

Suppressed alerts

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

There are no suppressed alerts...

` + "\n") } else { s.htmlReportSuppressed(&sb, conf) } sb.WriteString(`
` + "\n\n") // OK checks section sb.WriteString(`
` + "\n") sb.WriteString(`

OK checks

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

There are no OK checks...

` + "\n") } else { s.htmlReportBy(&sb, false, false, conf, func(cs checkState) bool { return cs.Status == nagiosOk }) } sb.WriteString(`
` + "\n\n") sb.WriteString(htmlFooter()) return sb.String() } // htmlReportChanged generates HTML for checks with status changes. // Mirrors state.reportChanged() from state.go. func (s state) htmlReportChanged(sb *strings.Builder, conf config) (changed bool) { if 0 < s.htmlReportBy(sb, true, false, conf, func(cs checkState) bool { return cs.Status == nagiosCritical && cs.changed() }) { changed = true } if 0 < s.htmlReportBy(sb, true, false, conf, func(cs checkState) bool { return cs.Status == nagiosWarning && cs.changed() }) { changed = true } if 0 < s.htmlReportBy(sb, true, false, conf, func(cs checkState) bool { return cs.Status == nagiosUnknown && cs.changed() }) { changed = true } if 0 < s.htmlReportBy(sb, true, false, conf, 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. func (s state) htmlReportUnhandledContent(sb *strings.Builder, conf config) { s.htmlReportBy(sb, false, false, conf, func(cs checkState) bool { return cs.Status == nagiosCritical }) s.htmlReportBy(sb, false, false, conf, func(cs checkState) bool { return cs.Status == nagiosWarning }) s.htmlReportBy(sb, false, false, conf, func(cs checkState) bool { return cs.Status == nagiosUnknown }) } // htmlReportStaleAlerts generates HTML for stale checks. // Only reports stale alerts that are not OK, since stale OK alerts aren't concerning. // Mirrors state.reportStaleAlerts() from state.go. func (s state) htmlReportStaleAlerts(sb *strings.Builder, conf config) int { return s.htmlReportBy(sb, false, true, conf, func(cs checkState) bool { return cs.Epoch < s.staleEpoch && cs.Status != nagiosOk }) } // htmlReportSuppressed generates HTML for suppressed checks. // Shows which non-OK checks are currently muted via OnlyIfNotExists for visibility. // OK checks are never shown as suppressed since there's nothing to suppress. func (s state) htmlReportSuppressed(sb *strings.Builder, conf config) (count int) { for name, cs := range s.checks { if cs.Status == nagiosOk || !isCheckSuppressed(name, conf) { continue // OK checks are never shown as suppressed } count++ sb.WriteString(`
` + "\n") sb.WriteString(htmlStatusBadge(nagiosCode(cs.Status))) sb.WriteString(": ") sb.WriteString(html.EscapeString(name)) sb.WriteString(": ") sb.WriteString(html.EscapeString(cs.Output)) if cs.federated() { sb.WriteString(" [federated from ") sb.WriteString(html.EscapeString(cs.FederatedFrom)) sb.WriteString("]") } sb.WriteString(` [SUPPRESSED]`) sb.WriteString("\n
\n") } return } // countSuppressed counts the number of suppressed non-OK checks. // OK checks are never counted as suppressed since there's nothing to suppress. func (s state) countSuppressed(conf config) (count int) { for name := range s.checks { if s.checks[name].Status != nagiosOk && isCheckSuppressed(name, conf) { count++ } } return } // htmlReportBy is the generic HTML generator for check items. // Mirrors state.reportBy() from state.go but outputs HTML. // Checks that are suppressed via OnlyIfNotExists are excluded. func (s state) htmlReportBy(sb *strings.Builder, showStatusChange, isStaleReport bool, conf config, 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 } if cs.Status != nagiosOk && isCheckSuppressed(name, conf) { continue // skip suppressed checks (OK checks are never suppressed) } 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 (excluding OK status). // Helper function for generating summary counts. func (s state) countStale(conf config) int { return s.countBy(conf, func(cs checkState) bool { return cs.Epoch < s.staleEpoch && cs.Status != nagiosOk }) } // htmlHeader generates the HTML document header with embedded CSS and status summary. // The summary line format is: C:# W:# U:# S:# SU:# OK:# func htmlHeader(subject string, numCriticals, numWarnings, numUnknown, numStale, numSuppressed, 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 SU:%d OK:%d", numCriticals, numWarnings, numUnknown, numStale, numSuppressed, 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) }