aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-28 03:12:23 -0800
committerFuwn <[email protected]>2026-01-28 03:12:23 -0800
commitdefea76033f75804a45bbb650e19cb16c677295b (patch)
treecfc507fb0d782f02925c001429df7d3c1d079420
parentfix: Handle libsql string-based time values (diff)
downloadkaze-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
-rw-r--r--cmd/kaze/main.go8
-rw-r--r--internal/config/config.go6
-rw-r--r--internal/monitor/scheduler.go29
-rw-r--r--internal/server/server.go335
-rw-r--r--internal/server/sse.go222
-rw-r--r--internal/server/templates/stream.html556
6 files changed, 1136 insertions, 20 deletions
diff --git a/cmd/kaze/main.go b/cmd/kaze/main.go
index aaed1fa..b8895a0 100644
--- a/cmd/kaze/main.go
+++ b/cmd/kaze/main.go
@@ -108,6 +108,10 @@ func main() {
}
srv.SetVersion(version, commit, date)
+ sched.SetOnCheckCallback(func() {
+ srv.BroadcastStatusUpdate(context.Background())
+ })
+
// Setup graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -178,10 +182,12 @@ func main() {
return fmt.Errorf("failed to create new scheduler: %w", err)
}
- // Swap config/scheduler without restarting the HTTP listener
srv.UpdateConfig(newCfg, newSched)
cfg = newCfg
sched = newSched
+ sched.SetOnCheckCallback(func() {
+ srv.BroadcastStatusUpdate(context.Background())
+ })
sched.Start()
logger.Info("configuration reloaded successfully (zero-downtime)",
diff --git a/internal/config/config.go b/internal/config/config.go
index 3ac03ea..5e74b5c 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -648,12 +648,10 @@ func (c *Config) validate() error {
return fmt.Errorf("api.access is 'authenticated' but no api.keys provided")
}
- // Validate refresh mode
switch c.Display.RefreshMode {
- case "page", "api":
- // Valid modes
+ case "page", "api", "stream":
default:
- return fmt.Errorf("invalid display.refresh_mode %q (must be page or api)", c.Display.RefreshMode)
+ return fmt.Errorf("invalid display.refresh_mode %q (must be page, api, or stream)", c.Display.RefreshMode)
}
if c.Display.RefreshInterval < 5 {
diff --git a/internal/monitor/scheduler.go b/internal/monitor/scheduler.go
index 809bab5..0d584ea 100644
--- a/internal/monitor/scheduler.go
+++ b/internal/monitor/scheduler.go
@@ -10,16 +10,18 @@ import (
"github.com/Fuwn/kaze/internal/storage"
)
-// Scheduler manages and runs all monitors
+type OnCheckCallback func()
+
type Scheduler struct {
- monitors []Monitor
- monitorCfg map[string]config.MonitorConfig // Monitor configs by ID (group/name) for reset flag checks
- configPath string
- storage *storage.Storage
- logger *slog.Logger
- wg sync.WaitGroup
- ctx context.Context
- cancel context.CancelFunc
+ monitors []Monitor
+ monitorCfg map[string]config.MonitorConfig
+ configPath string
+ storage *storage.Storage
+ logger *slog.Logger
+ wg sync.WaitGroup
+ ctx context.Context
+ cancel context.CancelFunc
+ onCheckDone OnCheckCallback
}
// NewScheduler creates a new monitor scheduler
@@ -197,6 +199,10 @@ func (s *Scheduler) executeCheck(mon Monitor) {
}
s.storage.RecordCheck()
+
+ if s.onCheckDone != nil {
+ s.onCheckDone()
+ }
}
// runCleanup periodically cleans up old data
@@ -247,11 +253,14 @@ func (s *Scheduler) runMaintenance() {
}
}
-// GetMonitors returns all registered monitors
func (s *Scheduler) GetMonitors() []Monitor {
return s.monitors
}
+func (s *Scheduler) SetOnCheckCallback(cb OnCheckCallback) {
+ s.onCheckDone = cb
+}
+
// RunCheck manually triggers a check for a specific monitor by ID (group/name format)
func (s *Scheduler) RunCheck(id string) *Result {
for _, mon := range s.monitors {
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>