diff options
| author | Fuwn <[email protected]> | 2026-01-17 23:17:49 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-17 23:17:49 -0800 |
| commit | 4bc6165258cd7b5b76ccb01aa75c7cefdc35d143 (patch) | |
| tree | e7c3bb335a1efd48f82d365169e8b4a66b7abe1d /internal/server/server.go | |
| download | kaze-4bc6165258cd7b5b76ccb01aa75c7cefdc35d143.tar.xz kaze-4bc6165258cd7b5b76ccb01aa75c7cefdc35d143.zip | |
feat: Initial commit
Diffstat (limited to 'internal/server/server.go')
| -rw-r--r-- | internal/server/server.go | 839 |
1 files changed, 839 insertions, 0 deletions
diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..04532b9 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,839 @@ +package server + +import ( + "context" + "embed" + "encoding/json" + "fmt" + "html/template" + "io/fs" + "log/slog" + "net/http" + "sort" + "strconv" + "time" + + "github.com/Fuwn/kaze/internal/config" + "github.com/Fuwn/kaze/internal/monitor" + "github.com/Fuwn/kaze/internal/storage" +) + +//go:embed templates/*.html +var templatesFS embed.FS + +//go:embed static/* +var staticFS embed.FS + +// Server handles HTTP requests for the status page +type Server struct { + config *config.Config + storage *storage.Storage + scheduler *monitor.Scheduler + logger *slog.Logger + server *http.Server + templates *template.Template +} + +// New creates a new HTTP server +func New(cfg *config.Config, store *storage.Storage, sched *monitor.Scheduler, logger *slog.Logger) (*Server, error) { + // Parse templates + tmpl, err := template.New("").Funcs(templateFuncs()).ParseFS(templatesFS, "templates/*.html") + if err != nil { + return nil, fmt.Errorf("failed to parse templates: %w", err) + } + + s := &Server{ + config: cfg, + storage: store, + scheduler: sched, + logger: logger, + templates: tmpl, + } + + // Setup routes + mux := http.NewServeMux() + + // Static files + staticContent, err := fs.Sub(staticFS, "static") + if err != nil { + return nil, fmt.Errorf("failed to get static fs: %w", err) + } + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticContent)))) + + // Pages + mux.HandleFunc("GET /", s.handleIndex) + mux.HandleFunc("GET /api/status", s.handleAPIStatus) + mux.HandleFunc("GET /api/monitor/{name}", s.handleAPIMonitor) + mux.HandleFunc("GET /api/history/{name}", s.handleAPIHistory) + + // Create HTTP server + s.server = &http.Server{ + Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port), + Handler: s.withMiddleware(mux), + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + return s, nil +} + +// Start begins serving HTTP requests +func (s *Server) Start() error { + s.logger.Info("starting HTTP server", "addr", s.server.Addr) + if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("server error: %w", err) + } + return nil +} + +// Stop gracefully shuts down the server +func (s *Server) Stop(ctx context.Context) error { + s.logger.Info("stopping HTTP server") + return s.server.Shutdown(ctx) +} + +// withMiddleware adds common middleware to the handler +func (s *Server) withMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Add security headers + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-XSS-Protection", "1; mode=block") + + next.ServeHTTP(w, r) + + s.logger.Debug("request", + "method", r.Method, + "path", r.URL.Path, + "duration", time.Since(start)) + }) +} + +// PageData contains data for rendering the status page +type PageData struct { + Site config.SiteConfig + Groups []GroupData + Incidents []IncidentData + OverallStatus string + LastUpdated time.Time + CurrentTime string // Formatted date/time for display (without timezone) + TimezoneTooltip string // JSON data for timezone tooltip + LastUpdatedTooltip string // JSON data for last updated tooltip + TickMode string // ping, minute, hour, day + TickCount int + ShowThemeToggle bool + Timezone string // Timezone for display +} + +// GroupData contains data for a monitor group +type GroupData struct { + Name string + Monitors []MonitorData + DefaultCollapsed bool + ShowGroupUptime bool + GroupUptime float64 +} + +// MonitorData contains data for a single monitor +type MonitorData struct { + Name string + Type string + Status string + StatusClass string + ResponseTime int64 + UptimePercent float64 + Ticks []*storage.TickData // Aggregated tick data for history bar + SSLDaysLeft int + SSLExpiryDate time.Time + SSLTooltip string // JSON data for SSL expiration tooltip + LastCheck time.Time + LastError string +} + +// IncidentData contains data for an incident +type IncidentData struct { + Title string + Status string + StatusClass string + Message string + ScheduledStart *time.Time + ScheduledEnd *time.Time + CreatedAt *time.Time + ResolvedAt *time.Time + Updates []IncidentUpdateData + IsScheduled bool + IsActive bool +} + +// IncidentUpdateData contains data for an incident update +type IncidentUpdateData struct { + Time time.Time + Status string + Message string +} + +// handleIndex renders the main status page +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Get all monitor stats + stats, err := s.storage.GetAllMonitorStats(ctx) + if err != nil { + s.logger.Error("failed to get monitor stats", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Build page data + data := PageData{ + Site: s.config.Site, + TickMode: s.config.Display.TickMode, + TickCount: s.config.Display.TickCount, + ShowThemeToggle: s.config.Display.ShowThemeToggle != nil && *s.config.Display.ShowThemeToggle, + Timezone: s.config.Display.Timezone, + } + + overallUp := true + hasDegraded := false + var mostRecentCheck time.Time + + // Build groups + for _, group := range s.config.Groups { + gd := GroupData{ + Name: group.Name, + DefaultCollapsed: group.DefaultCollapsed != nil && *group.DefaultCollapsed, + ShowGroupUptime: group.ShowGroupUptime == nil || *group.ShowGroupUptime, + } + + var totalUptime float64 + var monitorsWithUptime int + + for _, monCfg := range group.Monitors { + md := MonitorData{ + Name: monCfg.Name, + Type: monCfg.Type, + } + + if stat, ok := stats[monCfg.Name]; ok { + md.Status = stat.CurrentStatus + md.ResponseTime = stat.LastResponseTime + md.UptimePercent = stat.UptimePercent + md.SSLDaysLeft = stat.SSLDaysLeft + md.LastCheck = stat.LastCheck + md.LastError = stat.LastError + + // Set SSL expiry date and tooltip + if stat.SSLExpiry != nil { + md.SSLExpiryDate = *stat.SSLExpiry + md.SSLTooltip = formatSSLTooltip(*stat.SSLExpiry, stat.SSLDaysLeft, s.config.Display.Timezone) + } + + // Track most recent check time for footer + if stat.LastCheck.After(mostRecentCheck) { + mostRecentCheck = stat.LastCheck + } + + // Get aggregated history for display + ticks, err := s.storage.GetAggregatedHistory( + ctx, + monCfg.Name, + s.config.Display.TickCount, + s.config.Display.TickMode, + s.config.Display.PingFixedSlots, + ) + if err != nil { + s.logger.Error("failed to get tick history", "monitor", monCfg.Name, "error", err) + } else { + md.Ticks = ticks + } + + // Update overall status + if stat.CurrentStatus == "down" { + overallUp = false + } else if stat.CurrentStatus == "degraded" { + hasDegraded = true + } + } else { + md.Status = "unknown" + } + + md.StatusClass = statusToClass(md.Status) + gd.Monitors = append(gd.Monitors, md) + + // Accumulate uptime for group average + if md.UptimePercent >= 0 { + totalUptime += md.UptimePercent + monitorsWithUptime++ + } + } + + // Calculate group average uptime + if monitorsWithUptime > 0 { + gd.GroupUptime = totalUptime / float64(monitorsWithUptime) + } + + data.Groups = append(data.Groups, gd) + } + + // Set last updated time from most recent check + now := time.Now() + if !mostRecentCheck.IsZero() { + data.LastUpdated = mostRecentCheck + } else { + data.LastUpdated = now + } + + // Format current time for display + data.CurrentTime = formatCurrentTime(now, s.config.Display.Timezone) + data.TimezoneTooltip = formatTimezoneTooltip(now, s.config.Display.Timezone) + data.LastUpdatedTooltip = formatLastUpdatedTooltip(data.LastUpdated, s.config.Display.Timezone) + + // Determine overall status + if !overallUp { + data.OverallStatus = "Major Outage" + } else if hasDegraded { + data.OverallStatus = "Partial Outage" + } else { + data.OverallStatus = "All Systems Operational" + } + + // Build incidents + for _, inc := range s.config.Incidents { + id := IncidentData{ + Title: inc.Title, + Status: inc.Status, + StatusClass: incidentStatusToClass(inc.Status), + Message: inc.Message, + ScheduledStart: inc.ScheduledStart, + ScheduledEnd: inc.ScheduledEnd, + CreatedAt: inc.CreatedAt, + ResolvedAt: inc.ResolvedAt, + IsScheduled: inc.Status == "scheduled", + } + + // Check if incident is active (not resolved and not future scheduled) + if inc.Status != "resolved" { + if inc.Status == "scheduled" { + if inc.ScheduledStart != nil && inc.ScheduledStart.After(time.Now()) { + id.IsActive = false + } else { + id.IsActive = true + } + } else { + id.IsActive = true + } + } + + // Add updates + for _, upd := range inc.Updates { + id.Updates = append(id.Updates, IncidentUpdateData{ + Time: upd.Time, + Status: upd.Status, + Message: upd.Message, + }) + } + + data.Incidents = append(data.Incidents, id) + } + + // Sort incidents: active first, then by date + sort.Slice(data.Incidents, func(i, j int) bool { + if data.Incidents[i].IsActive != data.Incidents[j].IsActive { + return data.Incidents[i].IsActive + } + return false + }) + + // Render template + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.templates.ExecuteTemplate(w, "index.html", data); err != nil { + s.logger.Error("failed to render template", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +// handleAPIStatus returns JSON status for all monitors +func (s *Server) handleAPIStatus(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + stats, err := s.storage.GetAllMonitorStats(ctx) + if err != nil { + s.jsonError(w, "Failed to get stats", http.StatusInternalServerError) + return + } + + s.jsonResponse(w, stats) +} + +// handleAPIMonitor returns JSON status for a specific monitor +func (s *Server) handleAPIMonitor(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if name == "" { + s.jsonError(w, "Monitor name required", http.StatusBadRequest) + return + } + + stats, err := s.storage.GetMonitorStats(r.Context(), name) + if err != nil { + s.jsonError(w, "Failed to get monitor stats", http.StatusInternalServerError) + return + } + + s.jsonResponse(w, stats) +} + +// handleAPIHistory returns aggregated history for a monitor +func (s *Server) handleAPIHistory(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if name == "" { + s.jsonError(w, "Monitor name required", http.StatusBadRequest) + return + } + + // Allow optional parameters, default to config values + mode := s.config.Display.TickMode + if modeParam := r.URL.Query().Get("mode"); modeParam != "" { + switch modeParam { + case "ping", "minute", "hour", "day": + mode = modeParam + } + } + + count := s.config.Display.TickCount + if countParam := r.URL.Query().Get("count"); countParam != "" { + if c, err := strconv.Atoi(countParam); err == nil && c > 0 && c <= 200 { + count = c + } + } + + ticks, err := s.storage.GetAggregatedHistory(r.Context(), name, count, mode, s.config.Display.PingFixedSlots) + if err != nil { + s.jsonError(w, "Failed to get history", http.StatusInternalServerError) + return + } + + s.jsonResponse(w, map[string]interface{}{ + "monitor": name, + "mode": mode, + "count": count, + "ticks": ticks, + }) +} + +// jsonResponse writes a JSON response +func (s *Server) jsonResponse(w http.ResponseWriter, data interface{}) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(data); err != nil { + s.logger.Error("failed to encode JSON response", "error", err) + } +} + +// jsonError writes a JSON error response +func (s *Server) jsonError(w http.ResponseWriter, message string, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": message}) +} + +// templateFuncs returns custom template functions +func templateFuncs() template.FuncMap { + return template.FuncMap{ + "formatTime": func(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.Format("Jan 2, 15:04 UTC") + }, + "formatDate": func(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.Format("Jan 2, 2006") + }, + "formatDuration": func(ms int64) string { + if ms < 1000 { + return fmt.Sprintf("%dms", ms) + } + return fmt.Sprintf("%.2fs", float64(ms)/1000) + }, + "formatUptime": func(pct float64) string { + if pct < 0 { + return "-" + } + return fmt.Sprintf("%.2f%%", pct) + }, + "timeAgo": func(t time.Time) string { + if t.IsZero() { + return "never" + } + d := time.Since(t) + if d < time.Minute { + return fmt.Sprintf("%d seconds ago", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%d minutes ago", int(d.Minutes())) + } + if d < 24*time.Hour { + return fmt.Sprintf("%d hours ago", int(d.Hours())) + } + return fmt.Sprintf("%d days ago", int(d.Hours()/24)) + }, + "tickColor": func(tick *storage.TickData) string { + if tick == nil { + return "bg-neutral-200 dark:bg-neutral-800" // No data + } + if tick.UptimePercent >= 99 { + return "bg-emerald-500" + } + if tick.UptimePercent >= 95 { + return "bg-yellow-500" + } + if tick.UptimePercent > 0 { + return "bg-red-500" + } + // For ping mode with status + switch tick.Status { + case "up": + return "bg-emerald-500" + case "degraded": + return "bg-yellow-500" + case "down": + return "bg-red-500" + } + return "bg-neutral-200 dark:bg-neutral-800" + }, + "tickTooltipData": func(tick *storage.TickData, mode, timezone string) string { + if tick == nil { + data := map[string]interface{}{"header": "No data"} + b, _ := json.Marshal(data) + return string(b) + } + + // Convert timestamp to configured timezone + loc := time.Local + if timezone != "" && timezone != "Local" { + if l, err := time.LoadLocation(timezone); err == nil { + loc = l + } + } + t := tick.Timestamp.In(loc) + + // Get timezone info + tzAbbr := t.Format("MST") + _, offset := t.Zone() + hours := offset / 3600 + minutes := (offset % 3600) / 60 + var utcOffset string + if minutes != 0 { + utcOffset = fmt.Sprintf("UTC%+d:%02d", hours, abs(minutes)) + } else { + utcOffset = fmt.Sprintf("UTC%+d", hours) + } + + var header, statusClass string + data := make(map[string]interface{}) + rows := []map[string]string{} + + switch mode { + case "ping": + header = t.Format("Jan 2, 15:04:05") + statusClass = tickStatusClass(tick.Status) + rows = append(rows, + map[string]string{"label": "Status", "value": tick.Status, "class": statusClass}, + map[string]string{"label": "Response", "value": fmt.Sprintf("%dms", tick.ResponseTime), "class": ""}, + map[string]string{"label": "Timezone", "value": fmt.Sprintf("%s (%s)", tzAbbr, utcOffset), "class": ""}, + ) + case "minute": + header = t.Format("Jan 2, 15:04") + statusClass = uptimeStatusClass(tick.UptimePercent) + rows = append(rows, + map[string]string{"label": "Checks", "value": fmt.Sprintf("%d", tick.TotalChecks), "class": ""}, + map[string]string{"label": "Uptime", "value": fmt.Sprintf("%.1f%%", tick.UptimePercent), "class": statusClass}, + map[string]string{"label": "Avg Response", "value": fmt.Sprintf("%dms", int(tick.AvgResponse)), "class": ""}, + map[string]string{"label": "Timezone", "value": fmt.Sprintf("%s (%s)", tzAbbr, utcOffset), "class": ""}, + ) + case "hour": + header = t.Format("Jan 2, 15:00") + statusClass = uptimeStatusClass(tick.UptimePercent) + rows = append(rows, + map[string]string{"label": "Checks", "value": fmt.Sprintf("%d", tick.TotalChecks), "class": ""}, + map[string]string{"label": "Uptime", "value": fmt.Sprintf("%.1f%%", tick.UptimePercent), "class": statusClass}, + map[string]string{"label": "Avg Response", "value": fmt.Sprintf("%dms", int(tick.AvgResponse)), "class": ""}, + map[string]string{"label": "Timezone", "value": fmt.Sprintf("%s (%s)", tzAbbr, utcOffset), "class": ""}, + ) + case "day": + header = t.Format("Jan 2, 2006") + statusClass = uptimeStatusClass(tick.UptimePercent) + rows = append(rows, + map[string]string{"label": "Checks", "value": fmt.Sprintf("%d", tick.TotalChecks), "class": ""}, + map[string]string{"label": "Uptime", "value": fmt.Sprintf("%.1f%%", tick.UptimePercent), "class": statusClass}, + map[string]string{"label": "Avg Response", "value": fmt.Sprintf("%dms", int(tick.AvgResponse)), "class": ""}, + map[string]string{"label": "Timezone", "value": fmt.Sprintf("%s (%s)", tzAbbr, utcOffset), "class": ""}, + ) + default: + header = t.Format("Jan 2, 15:04") + } + + data["header"] = header + data["rows"] = rows + + b, _ := json.Marshal(data) + return string(b) + }, + "seq": func(n int) []int { + result := make([]int, n) + for i := range result { + result[i] = i + } + return result + }, + } +} + +// formatCurrentTime formats the current time for display without timezone +func formatCurrentTime(t time.Time, timezone string) string { + loc := time.Local + + if timezone != "" && timezone != "Local" { + if l, err := time.LoadLocation(timezone); err == nil { + loc = l + } + } + + t = t.In(loc) + return t.Format("Jan 2, 2006 15:04") +} + +// formatTimezoneTooltip creates JSON data for timezone tooltip +func formatTimezoneTooltip(t time.Time, timezone string) string { + loc := time.Local + + if timezone != "" && timezone != "Local" { + if l, err := time.LoadLocation(timezone); err == nil { + loc = l + } + } + + t = t.In(loc) + + // Get timezone abbreviation (like PST, EST, etc.) + tzAbbr := t.Format("MST") + + // Get UTC offset in format like "UTC-8" or "UTC+5:30" + _, offset := t.Zone() + hours := offset / 3600 + minutes := (offset % 3600) / 60 + var utcOffset string + if minutes != 0 { + utcOffset = fmt.Sprintf("UTC%+d:%02d", hours, abs(minutes)) + } else { + utcOffset = fmt.Sprintf("UTC%+d", hours) + } + + // Get GMT offset in same format + var gmtOffset string + if minutes != 0 { + gmtOffset = fmt.Sprintf("GMT%+d:%02d", hours, abs(minutes)) + } else { + gmtOffset = fmt.Sprintf("GMT%+d", hours) + } + + data := map[string]interface{}{ + "header": "Timezone", + "rows": []map[string]string{ + {"label": "Abbreviation", "value": tzAbbr, "class": ""}, + {"label": "UTC Offset", "value": utcOffset, "class": ""}, + {"label": "GMT Offset", "value": gmtOffset, "class": ""}, + }, + } + + b, _ := json.Marshal(data) + return string(b) +} + +// abs returns the absolute value of an integer +func abs(n int) int { + if n < 0 { + return -n + } + return n +} + +// formatSSLTooltip creates JSON data for SSL expiration tooltip +func formatSSLTooltip(expiryDate time.Time, daysLeft int, timezone string) string { + loc := time.Local + + if timezone != "" && timezone != "Local" { + if l, err := time.LoadLocation(timezone); err == nil { + loc = l + } + } + + t := expiryDate.In(loc) + + // Get timezone abbreviation + tzAbbr := t.Format("MST") + + // Get UTC offset + _, offset := t.Zone() + hours := offset / 3600 + minutes := (offset % 3600) / 60 + var utcOffset string + if minutes != 0 { + utcOffset = fmt.Sprintf("UTC%+d:%02d", hours, abs(minutes)) + } else { + utcOffset = fmt.Sprintf("UTC%+d", hours) + } + + // Get GMT offset + var gmtOffset string + if minutes != 0 { + gmtOffset = fmt.Sprintf("GMT%+d:%02d", hours, abs(minutes)) + } else { + gmtOffset = fmt.Sprintf("GMT%+d", hours) + } + + // Format the expiry date + expiryStr := t.Format("Jan 2, 2006 15:04:05") + + // Determine status message + var statusMsg, statusClass string + if daysLeft < 0 { + statusMsg = "Expired" + statusClass = "error" + } else if daysLeft < 7 { + statusMsg = fmt.Sprintf("%d days (Critical)", daysLeft) + statusClass = "error" + } else if daysLeft < 14 { + statusMsg = fmt.Sprintf("%d days (Warning)", daysLeft) + statusClass = "warning" + } else { + statusMsg = fmt.Sprintf("%d days", daysLeft) + statusClass = "success" + } + + data := map[string]interface{}{ + "header": "SSL Certificate", + "rows": []map[string]string{ + {"label": "Expires", "value": expiryStr, "class": ""}, + {"label": "Days Left", "value": statusMsg, "class": statusClass}, + {"label": "Timezone", "value": tzAbbr, "class": ""}, + {"label": "UTC Offset", "value": utcOffset, "class": ""}, + {"label": "GMT Offset", "value": gmtOffset, "class": ""}, + }, + } + + b, _ := json.Marshal(data) + return string(b) +} + +// formatLastUpdatedTooltip creates JSON data for last updated tooltip +func formatLastUpdatedTooltip(t time.Time, timezone string) string { + loc := time.Local + + if timezone != "" && timezone != "Local" { + if l, err := time.LoadLocation(timezone); err == nil { + loc = l + } + } + + t = t.In(loc) + + // Get timezone abbreviation + tzAbbr := t.Format("MST") + + // Get UTC offset + _, offset := t.Zone() + hours := offset / 3600 + minutes := (offset % 3600) / 60 + var utcOffset string + if minutes != 0 { + utcOffset = fmt.Sprintf("UTC%+d:%02d", hours, abs(minutes)) + } else { + utcOffset = fmt.Sprintf("UTC%+d", hours) + } + + // Get GMT offset + var gmtOffset string + if minutes != 0 { + gmtOffset = fmt.Sprintf("GMT%+d:%02d", hours, abs(minutes)) + } else { + gmtOffset = fmt.Sprintf("GMT%+d", hours) + } + + // Format the datetime + datetime := t.Format("Jan 2, 2006 15:04:05") + + data := map[string]interface{}{ + "header": "Last Check", + "rows": []map[string]string{ + {"label": "Date & Time", "value": datetime, "class": ""}, + {"label": "Timezone", "value": tzAbbr, "class": ""}, + {"label": "UTC Offset", "value": utcOffset, "class": ""}, + {"label": "GMT Offset", "value": gmtOffset, "class": ""}, + }, + } + + b, _ := json.Marshal(data) + return string(b) +} + +// statusToClass converts a status to a CSS class +func statusToClass(status string) string { + switch status { + case "up": + return "status-up" + case "down": + return "status-down" + case "degraded": + return "status-degraded" + default: + return "status-unknown" + } +} + +// tickStatusClass returns CSS class for tooltip status text +func tickStatusClass(status string) string { + switch status { + case "up": + return "success" + case "degraded": + return "warning" + case "down": + return "error" + default: + return "" + } +} + +// uptimeStatusClass returns CSS class based on uptime percentage +func uptimeStatusClass(pct float64) string { + if pct >= 99 { + return "success" + } + if pct >= 95 { + return "warning" + } + return "error" +} + +// incidentStatusToClass converts an incident status to a CSS class +func incidentStatusToClass(status string) string { + switch status { + case "scheduled": + return "incident-scheduled" + case "investigating": + return "incident-investigating" + case "identified": + return "incident-identified" + case "monitoring": + return "incident-monitoring" + case "resolved": + return "incident-resolved" + default: + return "incident-unknown" + } +} |