aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-20 16:49:50 -0800
committerFuwn <[email protected]>2026-01-20 16:49:50 -0800
commit31cc0a33a378dbc8cb0e3c70b40d6570f3bed2e4 (patch)
tree6de6f3354af8095be83740b3b24ca1490892a999
parentperf: Optimize API refresh to single /api/page request (diff)
downloadkaze-31cc0a33a378dbc8cb0e3c70b40d6570f3bed2e4.tar.xz
kaze-31cc0a33a378dbc8cb0e3c70b40d6570f3bed2e4.zip
feat: Add new API endpoints (health, summary, uptime, incidents)
New endpoints: - GET /api/health - Simple health check, always public (for load balancers) - GET /api/summary - Lightweight status overview (counts + overall status) - GET /api/uptime/{name}?period=1h|24h|7d|30d|90d - Historical uptime stats - GET /api/incidents?filter=all|active|resolved|scheduled - List incidents All new endpoints (except /api/health) follow api.access rules.
-rw-r--r--config.example.yaml7
-rw-r--r--internal/server/server.go183
-rw-r--r--internal/storage/sqlite.go37
3 files changed, 227 insertions, 0 deletions
diff --git a/config.example.yaml b/config.example.yaml
index 624e1d0..706e3dc 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -367,11 +367,18 @@ incidents:
# keys: []string - List of valid API keys (for "authenticated" mode)
#
# Endpoints:
+# GET /api/health - Simple health check (always public)
+# Returns: {"status": "ok"}
# GET /api/status - All monitors status JSON
# GET /api/monitor/{name} - Single monitor status JSON
# GET /api/history/{name} - Monitor history (supports ?mode=ping|minute|hour|day&count=N)
# GET /api/page - Full page data (monitors + history + status) in one request
# Note: Always public when refresh_mode is "api"
+# GET /api/summary - Lightweight status overview (counts + overall status, no history)
+# GET /api/uptime/{name} - Historical uptime stats for a monitor
+# Supports ?period=1h|24h|7d|30d|90d (default: 24h)
+# GET /api/incidents - List incidents from config
+# Supports ?filter=all|active|resolved|scheduled (default: all)
#
# 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 d7dacaa..86ee2a3 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -70,6 +70,12 @@ func New(cfg *config.Config, store *storage.Storage, sched *monitor.Scheduler, l
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/summary", s.withAPIAuth(s.handleAPISummary))
+ mux.HandleFunc("GET /api/uptime/{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)
// Full page data endpoint - public if refresh_mode=api, otherwise follows api.access
if cfg.Display.RefreshMode == "api" {
@@ -637,6 +643,183 @@ func (s *Server) handleAPIPage(w http.ResponseWriter, r *http.Request) {
s.jsonResponse(w, response)
}
+// 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"})
+}
+
+// APISummaryResponse contains a lightweight status overview
+type APISummaryResponse struct {
+ OverallStatus string `json:"overall_status"`
+ Counts StatusCounts `json:"counts"`
+ LastUpdated time.Time `json:"last_updated"`
+}
+
+// handleAPISummary returns a lightweight status summary (no history data)
+func (s *Server) handleAPISummary(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ stats, err := s.storage.GetAllMonitorStats(ctx)
+ if err != nil {
+ s.jsonError(w, "Failed to get stats", http.StatusInternalServerError)
+ return
+ }
+
+ response := APISummaryResponse{
+ LastUpdated: time.Now(),
+ }
+
+ overallUp := true
+ hasDegraded := false
+
+ for _, group := range s.config.Groups {
+ for _, monCfg := range group.Monitors {
+ stat, ok := stats[monCfg.Name]
+ if !ok {
+ continue
+ }
+
+ response.Counts.Total++
+ switch stat.CurrentStatus {
+ case "down":
+ overallUp = false
+ response.Counts.Down++
+ case "degraded":
+ hasDegraded = true
+ response.Counts.Degraded++
+ case "up":
+ response.Counts.Up++
+ }
+ }
+ }
+
+ if !overallUp {
+ response.OverallStatus = "Major Outage"
+ } else if hasDegraded {
+ response.OverallStatus = "Partial Outage"
+ } else {
+ response.OverallStatus = "All Systems Operational"
+ }
+
+ s.jsonResponse(w, response)
+}
+
+// APIUptimeResponse contains historical uptime statistics
+type APIUptimeResponse struct {
+ Monitor string `json:"monitor"`
+ Period string `json:"period"`
+ UptimePercent float64 `json:"uptime_percent"`
+ TotalChecks int64 `json:"total_checks"`
+ SuccessChecks int64 `json:"success_checks"`
+ FailedChecks int64 `json:"failed_checks"`
+}
+
+// handleAPIUptime returns historical uptime for a specific period
+func (s *Server) handleAPIUptime(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("name")
+ if name == "" {
+ s.jsonError(w, "Monitor name required", http.StatusBadRequest)
+ return
+ }
+
+ // Parse period (default: 24h, options: 1h, 24h, 7d, 30d, 90d)
+ period := r.URL.Query().Get("period")
+ if period == "" {
+ period = "24h"
+ }
+
+ var duration time.Duration
+ switch period {
+ case "1h":
+ duration = time.Hour
+ case "24h":
+ duration = 24 * time.Hour
+ case "7d":
+ duration = 7 * 24 * time.Hour
+ case "30d":
+ duration = 30 * 24 * time.Hour
+ case "90d":
+ duration = 90 * 24 * time.Hour
+ default:
+ s.jsonError(w, "Invalid period (use: 1h, 24h, 7d, 30d, 90d)", http.StatusBadRequest)
+ return
+ }
+
+ stats, err := s.storage.GetUptimeStats(r.Context(), name, duration)
+ if err != nil {
+ s.jsonError(w, "Failed to get uptime stats", http.StatusInternalServerError)
+ return
+ }
+
+ s.jsonResponse(w, APIUptimeResponse{
+ Monitor: name,
+ Period: period,
+ UptimePercent: stats.UptimePercent,
+ TotalChecks: stats.TotalChecks,
+ SuccessChecks: stats.SuccessChecks,
+ FailedChecks: stats.FailedChecks,
+ })
+}
+
+// APIIncidentResponse contains incident data for the API
+type APIIncidentResponse struct {
+ Title string `json:"title"`
+ Status string `json:"status"`
+ Message string `json:"message"`
+ IsActive bool `json:"is_active"`
+ CreatedAt *time.Time `json:"created_at,omitempty"`
+ ResolvedAt *time.Time `json:"resolved_at,omitempty"`
+ ScheduledStart *time.Time `json:"scheduled_start,omitempty"`
+ ScheduledEnd *time.Time `json:"scheduled_end,omitempty"`
+ Affected []string `json:"affected_monitors,omitempty"`
+}
+
+// handleAPIIncidents returns active and recent incidents
+func (s *Server) handleAPIIncidents(w http.ResponseWriter, r *http.Request) {
+ // Filter: all, active, resolved, scheduled (default: all)
+ filter := r.URL.Query().Get("filter")
+
+ var incidents []APIIncidentResponse
+
+ for _, inc := range s.config.Incidents {
+ isActive := inc.Status != "resolved"
+ isScheduled := inc.Status == "scheduled"
+
+ // Apply filter
+ switch filter {
+ case "active":
+ if !isActive || isScheduled {
+ continue
+ }
+ case "resolved":
+ if isActive {
+ continue
+ }
+ case "scheduled":
+ if !isScheduled {
+ continue
+ }
+ }
+
+ incidents = append(incidents, APIIncidentResponse{
+ Title: inc.Title,
+ Status: inc.Status,
+ Message: inc.Message,
+ IsActive: isActive,
+ CreatedAt: inc.CreatedAt,
+ ResolvedAt: inc.ResolvedAt,
+ ScheduledStart: inc.ScheduledStart,
+ ScheduledEnd: inc.ScheduledEnd,
+ Affected: inc.AffectedMonitors,
+ })
+ }
+
+ s.jsonResponse(w, map[string]interface{}{
+ "incidents": incidents,
+ "count": len(incidents),
+ })
+}
+
// jsonResponse writes a JSON response
func (s *Server) jsonResponse(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go
index e441fe7..3d89cb1 100644
--- a/internal/storage/sqlite.go
+++ b/internal/storage/sqlite.go
@@ -496,6 +496,43 @@ func (s *Storage) GetUptimeHistory(ctx context.Context, monitorName string, days
return result, nil
}
+// UptimeStats contains uptime statistics for a period
+type UptimeStats struct {
+ UptimePercent float64
+ TotalChecks int64
+ SuccessChecks int64
+ FailedChecks int64
+}
+
+// GetUptimeStats returns uptime statistics for a monitor over a given duration
+func (s *Storage) GetUptimeStats(ctx context.Context, monitorName string, duration time.Duration) (*UptimeStats, error) {
+ cutoff := time.Now().Add(-duration)
+
+ var totalChecks, successChecks int64
+ err := s.db.QueryRowContext(ctx, `
+ SELECT
+ COUNT(*) as total,
+ SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) as success
+ FROM check_results
+ WHERE monitor_name = ? AND timestamp >= ?
+ `, monitorName, cutoff).Scan(&totalChecks, &successChecks)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query uptime stats: %w", err)
+ }
+
+ stats := &UptimeStats{
+ TotalChecks: totalChecks,
+ SuccessChecks: successChecks,
+ FailedChecks: totalChecks - successChecks,
+ }
+
+ if totalChecks > 0 {
+ stats.UptimePercent = float64(successChecks) / float64(totalChecks) * 100
+ }
+
+ return stats, nil
+}
+
// PingResult represents a single ping for the history display
type PingResult struct {
Status string // up, down, degraded