From b56941ebd96a231d22a1316daa6b7e112478730e Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 20 Jan 2026 16:44:43 -0800 Subject: perf: Optimize API refresh to single /api/page request Replace N+1 API calls (1 status + N history) with a single /api/page endpoint that returns all monitor data including history ticks. For 20 monitors: 21 requests -> 1 request per refresh interval. The /api/page endpoint is automatically public when refresh_mode=api, regardless of api.access setting, to ensure the page can always refresh. --- config.example.yaml | 2 + internal/server/server.go | 101 +++++++++++++++++++++++++++++++++++ internal/server/templates/index.html | 93 +++++++++----------------------- 3 files changed, 127 insertions(+), 69 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 89da0d7..624e1d0 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -370,6 +370,8 @@ incidents: # 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" # # 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 a5ded3d..d7dacaa 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -71,6 +71,13 @@ func New(cfg *config.Config, store *storage.Storage, sched *monitor.Scheduler, l mux.HandleFunc("GET /api/monitor/{name}", s.withAPIAuth(s.handleAPIMonitor)) mux.HandleFunc("GET /api/history/{name}", s.withAPIAuth(s.handleAPIHistory)) + // 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) + } else { + mux.HandleFunc("GET /api/page", s.withAPIAuth(s.handleAPIPage)) + } + // Create HTTP server s.server = &http.Server{ Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port), @@ -536,6 +543,100 @@ func (s *Server) handleAPIHistory(w http.ResponseWriter, r *http.Request) { }) } +// APIPageResponse contains all data needed to render/update the status page +type APIPageResponse struct { + Monitors map[string]APIMonitorData `json:"monitors"` + OverallStatus string `json:"overall_status"` + Counts StatusCounts `json:"counts"` + LastUpdated time.Time `json:"last_updated"` +} + +// APIMonitorData contains monitor data for the API page response +type APIMonitorData struct { + Status string `json:"status"` + ResponseTime int64 `json:"response_time"` + Uptime float64 `json:"uptime"` + LastError string `json:"last_error,omitempty"` + SSLDaysLeft int `json:"ssl_days_left,omitempty"` + Ticks []*storage.TickData `json:"ticks"` +} + +// handleAPIPage returns all data needed to update the status page in a single request +func (s *Server) handleAPIPage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Get all monitor stats + stats, err := s.storage.GetAllMonitorStats(ctx) + if err != nil { + s.jsonError(w, "Failed to get stats", http.StatusInternalServerError) + return + } + + response := APIPageResponse{ + Monitors: make(map[string]APIMonitorData), + LastUpdated: time.Now(), + } + + overallUp := true + hasDegraded := false + + // Build monitor data with history + for _, group := range s.config.Groups { + for _, monCfg := range group.Monitors { + stat, ok := stats[monCfg.Name] + if !ok { + continue + } + + // Get history ticks + ticks, err := s.storage.GetAggregatedHistory( + ctx, + monCfg.Name, + 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) + ticks = nil + } + + response.Monitors[monCfg.Name] = APIMonitorData{ + Status: stat.CurrentStatus, + ResponseTime: stat.LastResponseTime, + Uptime: stat.UptimePercent, + LastError: stat.LastError, + SSLDaysLeft: stat.SSLDaysLeft, + Ticks: ticks, + } + + // Track status counts + 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++ + } + } + } + + // Determine overall status + if !overallUp { + response.OverallStatus = "Major Outage" + } else if hasDegraded { + response.OverallStatus = "Partial Outage" + } else { + response.OverallStatus = "All Systems Operational" + } + + s.jsonResponse(w, response) +} + // 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/server/templates/index.html b/internal/server/templates/index.html index b86d043..6471e53 100644 --- a/internal/server/templates/index.html +++ b/internal/server/templates/index.html @@ -452,33 +452,6 @@ return 'Error'; } - function getOverallStatus(stats) { - let hasDown = false; - let hasDegraded = false; - for (const name in stats) { - if (stats[name].CurrentStatus === 'down') hasDown = true; - if (stats[name].CurrentStatus === 'degraded') hasDegraded = true; - } - if (hasDown) return 'Major Outage'; - if (hasDegraded) return 'Partial Outage'; - return 'All Systems Operational'; - } - - function updateStatusCounts(stats) { - let up = 0, down = 0, degraded = 0; - for (const name in stats) { - switch(stats[name].CurrentStatus) { - case 'up': up++; break; - case 'down': down++; break; - case 'degraded': degraded++; break; - } - } - let title = '{{.Site.Name}} [↑' + up; - if (down > 0) title += '/' + down + '↓'; - title += ']'; - document.title = title; - } - function formatTickHeader(timestamp, mode) { const date = new Date(timestamp); const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; @@ -556,41 +529,21 @@ async function refresh() { try { - // Fetch status for all monitors - const statusResponse = await fetch('/api/status'); - if (!statusResponse.ok) return; - const stats = await statusResponse.json(); - - // Get all monitor elements - const monitorEls = document.querySelectorAll('[data-monitor]'); - - // Fetch history for each monitor in parallel - const historyPromises = []; - const monitorNames = []; - monitorEls.forEach(el => { - const name = el.getAttribute('data-monitor'); - monitorNames.push(name); - historyPromises.push( - fetch('/api/history/' + encodeURIComponent(name)) - .then(r => r.ok ? r.json() : null) - .catch(() => null) - ); - }); - - const historyResults = await Promise.all(historyPromises); + // Fetch all page data in a single request + const response = await fetch('/api/page'); + if (!response.ok) return; + const data = await response.json(); // Update each monitor - monitorEls.forEach((el, index) => { - const name = monitorNames[index]; - const stat = stats[name]; - const history = historyResults[index]; - - if (!stat) return; + document.querySelectorAll('[data-monitor]').forEach(el => { + const name = el.getAttribute('data-monitor'); + const monitor = data.monitors[name]; + if (!monitor) return; // Update status indicator const statusDot = el.querySelector('.rounded-full'); if (statusDot) { - statusDot.className = 'w-2 h-2 rounded-full flex-shrink-0 ' + getStatusColor(stat.CurrentStatus); + statusDot.className = 'w-2 h-2 rounded-full flex-shrink-0 ' + getStatusColor(monitor.status); } // Update response time @@ -599,18 +552,18 @@ const spans = infoDiv.querySelectorAll(':scope > span'); const hidePing = el.hasAttribute('data-hide-ping'); if (spans.length > 0 && !hidePing) { - spans[0].textContent = formatDuration(stat.LastResponseTime); + spans[0].textContent = formatDuration(monitor.response_time); } // Update error let errorSpan = infoDiv.querySelector('.text-red-600'); - if (stat.LastError) { + if (monitor.last_error) { if (!errorSpan) { errorSpan = document.createElement('span'); errorSpan.className = 'text-red-600 dark:text-red-400'; infoDiv.appendChild(errorSpan); } - errorSpan.textContent = simplifyError(stat.LastError); + errorSpan.textContent = simplifyError(monitor.last_error); } else if (errorSpan) { errorSpan.remove(); } @@ -619,31 +572,30 @@ // Update uptime const uptimeEl = el.querySelector('.text-sm.font-medium'); if (uptimeEl) { - uptimeEl.textContent = formatUptime(stat.UptimePercent); - uptimeEl.className = 'text-sm font-medium ' + getUptimeColor(stat.UptimePercent); + uptimeEl.textContent = formatUptime(monitor.uptime); + uptimeEl.className = 'text-sm font-medium ' + getUptimeColor(monitor.uptime); } // Update history bar - if (history && history.ticks) { + if (monitor.ticks) { const hidePing = el.hasAttribute('data-hide-ping'); const disableTooltips = el.hasAttribute('data-disable-tooltips'); - updateHistoryBar(el, history.ticks, hidePing, disableTooltips); + updateHistoryBar(el, monitor.ticks, hidePing, disableTooltips); } }); // Update overall status banner - const overallStatus = getOverallStatus(stats); const banner = document.querySelector('.mb-8.p-4.rounded-lg.border'); if (banner) { const statusText = banner.querySelector('.font-medium'); - if (statusText) statusText.textContent = overallStatus; + if (statusText) statusText.textContent = data.overall_status; const dot = banner.querySelector('.rounded-full'); if (dot) { - if (overallStatus === 'All Systems Operational') { + if (data.overall_status === 'All Systems Operational') { dot.className = 'w-3 h-3 rounded-full bg-emerald-500 animate-pulse'; banner.className = 'mb-8 p-4 rounded-lg border bg-emerald-50 dark:bg-emerald-950/30 border-emerald-200 dark:border-emerald-900'; - } else if (overallStatus === 'Partial Outage') { + } else if (data.overall_status === 'Partial Outage') { dot.className = 'w-3 h-3 rounded-full bg-yellow-500 animate-pulse'; banner.className = 'mb-8 p-4 rounded-lg border bg-yellow-50 dark:bg-yellow-950/30 border-yellow-200 dark:border-yellow-900'; } else { @@ -654,12 +606,15 @@ } // Update status counts in title - updateStatusCounts(stats); + let title = '{{.Site.Name}} [↑' + data.counts.Up; + if (data.counts.Down > 0) title += '/' + data.counts.Down + '↓'; + title += ']'; + document.title = title; // Update last updated time const timeEl = document.querySelector('[data-timestamp][data-format="datetime"]'); if (timeEl) { - const now = new Date(); + const now = new Date(data.last_updated); {{if .UseBrowserTimezone}} const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; timeEl.textContent = months[now.getMonth()] + ' ' + now.getDate() + ', ' + now.getFullYear() + ' ' + -- cgit v1.2.3