diff options
| author | Fuwn <[email protected]> | 2026-01-28 03:12:23 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-28 03:12:23 -0800 |
| commit | defea76033f75804a45bbb650e19cb16c677295b (patch) | |
| tree | cfc507fb0d782f02925c001429df7d3c1d079420 /internal/server | |
| parent | fix: Handle libsql string-based time values (diff) | |
| download | kaze-defea76033f75804a45bbb650e19cb16c677295b.tar.xz kaze-defea76033f75804a45bbb650e19cb16c677295b.zip | |
feat: Add SSE streaming for instant page load and real-time updates
New refresh_mode 'stream' eliminates blocking database queries from initial
page load. Page renders instantly with skeleton UI, then hydrates via SSE.
- Add SSE hub for managing client connections and broadcasting
- Add /api/stream endpoint with init and update events
- Add stream.html skeleton template with loading animations
- Wire scheduler to broadcast on check completion
- Backwards compatible: page/api modes unchanged
Diffstat (limited to 'internal/server')
| -rw-r--r-- | internal/server/server.go | 335 | ||||
| -rw-r--r-- | internal/server/sse.go | 222 | ||||
| -rw-r--r-- | internal/server/templates/stream.html | 556 |
3 files changed, 1108 insertions, 5 deletions
diff --git a/internal/server/server.go b/internal/server/server.go index 9296647..e0b0b15 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -50,6 +50,7 @@ type Server struct { templates *template.Template reloadConfig ReloadFunc version VersionInfo + sseHub *SSEHub // SSE hub for real-time streaming } // New creates a new HTTP server @@ -66,6 +67,7 @@ func New(cfg *config.Config, store *storage.Storage, sched *monitor.Scheduler, l scheduler: sched, logger: logger, templates: tmpl, + sseHub: NewSSEHub(logger), } // Setup routes @@ -106,6 +108,13 @@ func New(cfg *config.Config, store *storage.Storage, sched *monitor.Scheduler, l // 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), @@ -295,12 +304,15 @@ type IncidentUpdateData struct { Message string } -// handleIndex renders the main status page func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { ctx := r.Context() cfg := s.getConfig() - // Get all monitor stats + 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) @@ -502,15 +514,132 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { return } - // Write buffered response atomically w.Header().Set("Content-Type", "text/html; charset=utf-8") if _, err := w.Write(buf.Bytes()); err != nil { - // Client likely disconnected, just log it s.logger.Debug("failed to write response", "error", err) } } -// handleAPIStatus returns JSON status for all monitors +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() @@ -675,6 +804,202 @@ func (s *Server) handleAPIPage(w http.ResponseWriter, r *http.Request) { 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"}) diff --git a/internal/server/sse.go b/internal/server/sse.go new file mode 100644 index 0000000..f95cd70 --- /dev/null +++ b/internal/server/sse.go @@ -0,0 +1,222 @@ +package server + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "sync" + "time" +) + +// SSEHub manages Server-Sent Events connections and broadcasts +type SSEHub struct { + mu sync.RWMutex + clients map[*sseClient]struct{} + logger *slog.Logger + closed bool + closedCh chan struct{} +} + +type sseClient struct { + ch chan []byte + doneCh chan struct{} + clientIP string +} + +// NewSSEHub creates a new SSE hub for managing client connections +func NewSSEHub(logger *slog.Logger) *SSEHub { + return &SSEHub{ + clients: make(map[*sseClient]struct{}), + logger: logger, + closedCh: make(chan struct{}), + } +} + +// ServeHTTP handles SSE connections +func (h *SSEHub) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Check if the response writer supports flushing + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "SSE not supported", http.StatusInternalServerError) + return + } + + // Set SSE headers + 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") // Disable nginx buffering + + // Create client + client := &sseClient{ + ch: make(chan []byte, 64), // Buffered to prevent blocking broadcasts + doneCh: make(chan struct{}), + clientIP: r.RemoteAddr, + } + + // Register client + h.addClient(client) + defer h.removeClient(client) + + h.logger.Debug("SSE client connected", "remote_addr", r.RemoteAddr) + + // Send initial connection event + fmt.Fprintf(w, "event: connected\ndata: {\"status\":\"connected\"}\n\n") + flusher.Flush() + + // Keepalive ticker (send comment every 30s to keep connection alive) + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + // Stream events to client + for { + select { + case <-r.Context().Done(): + h.logger.Debug("SSE client disconnected", "remote_addr", r.RemoteAddr) + return + + case <-h.closedCh: + // Hub is closing + return + + case data := <-client.ch: + // Send data event + fmt.Fprintf(w, "event: update\ndata: %s\n\n", data) + flusher.Flush() + + case <-ticker.C: + // Send keepalive comment (not an event, just keeps connection alive) + fmt.Fprintf(w, ": keepalive\n\n") + flusher.Flush() + } + } +} + +// Broadcast sends data to all connected clients +func (h *SSEHub) Broadcast(data any) { + h.mu.RLock() + if h.closed { + h.mu.RUnlock() + return + } + + clientCount := len(h.clients) + if clientCount == 0 { + h.mu.RUnlock() + return + } + + // Marshal data to JSON + jsonData, err := json.Marshal(data) + if err != nil { + h.mu.RUnlock() + h.logger.Error("failed to marshal SSE broadcast data", "error", err) + return + } + + // Send to all clients (non-blocking) + for client := range h.clients { + select { + case client.ch <- jsonData: + // Sent successfully + default: + // Client buffer full, skip this update + h.logger.Debug("SSE client buffer full, skipping update", "remote_addr", client.clientIP) + } + } + h.mu.RUnlock() + + h.logger.Debug("SSE broadcast sent", "clients", clientCount, "bytes", len(jsonData)) +} + +// ClientCount returns the number of connected clients +func (h *SSEHub) ClientCount() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.clients) +} + +// Close shuts down the hub and disconnects all clients +func (h *SSEHub) Close() { + h.mu.Lock() + defer h.mu.Unlock() + + if h.closed { + return + } + + h.closed = true + close(h.closedCh) + + // Close all client channels + for client := range h.clients { + close(client.doneCh) + } + h.clients = nil + + h.logger.Info("SSE hub closed") +} + +func (h *SSEHub) addClient(client *sseClient) { + h.mu.Lock() + defer h.mu.Unlock() + + if h.closed { + return + } + + h.clients[client] = struct{}{} + h.logger.Debug("SSE client registered", "total_clients", len(h.clients)) +} + +func (h *SSEHub) removeClient(client *sseClient) { + h.mu.Lock() + defer h.mu.Unlock() + + if _, ok := h.clients[client]; ok { + delete(h.clients, client) + close(client.ch) + h.logger.Debug("SSE client unregistered", "total_clients", len(h.clients)) + } +} + +// SSEPageData is the data structure sent via SSE for page updates +type SSEPageData struct { + Type string `json:"type"` // "init" for initial load, "update" for changes + Monitors map[string]APIMonitorData `json:"monitors"` + OverallStatus string `json:"overall_status"` + Counts StatusCounts `json:"counts"` + LastUpdated time.Time `json:"last_updated"` + // Additional fields for initial load + Groups []SSEGroupData `json:"groups,omitempty"` + Incidents []SSEIncidentData `json:"incidents,omitempty"` + Site *SSESiteData `json:"site,omitempty"` +} + +// SSEGroupData contains group info for initial SSE load +type SSEGroupData struct { + Name string `json:"name"` + MonitorIDs []string `json:"monitor_ids"` + DefaultCollapsed bool `json:"default_collapsed"` + ShowGroupUptime bool `json:"show_group_uptime"` + GroupUptime float64 `json:"group_uptime"` +} + +// SSEIncidentData contains incident info for SSE +type SSEIncidentData struct { + Title string `json:"title"` + Status string `json:"status"` + Message string `json:"message"` + IsActive bool `json:"is_active"` + IsScheduled bool `json:"is_scheduled"` + ScheduledStart *time.Time `json:"scheduled_start,omitempty"` + ScheduledEnd *time.Time `json:"scheduled_end,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` +} + +// SSESiteData contains site info for initial SSE load +type SSESiteData struct { + Name string `json:"name"` + Description string `json:"description"` +} diff --git a/internal/server/templates/stream.html b/internal/server/templates/stream.html new file mode 100644 index 0000000..2e40d3b --- /dev/null +++ b/internal/server/templates/stream.html @@ -0,0 +1,556 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <style> + :root { --scale: {{.Scale}}; } + </style> + {{if .ThemeCSS}} + <style> +{{.ThemeCSS}} + </style> + {{end}} + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{.Site.Name}} [Loading...]</title> + <meta name="description" content="{{.Site.Description}}"> + {{if .Site.Favicon}} + <link rel="icon" href="{{.Site.Favicon}}"> + {{else}} + <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎐</text></svg>"> + {{end}} + <link rel="stylesheet" href="/static/style.css"> + {{if .CustomHead}}{{.CustomHead}}{{end}} + <style> + .skeleton { background: linear-gradient(90deg, var(--skeleton-from, #e5e5e5) 25%, var(--skeleton-to, #f5f5f5) 50%, var(--skeleton-from, #e5e5e5) 75%); background-size: 200% 100%; animation: skeleton-pulse 1.5s ease-in-out infinite; } + @keyframes skeleton-pulse { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } + .dark .skeleton { --skeleton-from: #262626; --skeleton-to: #404040; } + </style> +</head> +<body class="bg-neutral-50 dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 min-h-screen font-mono"> + <div class="max-w-4xl mx-auto px-4 py-8 sm:py-12"> + <header class="mb-8 sm:mb-12"> + <div class="flex items-center justify-between"> + <div class="flex items-center gap-3"> + {{if .Site.Logo}} + <img src="{{.Site.Logo}}" alt="Logo" class="h-8 w-8"> + {{end}} + <div> + <h1 class="text-xl sm:text-2xl font-bold tracking-tight">{{.Site.Name}}</h1> + <p class="text-sm text-neutral-500 dark:text-neutral-400">{{.Site.Description}}</p> + </div> + </div> + </div> + </header> + + <div id="status-banner" class="mb-8 p-4 rounded-lg border bg-neutral-100 dark:bg-neutral-900 border-neutral-200 dark:border-neutral-800"> + <div class="flex items-center justify-between"> + <div class="flex items-center gap-3"> + <div class="flex-shrink-0"> + <div id="status-dot" class="w-3 h-3 rounded-full bg-neutral-400 animate-pulse"></div> + </div> + <span id="status-text" class="font-medium">Connecting...</span> + </div> + <span id="current-time" class="text-sm text-neutral-500 dark:text-neutral-400">--</span> + </div> + </div> + + <div class="space-y-6"> + {{range $groupIndex, $group := .Groups}} + <section class="border border-neutral-200 dark:border-neutral-800 rounded-lg overflow-hidden" data-group="{{$group.Name}}"> + <div class="px-4 py-3 bg-neutral-100 dark:bg-neutral-900 border-b border-neutral-200 dark:border-neutral-800 cursor-pointer hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-colors" onclick="toggleGroup('{{$group.Name}}')"> + <div class="flex items-center justify-between"> + <div class="flex items-center gap-3"> + <svg class="w-4 h-4 text-neutral-600 dark:text-neutral-400 transition-transform" data-group-icon="{{$group.Name}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/> + </svg> + <h2 class="font-semibold text-sm uppercase tracking-wider text-neutral-600 dark:text-neutral-400">{{$group.Name}}</h2> + </div> + {{if $group.ShowGroupUptime}} + <span data-group-uptime="{{$group.Name}}" class="text-sm font-medium text-neutral-400">--</span> + {{end}} + </div> + </div> + <div class="divide-y divide-neutral-200 dark:divide-neutral-800 group-content" data-group-content="{{$group.Name}}" data-default-collapsed="{{$group.DefaultCollapsed}}"> + {{range .Monitors}} + <div class="p-4 hover:bg-neutral-100/50 dark:hover:bg-neutral-900/50 transition-colors" data-monitor-id="{{.ID}}" data-monitor="{{.Name}}" data-group="{{$group.Name}}"{{if .HidePing}} data-hide-ping{{end}}{{if .DisablePingTooltips}} data-disable-tooltips{{end}}> + <div class="flex items-start justify-between gap-4"> + <div class="flex-1 min-w-0"> + <div class="flex items-center gap-2 mb-2"> + <div class="w-2 h-2 rounded-full bg-neutral-400 flex-shrink-0" data-status-dot></div> + {{if .Link}}<a href="{{.Link}}" target="_blank" rel="noopener noreferrer" class="font-medium truncate">{{.Name}}</a>{{else}}<span class="font-medium truncate">{{.Name}}</span>{{end}} + <span class="text-xs px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 uppercase">{{.Type}}</span> + </div> + <div class="flex items-center gap-4 text-xs text-neutral-500 dark:text-neutral-400"> + {{if not .HidePing}}<span data-response-time>--</span>{{end}} + <span data-ssl-info class="hidden"></span> + <span data-error class="hidden text-red-600 dark:text-red-400"></span> + </div> + </div> + <div class="flex items-center gap-2 flex-shrink-0"> + <span data-uptime class="text-sm font-medium text-neutral-400">--</span> + </div> + </div> + <div class="mt-3 flex gap-px" data-history-bar> + {{range seq $.TickCount}} + <div class="flex-1 h-6 rounded-sm skeleton"></div> + {{end}} + </div> + </div> + {{end}} + </div> + </section> + {{end}} + </div> + + {{if .Incidents}} + <section class="mt-8"> + <h2 class="text-lg font-semibold mb-4">Incidents</h2> + <div class="space-y-4"> + {{range .Incidents}} + <div class="border border-neutral-200 dark:border-neutral-800 rounded-lg overflow-hidden"> + <div class="p-4 {{if .IsActive}}bg-yellow-50 dark:bg-yellow-950/20{{else}}bg-neutral-50 dark:bg-neutral-900/50{{end}}"> + <div class="flex items-start justify-between gap-4"> + <div> + <div class="flex items-center gap-2 mb-1"> + {{if eq .Status "resolved"}} + <svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> + {{else if eq .Status "scheduled"}} + <svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> + {{else}} + <svg class="w-4 h-4 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg> + {{end}} + <span class="font-medium">{{.Title}}</span> + </div> + <p class="text-sm text-neutral-600 dark:text-neutral-400">{{.Message}}</p> + {{if .IsScheduled}} + <p class="text-xs text-neutral-500 dark:text-neutral-500 mt-2">Scheduled: {{if .ScheduledStart}}{{.ScheduledStart}}{{end}} - {{if .ScheduledEnd}}{{.ScheduledEnd}}{{end}}</p> + {{end}} + </div> + <div class="flex-shrink-0"> + <span class="text-xs px-2 py-1 rounded-full {{if eq .Status "resolved"}}bg-emerald-100 dark:bg-emerald-900/50 text-emerald-700 dark:text-emerald-300{{else if eq .Status "scheduled"}}bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300{{else if eq .Status "investigating"}}bg-yellow-100 dark:bg-yellow-900/50 text-yellow-700 dark:text-yellow-300{{else if eq .Status "identified"}}bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300{{else}}bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300{{end}} capitalize">{{.Status}}</span> + </div> + </div> + </div> + </div> + {{end}} + </div> + </section> + {{end}} + + <footer class="mt-12 pt-6 border-t border-neutral-200 dark:border-neutral-800"> + <div class="flex items-center justify-between text-xs text-neutral-500 dark:text-neutral-400"> + <span id="last-updated">Connecting...</span> + <span data-tooltip='{{.VersionTooltip}}'>Powered by <a href="https://github.com/Fuwn/kaze" class="hover:text-neutral-900 dark:hover:text-neutral-100">Kaze</a></span> + </div> + </footer> + </div> + + <div id="tooltip" class="tooltip"></div> + + <div id="command-palette" class="command-palette"> + <div class="command-palette-backdrop"></div> + <div class="command-palette-container"> + <input type="text" id="command-input" class="command-input" placeholder="Search groups and monitors..." autocomplete="off" /> + <div id="command-results" class="command-results"></div> + <div class="command-hint"> + <span><kbd>↑↓</kbd> navigate</span> + <span><kbd>↵</kbd> select</span> + <span><kbd>esc</kbd> close</span> + </div> + </div> + </div> + + <script> + const siteName = '{{.Site.Name}}'; + const tickMode = '{{.TickMode}}'; + const tickCount = {{.TickCount}}; + const useBrowserTimezone = {{.UseBrowserTimezone}}; + + function toggleGroup(groupName) { + const content = document.querySelector('[data-group-content="' + groupName + '"]'); + const icon = document.querySelector('[data-group-icon="' + groupName + '"]'); + if (!content || !icon) return; + const isCollapsed = content.classList.contains('collapsed'); + if (isCollapsed) { + content.classList.remove('collapsed'); + icon.classList.remove('rotated'); + localStorage.setItem('group-' + groupName, 'expanded'); + } else { + content.classList.add('collapsed'); + icon.classList.add('rotated'); + localStorage.setItem('group-' + groupName, 'collapsed'); + } + } + + (function initGroupStates() { + document.querySelectorAll('[data-group-content]').forEach(function(content) { + const groupName = content.getAttribute('data-group-content'); + const defaultCollapsed = content.getAttribute('data-default-collapsed') === 'true'; + const savedState = localStorage.getItem('group-' + groupName); + const icon = document.querySelector('[data-group-icon="' + groupName + '"]'); + let shouldCollapse = savedState !== null ? savedState === 'collapsed' : defaultCollapsed; + if (shouldCollapse) { + content.classList.add('collapsed'); + if (icon) icon.classList.add('rotated'); + } + }); + })(); + + (function() { + const tooltip = document.getElementById('tooltip'); + let currentTarget = null; + let hideTimeout = null; + + function renderTooltip(data) { + let html = '<span class="tooltip-header">' + data.header + '</span>'; + if (data.rows) { + data.rows.forEach(function(row) { + html += '<div class="tooltip-row">'; + html += '<span class="tooltip-label">' + row.label + '</span>'; + html += '<span class="tooltip-value ' + (row.class || '') + '">' + row.value + '</span>'; + html += '</div>'; + }); + } + return html; + } + + function showTooltip(e) { + const target = e.target.closest('[data-tooltip]'); + if (!target) return; + clearTimeout(hideTimeout); + currentTarget = target; + try { + const data = JSON.parse(target.getAttribute('data-tooltip')); + tooltip.innerHTML = renderTooltip(data); + } catch (err) { + tooltip.innerHTML = target.getAttribute('data-tooltip'); + } + tooltip.classList.add('visible'); + const rect = target.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2); + const padding = 8; + if (left < padding) left = padding; + else if (left + tooltipRect.width > window.innerWidth - padding) left = window.innerWidth - tooltipRect.width - padding; + let top = rect.top - tooltipRect.height - 8; + if (top < padding) { top = rect.bottom + 8; tooltip.classList.add('tooltip-top'); } + else tooltip.classList.remove('tooltip-top'); + tooltip.style.left = left + 'px'; + tooltip.style.top = top + 'px'; + } + + function hideTooltip() { + hideTimeout = setTimeout(() => { tooltip.classList.remove('visible'); currentTarget = null; }, 100); + } + + document.addEventListener('mouseenter', showTooltip, true); + document.addEventListener('mouseleave', function(e) { if (e.target.closest('[data-tooltip]')) hideTooltip(); }, true); + document.addEventListener('touchstart', function(e) { + const target = e.target.closest('[data-tooltip]'); + if (target) { if (currentTarget === target) hideTooltip(); else showTooltip(e); } + else hideTooltip(); + }, { passive: true }); + })(); + + (function initSSE() { + let eventSource = null; + let reconnectAttempts = 0; + const maxReconnectDelay = 30000; + + function getStatusColor(status) { + switch(status) { + case 'up': return 'bg-emerald-500'; + case 'degraded': return 'bg-yellow-500'; + case 'down': return 'bg-red-500'; + default: return 'bg-neutral-400'; + } + } + + function getTickColor(tick) { + if (!tick || tick.TotalChecks === 0) return 'bg-neutral-200 dark:bg-neutral-800'; + if (tick.Status) { + switch(tick.Status) { + case 'up': return 'bg-emerald-500'; + case 'degraded': return 'bg-yellow-500'; + case 'down': return 'bg-red-500'; + default: return 'bg-neutral-200 dark:bg-neutral-800'; + } + } + if (tick.FailureCount > 0 && tick.SuccessCount === 0) return 'bg-red-500'; + if (tick.FailureCount > 0) return 'bg-yellow-500'; + return 'bg-emerald-500'; + } + + function getUptimeColor(uptime) { + if (uptime >= 99.0) return 'text-emerald-600 dark:text-emerald-400'; + if (uptime >= 95.0) return 'text-yellow-600 dark:text-yellow-400'; + return 'text-red-600 dark:text-red-400'; + } + + function formatDuration(ms) { + if (ms < 1000) return ms + 'ms'; + return (ms / 1000).toFixed(2) + 's'; + } + + function formatUptime(pct) { + if (pct < 0) return '--'; + return pct.toFixed(2) + '%'; + } + + function simplifyError(err) { + if (!err) return ''; + const lower = err.toLowerCase(); + if (lower.includes('timeout') || lower.includes('deadline')) return 'Timeout'; + if (lower.includes('connection refused')) return 'Connection refused'; + if (lower.includes('no such host') || lower.includes('dns')) return 'DNS error'; + if (lower.includes('certificate') || lower.includes('tls')) return 'SSL/TLS error'; + if (lower.includes('eof') || lower.includes('reset by peer')) return 'Connection reset'; + return 'Error'; + } + + function formatDateTime(date) { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return months[date.getMonth()] + ' ' + date.getDate() + ', ' + date.getFullYear() + ' ' + + String(date.getHours()).padStart(2, '0') + ':' + String(date.getMinutes()).padStart(2, '0'); + } + + function timeAgo(date) { + const seconds = Math.floor((new Date() - date) / 1000); + if (seconds < 60) return seconds + ' seconds ago'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return minutes + ' minute' + (minutes === 1 ? '' : 's') + ' ago'; + const hours = Math.floor(minutes / 60); + if (hours < 24) return hours + ' hour' + (hours === 1 ? '' : 's') + ' ago'; + const days = Math.floor(hours / 24); + return days + ' day' + (days === 1 ? '' : 's') + ' ago'; + } + + function updateBanner(data) { + const banner = document.getElementById('status-banner'); + const dot = document.getElementById('status-dot'); + const text = document.getElementById('status-text'); + const time = document.getElementById('current-time'); + + text.textContent = data.overall_status; + time.textContent = formatDateTime(new Date(data.last_updated)); + + if (data.overall_status === 'All Systems Operational') { + banner.className = 'mb-8 p-4 rounded-lg border bg-emerald-50 dark:bg-emerald-950/30 border-emerald-200 dark:border-emerald-900'; + dot.className = 'w-3 h-3 rounded-full bg-emerald-500 animate-pulse'; + } else if (data.overall_status === 'Partial Outage') { + banner.className = 'mb-8 p-4 rounded-lg border bg-yellow-50 dark:bg-yellow-950/30 border-yellow-200 dark:border-yellow-900'; + dot.className = 'w-3 h-3 rounded-full bg-yellow-500 animate-pulse'; + } else { + banner.className = 'mb-8 p-4 rounded-lg border bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-900'; + dot.className = 'w-3 h-3 rounded-full bg-red-500 animate-pulse'; + } + + let title = siteName + ' [↑' + data.counts.Up; + if (data.counts.Down > 0) title += '/' + data.counts.Down + '↓'; + title += ']'; + document.title = title; + } + + function updateMonitor(monitorId, monitorData) { + const el = document.querySelector('[data-monitor-id="' + monitorId + '"]'); + if (!el) return; + + const statusDot = el.querySelector('[data-status-dot]'); + if (statusDot) statusDot.className = 'w-2 h-2 rounded-full flex-shrink-0 ' + getStatusColor(monitorData.status); + + const responseTime = el.querySelector('[data-response-time]'); + if (responseTime) responseTime.textContent = formatDuration(monitorData.response_time); + + const uptime = el.querySelector('[data-uptime]'); + if (uptime) { + uptime.textContent = formatUptime(monitorData.uptime); + uptime.className = 'text-sm font-medium ' + getUptimeColor(monitorData.uptime); + } + + const errorEl = el.querySelector('[data-error]'); + if (errorEl) { + if (monitorData.last_error) { + errorEl.textContent = simplifyError(monitorData.last_error); + errorEl.classList.remove('hidden'); + } else { + errorEl.classList.add('hidden'); + } + } + + const sslEl = el.querySelector('[data-ssl-info]'); + if (sslEl && monitorData.ssl_days_left > 0) { + sslEl.textContent = 'SSL: ' + monitorData.ssl_days_left + 'd'; + sslEl.classList.remove('hidden'); + if (monitorData.ssl_days_left < 7) sslEl.className = 'text-red-600 dark:text-red-400'; + else if (monitorData.ssl_days_left < 14) sslEl.className = 'text-yellow-600 dark:text-yellow-400'; + } + + const historyBar = el.querySelector('[data-history-bar]'); + if (historyBar && monitorData.ticks) { + const hidePing = el.hasAttribute('data-hide-ping'); + const disableTooltips = el.hasAttribute('data-disable-tooltips'); + let html = ''; + for (const tick of monitorData.ticks) { + const color = getTickColor(tick); + html += '<div class="flex-1 h-6 rounded-sm ' + color + '"></div>'; + } + while (historyBar.children.length < tickCount) { + html += '<div class="flex-1 h-6 rounded-sm bg-neutral-200 dark:bg-neutral-800"></div>'; + } + historyBar.innerHTML = html; + } + } + + function updateGroupUptime(groups, monitors) { + if (!groups) return; + for (const group of groups) { + const uptimeEl = document.querySelector('[data-group-uptime="' + group.name + '"]'); + if (!uptimeEl) continue; + + let totalUptime = 0; + let count = 0; + for (const monitorId of group.monitor_ids || []) { + const m = monitors[monitorId]; + if (m && m.uptime >= 0) { + totalUptime += m.uptime; + count++; + } + } + if (count > 0) { + const avgUptime = totalUptime / count; + uptimeEl.textContent = formatUptime(avgUptime); + uptimeEl.className = 'text-sm font-medium ' + getUptimeColor(avgUptime); + } + } + } + + function handleData(data) { + updateBanner(data); + + for (const [monitorId, monitorData] of Object.entries(data.monitors || {})) { + updateMonitor(monitorId, monitorData); + } + + if (data.groups) updateGroupUptime(data.groups, data.monitors); + + const lastUpdated = document.getElementById('last-updated'); + if (lastUpdated) lastUpdated.textContent = 'Updated ' + timeAgo(new Date(data.last_updated)); + } + + function connect() { + if (eventSource) eventSource.close(); + + document.getElementById('status-text').textContent = 'Connecting...'; + eventSource = new EventSource('/api/stream'); + + eventSource.addEventListener('init', function(e) { + reconnectAttempts = 0; + const data = JSON.parse(e.data); + handleData(data); + }); + + eventSource.addEventListener('update', function(e) { + const data = JSON.parse(e.data); + handleData(data); + }); + + eventSource.onerror = function() { + eventSource.close(); + reconnectAttempts++; + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectDelay); + document.getElementById('status-text').textContent = 'Reconnecting in ' + Math.round(delay/1000) + 's...'; + setTimeout(connect, delay); + }; + } + + connect(); + })(); + + (function() { + const palette = document.getElementById('command-palette'); + const input = document.getElementById('command-input'); + const results = document.getElementById('command-results'); + let selectedIndex = -1; + let items = []; + + function buildIndex() { + const index = []; + document.querySelectorAll('[data-group-content]').forEach(function(el) { + const name = el.getAttribute('data-group-content'); + index.push({ type: 'group', name: name, element: el.closest('section'), searchText: name.toLowerCase() }); + }); + document.querySelectorAll('[data-monitor]').forEach(function(el) { + const name = el.getAttribute('data-monitor'); + const group = el.getAttribute('data-group'); + index.push({ type: 'monitor', name: name, group: group, element: el, searchText: (name + ' ' + group).toLowerCase() }); + }); + return index; + } + + const searchIndex = buildIndex(); + + function openPalette() { palette.classList.add('visible'); input.value = ''; input.focus(); search(''); } + function closePalette() { palette.classList.remove('visible'); selectedIndex = -1; } + + function search(query) { + const q = query.toLowerCase().trim(); + items = q === '' ? searchIndex.slice(0, 10) : searchIndex.filter(item => item.searchText.includes(q)).slice(0, 10); + selectedIndex = items.length > 0 ? 0 : -1; + renderResults(); + } + + function renderResults() { + if (items.length === 0) { results.innerHTML = '<div class="command-empty">No results found</div>'; return; } + results.innerHTML = items.map(function(item, i) { + const isSelected = i === selectedIndex ? ' selected' : ''; + const icon = item.type === 'group' + ? '<svg class="command-item-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>' + : '<svg class="command-item-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3" stroke-width="2"/></svg>'; + const meta = item.type === 'group' ? '<span class="command-item-type">Group</span>' : '<span class="command-item-path">' + item.group + '</span>'; + return '<div class="command-item' + isSelected + '" data-index="' + i + '">' + icon + '<div class="command-item-content"><div class="command-item-name">' + item.name + '</div>' + meta + '</div></div>'; + }).join(''); + if (selectedIndex >= 0) { + const selectedEl = results.querySelector('.command-item.selected'); + if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' }); + } + } + + function selectItem(index) { + if (index < 0 || index >= items.length) return; + const item = items[index]; + closePalette(); + if (item.type === 'group' || item.type === 'monitor') { + const groupName = item.type === 'group' ? item.name : item.group; + const content = document.querySelector('[data-group-content="' + groupName + '"]'); + const icon = document.querySelector('[data-group-icon="' + groupName + '"]'); + if (content && content.classList.contains('collapsed')) { + content.classList.remove('collapsed'); + if (icon) icon.classList.remove('rotated'); + localStorage.setItem('group-' + groupName, 'expanded'); + } + } + setTimeout(function() { + item.element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + item.element.classList.add('highlight-jump'); + setTimeout(function() { item.element.classList.remove('highlight-jump'); }, 1000); + }, 50); + } + + document.addEventListener('keydown', function(e) { + if ((e.key === 'k' && (e.metaKey || e.ctrlKey)) || (e.key === '/' && !palette.classList.contains('visible') && document.activeElement.tagName !== 'INPUT')) { + e.preventDefault(); openPalette(); return; + } + if (!palette.classList.contains('visible')) return; + if (e.key === 'Escape') { e.preventDefault(); closePalette(); } + else if (e.key === 'ArrowDown') { e.preventDefault(); selectedIndex = Math.min(selectedIndex + 1, items.length - 1); renderResults(); } + else if (e.key === 'ArrowUp') { e.preventDefault(); selectedIndex = Math.max(selectedIndex - 1, 0); renderResults(); } + else if (e.key === 'Enter') { e.preventDefault(); if (selectedIndex >= 0) selectItem(selectedIndex); } + }); + + input.addEventListener('input', function() { search(input.value); }); + results.addEventListener('click', function(e) { const item = e.target.closest('.command-item'); if (item) selectItem(parseInt(item.getAttribute('data-index'), 10)); }); + palette.querySelector('.command-palette-backdrop').addEventListener('click', closePalette); + })(); + </script> +</body> +</html> |