package server import ( "bytes" "context" "embed" "encoding/json" "fmt" "html/template" "io/fs" "log/slog" "net/http" "net/url" "sort" "strconv" "strings" "sync" "time" "github.com/Fuwn/kaze/internal/config" "github.com/Fuwn/kaze/internal/monitor" "github.com/Fuwn/kaze/internal/storage" "github.com/Fuwn/kaze/internal/theme" ) //go:embed templates/*.html var templatesFS embed.FS //go:embed static/* var staticFS embed.FS // ReloadFunc is a callback function for reloading configuration type ReloadFunc func() error // VersionInfo contains build version information type VersionInfo struct { Version string Commit string Date string } // Server handles HTTP requests for the status page type Server struct { mu sync.RWMutex config *config.Config storage *storage.Storage scheduler *monitor.Scheduler logger *slog.Logger server *http.Server templates *template.Template reloadConfig ReloadFunc version VersionInfo sseHub *SSEHub // SSE hub for real-time streaming } // 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, sseHub: NewSSEHub(logger), } // 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) // API endpoints (protected by API access control) mux.HandleFunc("GET /api/status", s.withAPIAuth(s.handleAPIStatus)) mux.HandleFunc("GET /api/monitor/{group}/{name}", s.withAPIAuth(s.handleAPIMonitor)) mux.HandleFunc("GET /api/history/{group}/{name}", s.withAPIAuth(s.handleAPIHistory)) mux.HandleFunc("GET /api/summary", s.withAPIAuth(s.handleAPISummary)) mux.HandleFunc("GET /api/uptime/{group}/{name}", s.withAPIAuth(s.handleAPIUptime)) mux.HandleFunc("GET /api/incidents", s.withAPIAuth(s.handleAPIIncidents)) // Health check - always public (for load balancers, monitoring) mux.HandleFunc("GET /api/health", s.handleAPIHealth) // Badge endpoint - always public (for embedding in READMEs, docs) // Note: {path...} captures the rest of the path (group/name.svg) mux.HandleFunc("GET /api/badge/{path...}", s.handleAPIBadge) // Full page data endpoint - public if refresh_mode=api, otherwise follows api.access if cfg.Display.RefreshMode == "api" { mux.HandleFunc("GET /api/page", s.handleAPIPage) } else { mux.HandleFunc("GET /api/page", s.withAPIAuth(s.handleAPIPage)) } // Config reload endpoint - always requires authentication mux.HandleFunc("POST /api/reload", s.withStrictAuth(s.handleAPIReload)) // SSE stream endpoint - public for stream mode, otherwise follows api.access if cfg.Display.RefreshMode == "stream" { mux.HandleFunc("GET /api/stream", s.handleAPIStream) } else { mux.HandleFunc("GET /api/stream", s.withAPIAuth(s.handleAPIStream)) } // 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: 30 * time.Second, // Increased for large page renders 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)) }) } // withAPIAuth wraps an API handler with access control based on config.API.Access func (s *Server) withAPIAuth(handler http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cfg := s.getConfig() switch cfg.API.Access { case "private": s.jsonError(w, "API access is disabled", http.StatusForbidden) return case "authenticated": if !s.checkAPIKey(r, cfg) { w.Header().Set("WWW-Authenticate", "API-Key") s.jsonError(w, "API key required", http.StatusUnauthorized) return } } handler(w, r) } } func (s *Server) withStrictAuth(handler http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cfg := s.getConfig() if len(cfg.API.Keys) == 0 { s.jsonError(w, "No API keys configured. Add keys to api.keys in config to use this endpoint.", http.StatusForbidden) return } if !s.checkAPIKey(r, cfg) { w.Header().Set("WWW-Authenticate", "API-Key") s.jsonError(w, "API key required", http.StatusUnauthorized) return } handler(w, r) } } func (s *Server) checkAPIKey(r *http.Request, cfg *config.Config) bool { apiKey := r.Header.Get("X-API-Key") if apiKey == "" { apiKey = r.URL.Query().Get("api_key") } if apiKey == "" { return false } for _, key := range cfg.API.Keys { if key == apiKey { return true } } return false } // PageData contains data for rendering the status page type PageData struct { Site config.SiteConfig Groups []GroupData Incidents []IncidentData OverallStatus string StatusCounts StatusCounts // Counts for tab title 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 Timezone string // Timezone for display UseBrowserTimezone bool // Use client-side timezone conversion ThemeCSS template.CSS // OpenCode theme CSS (safe CSS) CustomHead template.HTML // Custom HTML for (trusted) Scale float64 // UI scale factor (0.5-2.0) RefreshMode string // page or api RefreshInterval int // seconds VersionTooltip string // JSON data for version tooltip } // StatusCounts holds monitor status counts for display type StatusCounts struct { Up int Down int Degraded int Total int } // 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 Link template.URL // Custom URL for clicking the monitor name (trusted) Status string StatusClass string ResponseTime int64 HidePing bool // Hide response time from display UptimePercent float64 UptimeTooltip string // JSON data for uptime tooltip with last failure info 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 LastFailure *time.Time // Time of last failure LastFailureError string // Error from last failure DisablePingTooltips bool DisableUptimeTooltip bool } // 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 } func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { ctx := r.Context() cfg := s.getConfig() if cfg.Display.RefreshMode == "stream" { s.handleIndexStream(w, r) return } 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 } // Load OpenCode theme if configured var themeCSS template.CSS if cfg.Site.ThemeURL != "" { resolvedTheme, err := theme.LoadTheme(cfg.Site.ThemeURL) if err != nil { s.logger.Warn("failed to load theme", "url", cfg.Site.ThemeURL, "error", err) } else if resolvedTheme != nil { cssString := resolvedTheme.GenerateCSS() + resolvedTheme.GenerateVariableOverrides() themeCSS = template.CSS(cssString) } } // Build page data data := PageData{ Site: cfg.Site, TickMode: cfg.Display.TickMode, TickCount: cfg.Display.TickCount, Timezone: cfg.Display.Timezone, UseBrowserTimezone: cfg.Display.Timezone == "Browser", ThemeCSS: themeCSS, CustomHead: template.HTML(cfg.Site.CustomHead), Scale: cfg.Display.Scale, RefreshMode: cfg.Display.RefreshMode, RefreshInterval: cfg.Display.RefreshInterval, VersionTooltip: s.formatVersionTooltip(), } overallUp := true hasDegraded := false var mostRecentCheck time.Time var statusCounts StatusCounts // Build groups for _, group := range cfg.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, Link: template.URL(monCfg.Link), HidePing: monCfg.HidePing, DisablePingTooltips: monCfg.DisablePingTooltips, DisableUptimeTooltip: monCfg.DisableUptimeTooltip, } monitorID := monCfg.ID() if stat, ok := stats[monitorID]; 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 if stat.SSLExpiry != nil { md.SSLExpiryDate = *stat.SSLExpiry md.SSLTooltip = formatSSLTooltip(*stat.SSLExpiry, stat.SSLDaysLeft, cfg.Display.Timezone) } md.LastFailure = stat.LastFailure md.LastFailureError = stat.LastFailureError md.UptimeTooltip = formatUptimeTooltip(stat.UptimePercent, stat.TotalChecks, stat.LastFailure, stat.LastFailureError, cfg.Display.Timezone) if stat.LastCheck.After(mostRecentCheck) { mostRecentCheck = stat.LastCheck } ticks, err := s.storage.GetAggregatedHistory( ctx, monitorID, cfg.Display.TickCount, cfg.Display.TickMode, cfg.Display.PingFixedSlots, ) if err != nil { s.logger.Error("failed to get tick history", "monitor", monitorID, "error", err) } else { md.Ticks = ticks } statusCounts.Total++ switch stat.CurrentStatus { case "down": overallUp = false statusCounts.Down++ case "degraded": hasDegraded = true statusCounts.Degraded++ case "up": statusCounts.Up++ } } else { md.Status = "unknown" } md.StatusClass = statusToClass(md.Status) gd.Monitors = append(gd.Monitors, md) if md.UptimePercent >= 0 { totalUptime += md.UptimePercent monitorsWithUptime++ } } if monitorsWithUptime > 0 { gd.GroupUptime = totalUptime / float64(monitorsWithUptime) } data.Groups = append(data.Groups, gd) } now := time.Now() if !mostRecentCheck.IsZero() { data.LastUpdated = mostRecentCheck } else { data.LastUpdated = now } data.CurrentTime = formatCurrentTime(now, cfg.Display.Timezone) data.TimezoneTooltip = formatTimezoneTooltip(now, cfg.Display.Timezone) data.LastUpdatedTooltip = formatLastUpdatedTooltip(data.LastUpdated, cfg.Display.Timezone) if !overallUp { data.OverallStatus = "Major Outage" } else if hasDegraded { data.OverallStatus = "Partial Outage" } else { data.OverallStatus = "All Systems Operational" } data.StatusCounts = statusCounts for _, inc := range cfg.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 to buffer first to prevent broken pipe errors // This allows us to complete template execution even if client disconnects var buf bytes.Buffer if err := s.templates.ExecuteTemplate(&buf, "index.html", data); err != nil { s.logger.Error("failed to render template", "error", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if _, err := w.Write(buf.Bytes()); err != nil { s.logger.Debug("failed to write response", "error", err) } } func (s *Server) handleIndexStream(w http.ResponseWriter, r *http.Request) { cfg := s.getConfig() var themeCSS template.CSS if cfg.Site.ThemeURL != "" { resolvedTheme, err := theme.LoadTheme(cfg.Site.ThemeURL) if err != nil { s.logger.Warn("failed to load theme", "url", cfg.Site.ThemeURL, "error", err) } else if resolvedTheme != nil { cssString := resolvedTheme.GenerateCSS() + resolvedTheme.GenerateVariableOverrides() themeCSS = template.CSS(cssString) } } data := StreamPageData{ Site: cfg.Site, TickMode: cfg.Display.TickMode, TickCount: cfg.Display.TickCount, Timezone: cfg.Display.Timezone, UseBrowserTimezone: cfg.Display.Timezone == "Browser", ThemeCSS: themeCSS, CustomHead: template.HTML(cfg.Site.CustomHead), Scale: cfg.Display.Scale, VersionTooltip: s.formatVersionTooltip(), Groups: make([]StreamGroupData, 0, len(cfg.Groups)), } for _, group := range cfg.Groups { gd := StreamGroupData{ Name: group.Name, DefaultCollapsed: group.DefaultCollapsed != nil && *group.DefaultCollapsed, ShowGroupUptime: group.ShowGroupUptime == nil || *group.ShowGroupUptime, Monitors: make([]StreamMonitorData, 0, len(group.Monitors)), } for _, monCfg := range group.Monitors { gd.Monitors = append(gd.Monitors, StreamMonitorData{ ID: monCfg.ID(), Name: monCfg.Name, Type: monCfg.Type, Link: template.URL(monCfg.Link), HidePing: monCfg.HidePing, DisablePingTooltips: monCfg.DisablePingTooltips, }) } data.Groups = append(data.Groups, gd) } for _, inc := range cfg.Incidents { isActive := inc.Status != "resolved" id := StreamIncidentData{ Title: inc.Title, Status: inc.Status, StatusClass: incidentStatusToClass(inc.Status), Message: inc.Message, ScheduledStart: inc.ScheduledStart, ScheduledEnd: inc.ScheduledEnd, CreatedAt: inc.CreatedAt, IsScheduled: inc.Status == "scheduled", IsActive: isActive, } data.Incidents = append(data.Incidents, id) } var buf bytes.Buffer if err := s.templates.ExecuteTemplate(&buf, "stream.html", data); err != nil { s.logger.Error("failed to render stream template", "error", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if _, err := w.Write(buf.Bytes()); err != nil { s.logger.Debug("failed to write response", "error", err) } } type StreamPageData struct { Site config.SiteConfig Groups []StreamGroupData Incidents []StreamIncidentData TickMode string TickCount int Timezone string UseBrowserTimezone bool ThemeCSS template.CSS CustomHead template.HTML Scale float64 VersionTooltip string } type StreamGroupData struct { Name string Monitors []StreamMonitorData DefaultCollapsed bool ShowGroupUptime bool } type StreamMonitorData struct { ID string Name string Type string Link template.URL HidePing bool DisablePingTooltips bool } type StreamIncidentData struct { Title string Status string StatusClass string Message string ScheduledStart *time.Time ScheduledEnd *time.Time CreatedAt *time.Time IsScheduled bool IsActive bool } 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) { group := r.PathValue("group") name := r.PathValue("name") if group == "" || name == "" { s.jsonError(w, "Group and monitor name required", http.StatusBadRequest) return } // Construct composite ID (path values are already URL-decoded by net/http, // but we need to re-encode to match the internal ID format) monitorID := url.PathEscape(group) + "/" + url.PathEscape(name) stats, err := s.storage.GetMonitorStats(r.Context(), monitorID) if err != nil { s.jsonError(w, "Failed to get monitor stats", http.StatusInternalServerError) return } s.jsonResponse(w, stats) } func (s *Server) handleAPIHistory(w http.ResponseWriter, r *http.Request) { group := r.PathValue("group") name := r.PathValue("name") if group == "" || name == "" { s.jsonError(w, "Group and monitor name required", http.StatusBadRequest) return } cfg := s.getConfig() monitorID := url.PathEscape(group) + "/" + url.PathEscape(name) mode := cfg.Display.TickMode if modeParam := r.URL.Query().Get("mode"); modeParam != "" { switch modeParam { case "ping", "minute", "hour", "day": mode = modeParam } } count := cfg.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(), monitorID, count, mode, cfg.Display.PingFixedSlots) if err != nil { s.jsonError(w, "Failed to get history", http.StatusInternalServerError) return } s.jsonResponse(w, map[string]interface{}{ "monitor": monitorID, "mode": mode, "count": count, "ticks": ticks, }) } // APIPageResponse contains all data needed to render/update the status page type APIPageResponse struct { Monitors map[string]APIMonitorData `json:"monitors"` OverallStatus string `json:"overall_status"` Counts StatusCounts `json:"counts"` LastUpdated time.Time `json:"last_updated"` } // APIMonitorData contains monitor data for the API page response type APIMonitorData struct { Status string `json:"status"` ResponseTime int64 `json:"response_time"` Uptime float64 `json:"uptime"` LastError string `json:"last_error,omitempty"` SSLDaysLeft int `json:"ssl_days_left,omitempty"` Ticks []*storage.TickData `json:"ticks"` } func (s *Server) handleAPIPage(w http.ResponseWriter, r *http.Request) { ctx := r.Context() cfg := s.getConfig() stats, err := s.storage.GetAllMonitorStats(ctx) if err != nil { s.jsonError(w, "Failed to get stats", http.StatusInternalServerError) return } response := APIPageResponse{ Monitors: make(map[string]APIMonitorData), LastUpdated: time.Now(), } overallUp := true hasDegraded := false for _, group := range cfg.Groups { for _, monCfg := range group.Monitors { monitorID := monCfg.ID() stat, ok := stats[monitorID] if !ok { continue } ticks, err := s.storage.GetAggregatedHistory( ctx, monitorID, cfg.Display.TickCount, cfg.Display.TickMode, cfg.Display.PingFixedSlots, ) if err != nil { s.logger.Error("failed to get tick history", "monitor", monitorID, "error", err) ticks = nil } response.Monitors[monitorID] = APIMonitorData{ Status: stat.CurrentStatus, ResponseTime: stat.LastResponseTime, Uptime: stat.UptimePercent, LastError: stat.LastError, SSLDaysLeft: stat.SSLDaysLeft, Ticks: ticks, } response.Counts.Total++ switch stat.CurrentStatus { case "down": overallUp = false response.Counts.Down++ case "degraded": hasDegraded = true response.Counts.Degraded++ case "up": response.Counts.Up++ } } } if !overallUp { response.OverallStatus = "Major Outage" } else if hasDegraded { response.OverallStatus = "Partial Outage" } else { response.OverallStatus = "All Systems Operational" } s.jsonResponse(w, response) } func (s *Server) handleAPIStream(w http.ResponseWriter, r *http.Request) { ctx := r.Context() cfg := s.getConfig() flusher, ok := w.(http.Flusher) if !ok { s.jsonError(w, "SSE not supported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") s.logger.Debug("SSE client connected", "remote_addr", r.RemoteAddr) initialData := s.buildSSEPageData(ctx, cfg, "init") s.writeSSEEvent(w, flusher, "init", initialData) client := &sseClient{ ch: make(chan []byte, 64), doneCh: make(chan struct{}), clientIP: r.RemoteAddr, } s.sseHub.addClient(client) defer s.sseHub.removeClient(client) ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for { select { case <-ctx.Done(): s.logger.Debug("SSE client disconnected", "remote_addr", r.RemoteAddr) return case data := <-client.ch: fmt.Fprintf(w, "event: update\ndata: %s\n\n", data) flusher.Flush() case <-ticker.C: fmt.Fprintf(w, ": keepalive\n\n") flusher.Flush() } } } func (s *Server) writeSSEEvent(w http.ResponseWriter, flusher http.Flusher, eventType string, data any) { jsonData, err := json.Marshal(data) if err != nil { s.logger.Error("failed to marshal SSE data", "error", err) return } fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, jsonData) flusher.Flush() } func (s *Server) buildSSEPageData(ctx context.Context, cfg *config.Config, dataType string) *SSEPageData { stats, err := s.storage.GetAllMonitorStats(ctx) if err != nil { s.logger.Error("failed to get monitor stats for SSE", "error", err) stats = make(map[string]*storage.MonitorStats) } data := &SSEPageData{ Type: dataType, Monitors: make(map[string]APIMonitorData), LastUpdated: time.Now(), } overallUp := true hasDegraded := false if dataType == "init" { data.Site = &SSESiteData{ Name: cfg.Site.Name, Description: cfg.Site.Description, } data.Groups = make([]SSEGroupData, 0, len(cfg.Groups)) data.Incidents = make([]SSEIncidentData, 0, len(cfg.Incidents)) for _, inc := range cfg.Incidents { isActive := inc.Status != "resolved" data.Incidents = append(data.Incidents, SSEIncidentData{ Title: inc.Title, Status: inc.Status, Message: inc.Message, IsActive: isActive, IsScheduled: inc.Status == "scheduled", ScheduledStart: inc.ScheduledStart, ScheduledEnd: inc.ScheduledEnd, CreatedAt: inc.CreatedAt, }) } } for _, group := range cfg.Groups { var groupData SSEGroupData if dataType == "init" { groupData = SSEGroupData{ Name: group.Name, MonitorIDs: make([]string, 0, len(group.Monitors)), DefaultCollapsed: group.DefaultCollapsed != nil && *group.DefaultCollapsed, ShowGroupUptime: group.ShowGroupUptime == nil || *group.ShowGroupUptime, } } var totalUptime float64 var monitorsWithUptime int for _, monCfg := range group.Monitors { monitorID := monCfg.ID() if dataType == "init" { groupData.MonitorIDs = append(groupData.MonitorIDs, monitorID) } stat, ok := stats[monitorID] if !ok { data.Monitors[monitorID] = APIMonitorData{ Status: "unknown", Ticks: nil, } continue } ticks, err := s.storage.GetAggregatedHistory( ctx, monitorID, cfg.Display.TickCount, cfg.Display.TickMode, cfg.Display.PingFixedSlots, ) if err != nil { s.logger.Error("failed to get tick history", "monitor", monitorID, "error", err) ticks = nil } data.Monitors[monitorID] = APIMonitorData{ Status: stat.CurrentStatus, ResponseTime: stat.LastResponseTime, Uptime: stat.UptimePercent, LastError: stat.LastError, SSLDaysLeft: stat.SSLDaysLeft, Ticks: ticks, } data.Counts.Total++ switch stat.CurrentStatus { case "down": overallUp = false data.Counts.Down++ case "degraded": hasDegraded = true data.Counts.Degraded++ case "up": data.Counts.Up++ } if stat.UptimePercent >= 0 { totalUptime += stat.UptimePercent monitorsWithUptime++ } } if dataType == "init" { if monitorsWithUptime > 0 { groupData.GroupUptime = totalUptime / float64(monitorsWithUptime) } data.Groups = append(data.Groups, groupData) } } if !overallUp { data.OverallStatus = "Major Outage" } else if hasDegraded { data.OverallStatus = "Partial Outage" } else { data.OverallStatus = "All Systems Operational" } return data } func (s *Server) BroadcastStatusUpdate(ctx context.Context) { if s.sseHub.ClientCount() == 0 { return } cfg := s.getConfig() data := s.buildSSEPageData(ctx, cfg, "update") s.sseHub.Broadcast(data) } func (s *Server) GetSSEHub() *SSEHub { return s.sseHub } // handleAPIHealth returns a simple health check response (always public) func (s *Server) handleAPIHealth(w http.ResponseWriter, r *http.Request) { s.jsonResponse(w, map[string]string{"status": "ok"}) } // handleAPIBadge returns an SVG status badge for a monitor (shields.io style) func (s *Server) handleAPIBadge(w http.ResponseWriter, r *http.Request) { path := r.PathValue("path") if path == "" { http.Error(w, "Monitor path required (group/name.svg)", http.StatusBadRequest) return } // Strip .svg extension if present path = strings.TrimSuffix(path, ".svg") // The path should be in format "group/name" (URL-encoded components) // Split into group and name, then re-encode to match internal ID format idx := strings.Index(path, "/") var monitorID, displayName string if idx >= 0 { group := path[:idx] name := path[idx+1:] // Re-encode to ensure consistent internal format monitorID = url.PathEscape(group) + "/" + url.PathEscape(name) displayName = name } else { // No group, just name monitorID = url.PathEscape(path) displayName = path } // Get monitor stats stats, err := s.storage.GetMonitorStats(r.Context(), monitorID) if err != nil { s.logger.Error("failed to get monitor stats for badge", "monitor", monitorID, "error", err) // Return a gray "unknown" badge on error s.serveBadge(w, r, displayName, "unknown", "#9ca3af") return } // Determine status and color var status, color string switch stats.CurrentStatus { case "up": status = "up" color = "#22c55e" // green-500 case "degraded": status = "degraded" color = "#eab308" // yellow-500 case "down": status = "down" color = "#ef4444" // red-500 default: status = "unknown" color = "#9ca3af" // gray-400 } // Check for custom label label := r.URL.Query().Get("label") if label == "" { label = displayName } // Check for style (flat or plastic, default: flat) style := r.URL.Query().Get("style") if style != "plastic" { style = "flat" } // Check if uptime should be shown instead of status if r.URL.Query().Get("type") == "uptime" { status = fmt.Sprintf("%.1f%%", stats.UptimePercent) if stats.UptimePercent >= 99.0 { color = "#22c55e" } else if stats.UptimePercent >= 95.0 { color = "#eab308" } else { color = "#ef4444" } } s.serveBadge(w, r, label, status, color) } // serveBadge generates and serves an SVG badge func (s *Server) serveBadge(w http.ResponseWriter, r *http.Request, label, status, color string) { style := r.URL.Query().Get("style") if style != "plastic" { style = "flat" } // Calculate widths (approximate, 6px per character + padding) labelWidth := len(label)*6 + 10 statusWidth := len(status)*6 + 10 totalWidth := labelWidth + statusWidth var svg string if style == "plastic" { // Plastic style with gradient svg = fmt.Sprintf(` %s %s %s %s `, totalWidth, totalWidth, labelWidth, labelWidth, statusWidth, color, totalWidth, labelWidth/2, label, labelWidth/2, label, labelWidth+statusWidth/2, status, labelWidth+statusWidth/2, status) } else { // Flat style (default) svg = fmt.Sprintf(` %s %s %s %s `, totalWidth, totalWidth, labelWidth, labelWidth, statusWidth, color, labelWidth/2, label, labelWidth/2, label, labelWidth+statusWidth/2, status, labelWidth+statusWidth/2, status) } w.Header().Set("Content-Type", "image/svg+xml") w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Write([]byte(svg)) } // APISummaryResponse contains a lightweight status overview type APISummaryResponse struct { OverallStatus string `json:"overall_status"` Counts StatusCounts `json:"counts"` LastUpdated time.Time `json:"last_updated"` } // handleAPISummary returns a lightweight status summary (no history data) func (s *Server) handleAPISummary(w http.ResponseWriter, r *http.Request) { cfg := s.getConfig() ctx := r.Context() stats, err := s.storage.GetAllMonitorStats(ctx) if err != nil { s.jsonError(w, "Failed to get stats", http.StatusInternalServerError) return } response := APISummaryResponse{ LastUpdated: time.Now(), } overallUp := true hasDegraded := false for _, group := range cfg.Groups { for _, monCfg := range group.Monitors { // Use composite ID (group/name) to look up stats monitorID := monCfg.ID() stat, ok := stats[monitorID] if !ok { continue } response.Counts.Total++ switch stat.CurrentStatus { case "down": overallUp = false response.Counts.Down++ case "degraded": hasDegraded = true response.Counts.Degraded++ case "up": response.Counts.Up++ } } } if !overallUp { response.OverallStatus = "Major Outage" } else if hasDegraded { response.OverallStatus = "Partial Outage" } else { response.OverallStatus = "All Systems Operational" } s.jsonResponse(w, response) } // APIUptimeResponse contains historical uptime statistics type APIUptimeResponse struct { Monitor string `json:"monitor"` Period string `json:"period"` UptimePercent float64 `json:"uptime_percent"` TotalChecks int64 `json:"total_checks"` SuccessChecks int64 `json:"success_checks"` FailedChecks int64 `json:"failed_checks"` } // handleAPIUptime returns historical uptime for a specific period func (s *Server) handleAPIUptime(w http.ResponseWriter, r *http.Request) { group := r.PathValue("group") name := r.PathValue("name") if group == "" || name == "" { s.jsonError(w, "Group and monitor name required", http.StatusBadRequest) return } // Construct composite ID (re-encode to match internal format) monitorID := url.PathEscape(group) + "/" + url.PathEscape(name) // Parse period (default: 24h, options: 1h, 24h, 7d, 30d, 90d) period := r.URL.Query().Get("period") if period == "" { period = "24h" } var duration time.Duration switch period { case "1h": duration = time.Hour case "24h": duration = 24 * time.Hour case "7d": duration = 7 * 24 * time.Hour case "30d": duration = 30 * 24 * time.Hour case "90d": duration = 90 * 24 * time.Hour default: s.jsonError(w, "Invalid period (use: 1h, 24h, 7d, 30d, 90d)", http.StatusBadRequest) return } stats, err := s.storage.GetUptimeStats(r.Context(), monitorID, duration) if err != nil { s.jsonError(w, "Failed to get uptime stats", http.StatusInternalServerError) return } s.jsonResponse(w, APIUptimeResponse{ Monitor: monitorID, Period: period, UptimePercent: stats.UptimePercent, TotalChecks: stats.TotalChecks, SuccessChecks: stats.SuccessChecks, FailedChecks: stats.FailedChecks, }) } // APIIncidentResponse contains incident data for the API type APIIncidentResponse struct { Title string `json:"title"` Status string `json:"status"` Message string `json:"message"` IsActive bool `json:"is_active"` CreatedAt *time.Time `json:"created_at,omitempty"` ResolvedAt *time.Time `json:"resolved_at,omitempty"` ScheduledStart *time.Time `json:"scheduled_start,omitempty"` ScheduledEnd *time.Time `json:"scheduled_end,omitempty"` Affected []string `json:"affected_monitors,omitempty"` } // handleAPIIncidents returns active and recent incidents func (s *Server) handleAPIIncidents(w http.ResponseWriter, r *http.Request) { cfg := s.getConfig() // Filter: all, active, resolved, scheduled (default: all) filter := r.URL.Query().Get("filter") var incidents []APIIncidentResponse for _, inc := range cfg.Incidents { isActive := inc.Status != "resolved" isScheduled := inc.Status == "scheduled" // Apply filter switch filter { case "active": if !isActive || isScheduled { continue } case "resolved": if isActive { continue } case "scheduled": if !isScheduled { continue } } incidents = append(incidents, APIIncidentResponse{ Title: inc.Title, Status: inc.Status, Message: inc.Message, IsActive: isActive, CreatedAt: inc.CreatedAt, ResolvedAt: inc.ResolvedAt, ScheduledStart: inc.ScheduledStart, ScheduledEnd: inc.ScheduledEnd, Affected: inc.AffectedMonitors, }) } s.jsonResponse(w, map[string]interface{}{ "incidents": incidents, "count": len(incidents), }) } // SetReloadFunc sets the callback function for reloading configuration func (s *Server) SetReloadFunc(fn ReloadFunc) { s.mu.Lock() defer s.mu.Unlock() s.reloadConfig = fn } // SetVersion sets the version information for display func (s *Server) SetVersion(version, commit, date string) { s.mu.Lock() defer s.mu.Unlock() s.version = VersionInfo{ Version: version, Commit: commit, Date: date, } } // UpdateConfig atomically updates the server's config and scheduler for zero-downtime reload func (s *Server) UpdateConfig(cfg *config.Config, sched *monitor.Scheduler) { s.mu.Lock() defer s.mu.Unlock() s.config = cfg s.scheduler = sched } // getConfig returns the current config (thread-safe) func (s *Server) getConfig() *config.Config { s.mu.RLock() defer s.mu.RUnlock() return s.config } // getScheduler returns the current scheduler (thread-safe) func (s *Server) getScheduler() *monitor.Scheduler { s.mu.RLock() defer s.mu.RUnlock() return s.scheduler } // getVersion returns the current version info (thread-safe) func (s *Server) getVersion() VersionInfo { s.mu.RLock() defer s.mu.RUnlock() return s.version } // getReloadFunc returns the reload function (thread-safe) func (s *Server) getReloadFunc() ReloadFunc { s.mu.RLock() defer s.mu.RUnlock() return s.reloadConfig } // handleAPIReload triggers a configuration reload (always requires authentication) func (s *Server) handleAPIReload(w http.ResponseWriter, r *http.Request) { if s.reloadConfig == nil { s.jsonError(w, "Reload function not configured", http.StatusServiceUnavailable) return } s.logger.Info("config reload triggered via API", "remote_addr", r.RemoteAddr) if err := s.reloadConfig(); err != nil { s.logger.Error("config reload failed", "error", err) s.jsonError(w, fmt.Sprintf("Reload failed: %v", err), http.StatusInternalServerError) return } s.jsonResponse(w, map[string]string{ "status": "ok", "message": "Configuration reloaded successfully", }) } // 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" }, "simplifyError": simplifyErrorMessage, "tickTooltipData": func(tick *storage.TickData, mode, timezone string, hidePing bool) string { if tick == nil { data := map[string]interface{}{"header": "No data"} b, _ := json.Marshal(data) return string(b) } // If using browser timezone, include raw timestamp for client-side conversion useBrowserTz := timezone == "Browser" // Convert timestamp to configured timezone (fallback for non-JS users) loc := time.Local if timezone != "" && timezone != "Local" && timezone != "Browser" { 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{} // Include raw timestamp for browser timezone conversion if useBrowserTz { data["timestamp"] = tick.Timestamp.Format(time.RFC3339) data["mode"] = mode } 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}, ) if !hidePing { rows = append(rows, map[string]string{"label": "Response", "value": fmt.Sprintf("%dms", tick.ResponseTime), "class": ""}, ) } rows = append(rows, 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}, ) if !hidePing { rows = append(rows, map[string]string{"label": "Avg Response", "value": fmt.Sprintf("%dms", int(tick.AvgResponse)), "class": ""}, ) } rows = append(rows, 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}, ) if !hidePing { rows = append(rows, map[string]string{"label": "Avg Response", "value": fmt.Sprintf("%dms", int(tick.AvgResponse)), "class": ""}, ) } rows = append(rows, 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}, ) if !hidePing { rows = append(rows, map[string]string{"label": "Avg Response", "value": fmt.Sprintf("%dms", int(tick.AvgResponse)), "class": ""}, ) } rows = append(rows, 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) } // formatUptimeTooltip creates JSON data for uptime percentage tooltip func formatUptimeTooltip(uptimePercent float64, totalChecks int64, lastFailure *time.Time, lastFailureError string, timezone string) string { loc := time.Local if timezone != "" && timezone != "Local" && timezone != "Browser" { if l, err := time.LoadLocation(timezone); err == nil { loc = l } } rows := []map[string]string{ {"label": "Uptime", "value": fmt.Sprintf("%.2f%%", uptimePercent), "class": ""}, {"label": "Total Checks", "value": fmt.Sprintf("%d", totalChecks), "class": ""}, } if lastFailure != nil { t := lastFailure.In(loc) failureTime := t.Format("Jan 2, 2006 15:04:05") rows = append(rows, map[string]string{"label": "Last Failure", "value": failureTime, "class": "error"}) if lastFailureError != "" { // Simplify the error for display simplifiedError := simplifyErrorMessage(lastFailureError) rows = append(rows, map[string]string{"label": "Failure Reason", "value": simplifiedError, "class": ""}) } } else { rows = append(rows, map[string]string{"label": "Last Failure", "value": "Never", "class": "success"}) } data := map[string]interface{}{ "header": "Uptime Statistics", "rows": rows, } b, _ := json.Marshal(data) return string(b) } // formatVersionTooltip creates JSON data for version tooltip func (s *Server) formatVersionTooltip() string { cfg := s.getConfig() timezone := cfg.Display.Timezone useBrowserTz := timezone == "Browser" rows := []map[string]string{ {"label": "Version", "value": s.version.Version, "class": ""}, {"label": "Commit", "value": s.version.Commit, "class": ""}, } data := map[string]interface{}{ "header": "Kaze", } // Parse and format build date if available if s.version.Date != "" && s.version.Date != "unknown" { if t, err := time.Parse(time.RFC3339, s.version.Date); err == nil { // Include raw timestamp for browser timezone conversion if useBrowserTz { data["timestamp"] = t.Format(time.RFC3339) data["timestampLabel"] = "Built" } // Convert to configured timezone for server-side rendering loc := time.Local if timezone != "" && timezone != "Local" && timezone != "Browser" { if l, err := time.LoadLocation(timezone); err == nil { loc = l } } t = t.In(loc) rows = append(rows, map[string]string{"label": "Built", "value": t.Format("Jan 2, 2006 15:04"), "class": ""}) } else { rows = append(rows, map[string]string{"label": "Built", "value": s.version.Date, "class": ""}) } } data["rows"] = rows b, _ := json.Marshal(data) return string(b) } // simplifyErrorMessage simplifies error messages for display func simplifyErrorMessage(err string) string { if err == "" { return "" } switch { case strings.Contains(err, "no such host"): return "DNS lookup failed" case strings.Contains(err, "connection refused"): return "Connection refused" case strings.Contains(err, "connection reset"): return "Connection reset" case strings.Contains(err, "timeout"): return "Timeout" case strings.Contains(err, "certificate"): return "SSL/TLS error" case strings.Contains(err, "EOF"): return "Connection closed" case strings.Contains(err, "status code"): return "Unexpected status" case strings.Contains(err, "expected content"): return "Content mismatch" case strings.Contains(err, "i/o timeout"): return "I/O timeout" case strings.Contains(err, "network is unreachable"): return "Network unreachable" default: return "Error" } } // 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" } }