aboutsummaryrefslogtreecommitdiff
path: root/internal/server/server.go
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-20 17:16:22 -0800
committerFuwn <[email protected]>2026-01-20 17:16:22 -0800
commit2371b28128213fbcc8d1c062dccc3074e6b0fa98 (patch)
tree84452dbf5f2b1821d1fc5cf8ecdb0a5ad2b74f56 /internal/server/server.go
parentfix: Use wildcard path for badge endpoint to support .svg extension (diff)
downloadkaze-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.go100
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,