aboutsummaryrefslogtreecommitdiff
path: root/internal/server/server.go
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-17 23:17:49 -0800
committerFuwn <[email protected]>2026-01-17 23:17:49 -0800
commit4bc6165258cd7b5b76ccb01aa75c7cefdc35d143 (patch)
treee7c3bb335a1efd48f82d365169e8b4a66b7abe1d /internal/server/server.go
downloadkaze-4bc6165258cd7b5b76ccb01aa75c7cefdc35d143.tar.xz
kaze-4bc6165258cd7b5b76ccb01aa75c7cefdc35d143.zip
feat: Initial commit
Diffstat (limited to 'internal/server/server.go')
-rw-r--r--internal/server/server.go839
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"
+ }
+}