aboutsummaryrefslogtreecommitdiff
path: root/internal/server
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 /internal/server
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.
Diffstat (limited to 'internal/server')
-rw-r--r--internal/server/server.go4
-rw-r--r--internal/server/templates/index.html173
2 files changed, 175 insertions, 2 deletions
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}}