diff options
| author | Fuwn <[email protected]> | 2026-01-20 17:16:22 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-20 17:16:22 -0800 |
| commit | 2371b28128213fbcc8d1c062dccc3074e6b0fa98 (patch) | |
| tree | 84452dbf5f2b1821d1fc5cf8ecdb0a5ad2b74f56 /internal/server/server.go | |
| parent | fix: Use wildcard path for badge endpoint to support .svg extension (diff) | |
| download | kaze-2371b28128213fbcc8d1c062dccc3074e6b0fa98.tar.xz kaze-2371b28128213fbcc8d1c062dccc3074e6b0fa98.zip | |
feat: Use composite group/name key for monitor identification
Previously monitors were identified by just their name, causing monitors
with the same name in different groups to share data in the database.
Changes:
- Add ID() method to MonitorConfig returning 'group/name' format
- Add Group field to MonitorConfig (set at runtime)
- Update Monitor interface with ID() and Group() methods
- Update all monitor implementations (http, tcp, dns, icmp, gemini,
graphql, database) to use composite ID
- Update Scheduler to use monitor ID instead of name
- Update server handlers to use composite ID for stats lookups
- Change API routes to use {group}/{name} pattern:
- /api/monitor/{group}/{name}
- /api/history/{group}/{name}
- /api/uptime/{group}/{name}
- /api/badge/{group}/{name}.svg
- URL-encode group and name components to handle special characters
(e.g., slashes in names become %2F)
- Update config.UpdateResetFlag to accept group and name separately
BREAKING: API endpoints now require group in the path. Existing database
data using just monitor names won't be associated with the new composite
keys.
Diffstat (limited to 'internal/server/server.go')
| -rw-r--r-- | internal/server/server.go | 100 |
1 files changed, 68 insertions, 32 deletions
diff --git a/internal/server/server.go b/internal/server/server.go index ace36f1..b47bf79 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -10,6 +10,7 @@ import ( "io/fs" "log/slog" "net/http" + "net/url" "sort" "strconv" "strings" @@ -68,18 +69,18 @@ func New(cfg *config.Config, store *storage.Storage, sched *monitor.Scheduler, l // API endpoints (protected by API access control) mux.HandleFunc("GET /api/status", s.withAPIAuth(s.handleAPIStatus)) - mux.HandleFunc("GET /api/monitor/{name}", s.withAPIAuth(s.handleAPIMonitor)) - mux.HandleFunc("GET /api/history/{name}", s.withAPIAuth(s.handleAPIHistory)) + mux.HandleFunc("GET /api/monitor/{group}/{name}", s.withAPIAuth(s.handleAPIMonitor)) + mux.HandleFunc("GET /api/history/{group}/{name}", s.withAPIAuth(s.handleAPIHistory)) mux.HandleFunc("GET /api/summary", s.withAPIAuth(s.handleAPISummary)) - mux.HandleFunc("GET /api/uptime/{name}", s.withAPIAuth(s.handleAPIUptime)) + mux.HandleFunc("GET /api/uptime/{group}/{name}", s.withAPIAuth(s.handleAPIUptime)) mux.HandleFunc("GET /api/incidents", s.withAPIAuth(s.handleAPIIncidents)) // Health check - always public (for load balancers, monitoring) mux.HandleFunc("GET /api/health", s.handleAPIHealth) // Badge endpoint - always public (for embedding in READMEs, docs) - // Note: {name...} captures the rest of the path including .svg extension - mux.HandleFunc("GET /api/badge/{name...}", s.handleAPIBadge) + // Note: {path...} captures the rest of the path (group/name.svg) + mux.HandleFunc("GET /api/badge/{path...}", s.handleAPIBadge) // Full page data endpoint - public if refresh_mode=api, otherwise follows api.access if cfg.Display.RefreshMode == "api" { @@ -326,7 +327,9 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { DisableUptimeTooltip: monCfg.DisableUptimeTooltip, } - if stat, ok := stats[monCfg.Name]; ok { + // Use composite ID (group/name) to look up stats + monitorID := monCfg.ID() + if stat, ok := stats[monitorID]; ok { md.Status = stat.CurrentStatus md.ResponseTime = stat.LastResponseTime md.UptimePercent = stat.UptimePercent @@ -353,13 +356,13 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { // Get aggregated history for display ticks, err := s.storage.GetAggregatedHistory( ctx, - monCfg.Name, + monitorID, 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) + s.logger.Error("failed to get tick history", "monitor", monitorID, "error", err) } else { md.Ticks = ticks } @@ -500,13 +503,18 @@ func (s *Server) handleAPIStatus(w http.ResponseWriter, r *http.Request) { // handleAPIMonitor returns JSON status for a specific monitor func (s *Server) handleAPIMonitor(w http.ResponseWriter, r *http.Request) { + group := r.PathValue("group") name := r.PathValue("name") - if name == "" { - s.jsonError(w, "Monitor name required", http.StatusBadRequest) + if group == "" || name == "" { + s.jsonError(w, "Group and monitor name required", http.StatusBadRequest) return } - stats, err := s.storage.GetMonitorStats(r.Context(), name) + // Construct composite ID (path values are already URL-decoded by net/http, + // but we need to re-encode to match the internal ID format) + monitorID := url.PathEscape(group) + "/" + url.PathEscape(name) + + stats, err := s.storage.GetMonitorStats(r.Context(), monitorID) if err != nil { s.jsonError(w, "Failed to get monitor stats", http.StatusInternalServerError) return @@ -517,12 +525,16 @@ func (s *Server) handleAPIMonitor(w http.ResponseWriter, r *http.Request) { // handleAPIHistory returns aggregated history for a monitor func (s *Server) handleAPIHistory(w http.ResponseWriter, r *http.Request) { + group := r.PathValue("group") name := r.PathValue("name") - if name == "" { - s.jsonError(w, "Monitor name required", http.StatusBadRequest) + if group == "" || name == "" { + s.jsonError(w, "Group and monitor name required", http.StatusBadRequest) return } + // Construct composite ID (re-encode to match internal format) + monitorID := url.PathEscape(group) + "/" + url.PathEscape(name) + // Allow optional parameters, default to config values mode := s.config.Display.TickMode if modeParam := r.URL.Query().Get("mode"); modeParam != "" { @@ -539,14 +551,14 @@ func (s *Server) handleAPIHistory(w http.ResponseWriter, r *http.Request) { } } - ticks, err := s.storage.GetAggregatedHistory(r.Context(), name, count, mode, s.config.Display.PingFixedSlots) + ticks, err := s.storage.GetAggregatedHistory(r.Context(), monitorID, 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, + "monitor": monitorID, "mode": mode, "count": count, "ticks": ticks, @@ -593,7 +605,9 @@ func (s *Server) handleAPIPage(w http.ResponseWriter, r *http.Request) { // Build monitor data with history for _, group := range s.config.Groups { for _, monCfg := range group.Monitors { - stat, ok := stats[monCfg.Name] + // Use composite ID (group/name) to look up stats + monitorID := monCfg.ID() + stat, ok := stats[monitorID] if !ok { continue } @@ -601,17 +615,17 @@ func (s *Server) handleAPIPage(w http.ResponseWriter, r *http.Request) { // Get history ticks ticks, err := s.storage.GetAggregatedHistory( ctx, - monCfg.Name, + monitorID, 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) + s.logger.Error("failed to get tick history", "monitor", monitorID, "error", err) ticks = nil } - response.Monitors[monCfg.Name] = APIMonitorData{ + response.Monitors[monitorID] = APIMonitorData{ Status: stat.CurrentStatus, ResponseTime: stat.LastResponseTime, Uptime: stat.UptimePercent, @@ -654,21 +668,37 @@ func (s *Server) handleAPIHealth(w http.ResponseWriter, r *http.Request) { // handleAPIBadge returns an SVG status badge for a monitor (shields.io style) func (s *Server) handleAPIBadge(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - if name == "" { - http.Error(w, "Monitor name required", http.StatusBadRequest) + path := r.PathValue("path") + if path == "" { + http.Error(w, "Monitor path required (group/name.svg)", http.StatusBadRequest) return } // Strip .svg extension if present - name = strings.TrimSuffix(name, ".svg") + path = strings.TrimSuffix(path, ".svg") + + // The path should be in format "group/name" (URL-encoded components) + // Split into group and name, then re-encode to match internal ID format + idx := strings.Index(path, "/") + var monitorID, displayName string + if idx >= 0 { + group := path[:idx] + name := path[idx+1:] + // Re-encode to ensure consistent internal format + monitorID = url.PathEscape(group) + "/" + url.PathEscape(name) + displayName = name + } else { + // No group, just name + monitorID = url.PathEscape(path) + displayName = path + } // Get monitor stats - stats, err := s.storage.GetMonitorStats(r.Context(), name) + stats, err := s.storage.GetMonitorStats(r.Context(), monitorID) if err != nil { - s.logger.Error("failed to get monitor stats for badge", "monitor", name, "error", err) + s.logger.Error("failed to get monitor stats for badge", "monitor", monitorID, "error", err) // Return a gray "unknown" badge on error - s.serveBadge(w, r, name, "unknown", "#9ca3af") + s.serveBadge(w, r, displayName, "unknown", "#9ca3af") return } @@ -692,7 +722,7 @@ func (s *Server) handleAPIBadge(w http.ResponseWriter, r *http.Request) { // Check for custom label label := r.URL.Query().Get("label") if label == "" { - label = name + label = displayName } // Check for style (flat or plastic, default: flat) @@ -809,7 +839,9 @@ func (s *Server) handleAPISummary(w http.ResponseWriter, r *http.Request) { for _, group := range s.config.Groups { for _, monCfg := range group.Monitors { - stat, ok := stats[monCfg.Name] + // Use composite ID (group/name) to look up stats + monitorID := monCfg.ID() + stat, ok := stats[monitorID] if !ok { continue } @@ -851,12 +883,16 @@ type APIUptimeResponse struct { // handleAPIUptime returns historical uptime for a specific period func (s *Server) handleAPIUptime(w http.ResponseWriter, r *http.Request) { + group := r.PathValue("group") name := r.PathValue("name") - if name == "" { - s.jsonError(w, "Monitor name required", http.StatusBadRequest) + if group == "" || name == "" { + s.jsonError(w, "Group and monitor name required", http.StatusBadRequest) return } + // Construct composite ID (re-encode to match internal format) + monitorID := url.PathEscape(group) + "/" + url.PathEscape(name) + // Parse period (default: 24h, options: 1h, 24h, 7d, 30d, 90d) period := r.URL.Query().Get("period") if period == "" { @@ -880,14 +916,14 @@ func (s *Server) handleAPIUptime(w http.ResponseWriter, r *http.Request) { return } - stats, err := s.storage.GetUptimeStats(r.Context(), name, duration) + stats, err := s.storage.GetUptimeStats(r.Context(), monitorID, duration) if err != nil { s.jsonError(w, "Failed to get uptime stats", http.StatusInternalServerError) return } s.jsonResponse(w, APIUptimeResponse{ - Monitor: name, + Monitor: monitorID, Period: period, UptimePercent: stats.UptimePercent, TotalChecks: stats.TotalChecks, |