aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-20 16:34:01 -0800
committerFuwn <[email protected]>2026-01-20 16:34:01 -0800
commitd21e767ec97826beb878a501936bc03f1cb5d33b (patch)
tree7ee6f279222991ee42f0705a544ac6d68a40edf4
parentfeat: Add API access control (public/private/authenticated) (diff)
downloadkaze-d21e767ec97826beb878a501936bc03f1cb5d33b.tar.xz
kaze-d21e767ec97826beb878a501936bc03f1cb5d33b.zip
feat: Add API-based refresh mode for smoother updates
Add display.refresh_mode option: - 'page' (default): Full page refresh via meta refresh - 'api': Fetch /api/status and update DOM without reload Also add display.refresh_interval (default: 30s, min: 5s) API mode updates: status indicators, response times, uptimes, errors, overall status banner, and page title counts. History bars remain static until full page refresh.
-rw-r--r--config.example.yaml8
-rw-r--r--internal/config/config.go24
-rw-r--r--internal/server/server.go4
-rw-r--r--internal/server/templates/index.html173
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}}