diff options
| author | Fuwn <[email protected]> | 2026-01-20 16:54:15 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-20 16:54:15 -0800 |
| commit | cb8e8a750a275b9fae116cd01210e0ec50f62b55 (patch) | |
| tree | 5a8d985d706bd57cfaa3ee18c4bf8929f6895c71 | |
| parent | feat: Add new API endpoints (health, summary, uptime, incidents) (diff) | |
| download | kaze-cb8e8a750a275b9fae116cd01210e0ec50f62b55.tar.xz kaze-cb8e8a750a275b9fae116cd01210e0ec50f62b55.zip | |
feat: Add SVG status badge endpoint
GET /api/badge/{name}.svg - Shields.io style status badges (always public)
Options:
- ?label=Custom - Override the label text (default: monitor name)
- ?style=flat|plastic - Badge style (default: flat)
- ?type=status|uptime - Show status or uptime percentage (default: status)
Colors:
- Green (#22c55e) for up / >=99% uptime
- Yellow (#eab308) for degraded / >=95% uptime
- Red (#ef4444) for down / <95% uptime
- Gray (#9ca3af) for unknown
Example: 
| -rw-r--r-- | config.example.yaml | 3 | ||||
| -rw-r--r-- | internal/server/server.go | 131 |
2 files changed, 134 insertions, 0 deletions
diff --git a/config.example.yaml b/config.example.yaml index 706e3dc..91d17e9 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -379,6 +379,9 @@ incidents: # Supports ?period=1h|24h|7d|30d|90d (default: 24h) # GET /api/incidents - List incidents from config # Supports ?filter=all|active|resolved|scheduled (default: all) +# GET /api/badge/{name}.svg - SVG status badge (always public, shields.io style) +# Supports: ?label=custom&style=flat|plastic&type=status|uptime +# Example:  # # Authentication (when access is "authenticated"): # - Header: X-API-Key: your-secret-key diff --git a/internal/server/server.go b/internal/server/server.go index 86ee2a3..b835955 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -77,6 +77,9 @@ func New(cfg *config.Config, store *storage.Storage, sched *monitor.Scheduler, l // Health check - always public (for load balancers, monitoring) mux.HandleFunc("GET /api/health", s.handleAPIHealth) + // Badge endpoint - always public (for embedding in READMEs, docs) + mux.HandleFunc("GET /api/badge/{name}.svg", s.handleAPIBadge) + // Full page data endpoint - public if refresh_mode=api, otherwise follows api.access if cfg.Display.RefreshMode == "api" { mux.HandleFunc("GET /api/page", s.handleAPIPage) @@ -648,6 +651,134 @@ func (s *Server) handleAPIHealth(w http.ResponseWriter, r *http.Request) { s.jsonResponse(w, map[string]string{"status": "ok"}) } +// 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) + return + } + + // Get monitor stats + stats, err := s.storage.GetMonitorStats(r.Context(), name) + if err != nil { + s.logger.Error("failed to get monitor stats for badge", "monitor", name, "error", err) + // Return a gray "unknown" badge on error + s.serveBadge(w, r, name, "unknown", "#9ca3af") + return + } + + // Determine status and color + var status, color string + switch stats.CurrentStatus { + case "up": + status = "up" + color = "#22c55e" // green-500 + case "degraded": + status = "degraded" + color = "#eab308" // yellow-500 + case "down": + status = "down" + color = "#ef4444" // red-500 + default: + status = "unknown" + color = "#9ca3af" // gray-400 + } + + // Check for custom label + label := r.URL.Query().Get("label") + if label == "" { + label = name + } + + // Check for style (flat or plastic, default: flat) + style := r.URL.Query().Get("style") + if style != "plastic" { + style = "flat" + } + + // Check if uptime should be shown instead of status + if r.URL.Query().Get("type") == "uptime" { + status = fmt.Sprintf("%.1f%%", stats.UptimePercent) + if stats.UptimePercent >= 99.0 { + color = "#22c55e" + } else if stats.UptimePercent >= 95.0 { + color = "#eab308" + } else { + color = "#ef4444" + } + } + + s.serveBadge(w, r, label, status, color) +} + +// serveBadge generates and serves an SVG badge +func (s *Server) serveBadge(w http.ResponseWriter, r *http.Request, label, status, color string) { + style := r.URL.Query().Get("style") + if style != "plastic" { + style = "flat" + } + + // Calculate widths (approximate, 6px per character + padding) + labelWidth := len(label)*6 + 10 + statusWidth := len(status)*6 + 10 + totalWidth := labelWidth + statusWidth + + var svg string + if style == "plastic" { + // Plastic style with gradient + svg = fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20"> + <linearGradient id="b" x2="0" y2="100%%"> + <stop offset="0" stop-color="#fff" stop-opacity=".7"/> + <stop offset=".1" stop-color="#aaa" stop-opacity=".1"/> + <stop offset=".9" stop-color="#000" stop-opacity=".3"/> + <stop offset="1" stop-color="#000" stop-opacity=".5"/> + </linearGradient> + <clipPath id="a"> + <rect width="%d" height="20" rx="3" fill="#fff"/> + </clipPath> + <g clip-path="url(#a)"> + <rect width="%d" height="20" fill="#555"/> + <rect x="%d" width="%d" height="20" fill="%s"/> + <rect width="%d" height="20" fill="url(#b)"/> + </g> + <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"> + <text x="%d" y="15" fill="#010101" fill-opacity=".3">%s</text> + <text x="%d" y="14">%s</text> + <text x="%d" y="15" fill="#010101" fill-opacity=".3">%s</text> + <text x="%d" y="14">%s</text> + </g> +</svg>`, + totalWidth, totalWidth, labelWidth, labelWidth, statusWidth, color, totalWidth, + labelWidth/2, label, labelWidth/2, label, + labelWidth+statusWidth/2, status, labelWidth+statusWidth/2, status) + } else { + // Flat style (default) + svg = fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20"> + <clipPath id="a"> + <rect width="%d" height="20" rx="3" fill="#fff"/> + </clipPath> + <g clip-path="url(#a)"> + <rect width="%d" height="20" fill="#555"/> + <rect x="%d" width="%d" height="20" fill="%s"/> + </g> + <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"> + <text x="%d" y="15" fill="#010101" fill-opacity=".3">%s</text> + <text x="%d" y="14">%s</text> + <text x="%d" y="15" fill="#010101" fill-opacity=".3">%s</text> + <text x="%d" y="14">%s</text> + </g> +</svg>`, + totalWidth, totalWidth, labelWidth, labelWidth, statusWidth, color, + labelWidth/2, label, labelWidth/2, label, + labelWidth+statusWidth/2, status, labelWidth+statusWidth/2, status) + } + + w.Header().Set("Content-Type", "image/svg+xml") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Write([]byte(svg)) +} + // APISummaryResponse contains a lightweight status overview type APISummaryResponse struct { OverallStatus string `json:"overall_status"` |