diff options
| -rw-r--r-- | config.example.yaml | 8 | ||||
| -rw-r--r-- | internal/config/config.go | 24 | ||||
| -rw-r--r-- | internal/server/server.go | 4 | ||||
| -rw-r--r-- | internal/server/templates/index.html | 173 |
4 files changed, 207 insertions, 2 deletions
diff --git a/config.example.yaml b/config.example.yaml index 228aecd..89da0d7 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -67,6 +67,14 @@ display: # UI scale factor (0.5 to 2.0, default: 1.0) # Adjusts the overall size of text and spacing # scale: 1.0 + + # Refresh mode: + # "page" - Full page refresh (default) + # "api" - Fetch updates via API without page reload (smoother UX) + # refresh_mode: page + + # Refresh interval in seconds (default: 30, minimum: 5) + # refresh_interval: 30 # Monitor groups groups: diff --git a/internal/config/config.go b/internal/config/config.go index 5f05b96..0c8b430 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,6 +44,12 @@ type DisplayConfig struct { Timezone string `yaml:"timezone"` // Scale adjusts the overall UI scale (default: 1.0, range: 0.5-2.0) Scale float64 `yaml:"scale"` + // RefreshMode controls how the page updates: + // "page" - Full page refresh via meta refresh (default) + // "api" - Fetch updates via API without page reload + RefreshMode string `yaml:"refresh_mode"` + // RefreshInterval is how often to refresh in seconds (default: 30) + RefreshInterval int `yaml:"refresh_interval"` } // SiteConfig contains site metadata @@ -252,6 +258,12 @@ func (c *Config) applyDefaults() { } else if c.Display.Scale > 2.0 { c.Display.Scale = 2.0 } + if c.Display.RefreshMode == "" { + c.Display.RefreshMode = "page" + } + if c.Display.RefreshInterval == 0 { + c.Display.RefreshInterval = 30 + } // Apply API defaults if c.API.Access == "" { @@ -485,6 +497,18 @@ func (c *Config) validate() error { return fmt.Errorf("api.access is 'authenticated' but no api.keys provided") } + // Validate refresh mode + switch c.Display.RefreshMode { + case "page", "api": + // Valid modes + default: + return fmt.Errorf("invalid display.refresh_mode %q (must be page or api)", c.Display.RefreshMode) + } + + if c.Display.RefreshInterval < 5 { + return fmt.Errorf("display.refresh_interval must be at least 5 seconds, got %d", c.Display.RefreshInterval) + } + return nil } diff --git a/internal/server/server.go b/internal/server/server.go index b0192a4..a5ded3d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -178,6 +178,8 @@ type PageData struct { ThemeCSS template.CSS // OpenCode theme CSS (safe CSS) CustomHead template.HTML // Custom HTML for <head> (trusted) Scale float64 // UI scale factor (0.5-2.0) + RefreshMode string // page or api + RefreshInterval int // seconds } // StatusCounts holds monitor status counts for display @@ -277,6 +279,8 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { ThemeCSS: themeCSS, CustomHead: template.HTML(s.config.Site.CustomHead), Scale: s.config.Display.Scale, + RefreshMode: s.config.Display.RefreshMode, + RefreshInterval: s.config.Display.RefreshInterval, } overallUp := true diff --git a/internal/server/templates/index.html b/internal/server/templates/index.html index be80544..e84945e 100644 --- a/internal/server/templates/index.html +++ b/internal/server/templates/index.html @@ -389,8 +389,177 @@ }, { passive: true }); })(); - // Auto-refresh every 30 seconds - setTimeout(() => location.reload(), 30000); + // Auto-refresh + {{if eq .RefreshMode "api"}} + // API-based refresh (no page reload) + (function() { + const refreshInterval = {{.RefreshInterval}} * 1000; + + function getStatusColor(status) { + switch(status) { + case 'up': return 'bg-emerald-500'; + case 'degraded': return 'bg-yellow-500'; + case 'down': return 'bg-red-500'; + default: return 'bg-neutral-400'; + } + } + + function getUptimeColor(uptime) { + if (uptime >= 99.0) return 'text-emerald-600 dark:text-emerald-400'; + if (uptime >= 95.0) return 'text-yellow-600 dark:text-yellow-400'; + return 'text-red-600 dark:text-red-400'; + } + + function formatDuration(ms) { + if (ms < 1000) return ms + 'ms'; + return (ms / 1000).toFixed(2) + 's'; + } + + function formatUptime(pct) { + if (pct >= 99.99) return pct.toFixed(2) + '%'; + if (pct >= 99.9) return pct.toFixed(2) + '%'; + return pct.toFixed(1) + '%'; + } + + function simplifyError(err) { + if (!err) return ''; + const lower = err.toLowerCase(); + if (lower.includes('timeout') || lower.includes('deadline')) return 'Timeout'; + if (lower.includes('connection refused')) return 'Connection refused'; + if (lower.includes('no such host') || lower.includes('dns')) return 'DNS error'; + if (lower.includes('certificate') || lower.includes('x509') || lower.includes('tls')) return 'SSL/TLS error'; + if (lower.includes('eof') || lower.includes('reset by peer')) return 'Connection reset'; + 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; + } + } + // Update page title + let title = '{{.Site.Name}} [↑' + up; + if (down > 0) title += '/' + down + '↓'; + title += ']'; + document.title = title; + } + + async function refresh() { + try { + const response = await fetch('/api/status'); + if (!response.ok) return; + + const stats = await response.json(); + + // Update each monitor + document.querySelectorAll('[data-monitor]').forEach(el => { + const name = el.getAttribute('data-monitor'); + const stat = stats[name]; + if (!stat) 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); + } + + // Update response time + const infoSpans = el.querySelectorAll('.text-xs.text-neutral-500 > span'); + if (infoSpans.length > 0 && !el.querySelector('[data-hide-ping]')) { + infoSpans[0].textContent = formatDuration(stat.LastResponseTime); + } + + // 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); + } + + // Update error (find or create) + const infoDiv = el.querySelector('.text-xs.text-neutral-500'); + if (infoDiv) { + let errorSpan = infoDiv.querySelector('.text-red-600, .dark\\:text-red-400'); + if (stat.LastError) { + if (!errorSpan) { + errorSpan = document.createElement('span'); + errorSpan.className = 'text-red-600 dark:text-red-400'; + infoDiv.appendChild(errorSpan); + } + errorSpan.textContent = simplifyError(stat.LastError); + } else if (errorSpan) { + errorSpan.remove(); + } + } + }); + + // 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; + + const dot = banner.querySelector('.rounded-full'); + if (dot) { + if (overallStatus === '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') { + 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 { + dot.className = 'w-3 h-3 rounded-full bg-red-500 animate-pulse'; + banner.className = 'mb-8 p-4 rounded-lg border bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-900'; + } + } + } + + // Update status counts in title + updateStatusCounts(stats); + + // Update last updated time + const timeEl = document.querySelector('[data-timestamp][data-format="datetime"]'); + if (timeEl) { + const now = new Date(); + {{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() + ' ' + + String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0'); + {{else}} + // Server timezone - just update the time portion + timeEl.textContent = timeEl.textContent.replace(/\d{2}:\d{2}$/, + String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0')); + {{end}} + } + + } catch (e) { + console.error('Failed to refresh status:', e); + } + } + + setInterval(refresh, refreshInterval); + })(); + {{else}} + // Page-based refresh (full reload) + setTimeout(() => location.reload(), {{.RefreshInterval}} * 1000); + {{end}} // Client-side timezone conversion {{if .UseBrowserTimezone}} |