//! Optional HTML dashboard served at `GET /`. use axum::extract::State as AxumState; use axum::response::{Html, IntoResponse}; use crate::api::ApiState; /// Handler for `GET /` — returns the dashboard HTML page. pub async fn dashboard_handler(AxumState(state): AxumState) -> impl IntoResponse { let instance_name = state.config.instance_name(); let version = env!("CARGO_PKG_VERSION"); let api_token = state.config.serve.api_token.as_deref().unwrap_or(""); let has_mesh = state.mesh.is_some(); Html(render_dashboard(&instance_name, version, api_token, has_mesh)) } /// Escape a string for safe embedding in a JS string literal (double-quoted). pub(crate) fn escape_js(s: &str) -> String { let mut out = String::with_capacity(s.len()); for ch in s.chars() { match ch { '\\' => out.push_str("\\\\"), '"' => out.push_str("\\\""), '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '<' => out.push_str("\\x3c"), '\0' => out.push_str("\\0"), _ => out.push(ch), } } out } fn render_dashboard(instance_name: &str, version: &str, api_token: &str, has_mesh: bool) -> String { let js = format!( "const API_TOKEN = \"{token}\";\nconst HAS_MESH = {has_mesh};\n{JS}", token = escape_js(api_token), has_mesh = has_mesh, JS = JS, ); format!( r##" PoM — {instance_name}
PoM {instance_name}
v{version} Refresh: 30s
"##, instance_name = instance_name, CSS = CSS, js = js, ) } const CSS: &str = r##" :root { --background: #ede8e1; --text: #3d3530; --surface-muted: #ddd7c5; --light-background: #f4f0eb; --border: #d0cbb8; --ok: #22c55e; --warn: #f59e0b; --error: #ef4444; --unknown: #9ca3af; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: 'Lato', sans-serif; background: var(--background); color: var(--text); line-height: 1.5; } .health-container { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; } .summary-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; padding: 0.75rem 1rem; background: var(--surface-muted); border-radius: 6px; } .summary-left, .summary-right { display: flex; align-items: center; gap: 0.75rem; } .summary-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--unknown); display: inline-block; flex-shrink: 0; } .summary-title { font-family: 'IBM Plex Mono', monospace; font-weight: 500; font-size: 1.1rem; } .summary-instance { font-family: 'IBM Plex Mono', monospace; font-size: 0.85rem; opacity: 0.6; } .summary-version { font-family: 'IBM Plex Mono', monospace; font-size: 0.8rem; opacity: 0.7; } .summary-refresh { font-family: 'IBM Plex Mono', monospace; font-size: 0.8rem; opacity: 0.7; } .summary-updated { font-family: 'IBM Plex Mono', monospace; font-size: 0.8rem; opacity: 0.7; } .health-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-bottom: 2rem; } .health-card { background: var(--surface-muted); border-radius: 6px; padding: 1.25rem; } .health-card.incident { border-left: 3px solid var(--error); } .health-card.stale { border-left: 3px solid var(--warn); } .card-header { display: flex; align-items: center; gap: 0.5rem; font-family: 'IBM Plex Mono', monospace; font-size: 0.9rem; font-weight: 500; margin-bottom: 0.75rem; } .status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; } .dot-ok { background: var(--ok); } .dot-warn { background: var(--warn); } .dot-error { background: var(--error); } .dot-unknown { background: var(--unknown); } dl { display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 0.75rem; font-size: 0.85rem; } dt { opacity: 0.7; } dd { text-align: right; font-family: 'IBM Plex Mono', monospace; } .section-title { font-family: 'IBM Plex Mono', monospace; font-size: 1rem; font-weight: 500; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; margin: 1.5rem 0 1rem; } details { margin-bottom: 0.75rem; } details summary { font-family: 'IBM Plex Mono', monospace; font-size: 0.85rem; cursor: pointer; padding: 0.5rem; background: var(--light-background); border-radius: 4px; list-style: none; } details summary::before { content: '\25b6 '; font-size: 0.7rem; } details[open] summary::before { content: '\25bc '; } details .detail-content { padding: 0.5rem; font-size: 0.85rem; } .incident-line { display: flex; justify-content: space-between; align-items: center; padding: 0.4rem 0; border-bottom: 1px solid var(--border); font-size: 0.85rem; } .incident-line:last-child { border-bottom: none; } .uptime-ok { color: var(--ok); } .uptime-warn { color: var(--warn); } .uptime-danger { color: var(--error); } .alert-bar { padding: 0.4rem 0.6rem; font-size: 0.8rem; border-radius: 4px; margin-top: 0.5rem; font-family: 'IBM Plex Mono', monospace; } .alert-bar.incident-bar { background: rgba(239,68,68,0.1); border-left: 3px solid var(--error); } .alert-bar.stale-bar { background: rgba(245,158,11,0.1); border-left: 3px solid var(--warn); } @media (max-width: 600px) { .summary-bar { flex-direction: column; gap: 0.5rem; } .health-grid { grid-template-columns: 1fr; } } "##; const JS: &str = r##" let countdown = 30; let timer = null; function headers() { const h = {}; if (API_TOKEN) h['Authorization'] = 'Bearer ' + API_TOKEN; return h; } function dotClass(status) { if (!status) return 'dot-unknown'; const s = status.toLowerCase(); if (s === 'operational') return 'dot-ok'; if (s === 'degraded') return 'dot-warn'; if (s === 'error' || s === 'unreachable') return 'dot-error'; return 'dot-unknown'; } function uptimeClass(pct) { if (pct >= 99) return 'uptime-ok'; if (pct >= 95) return 'uptime-warn'; return 'uptime-danger'; } function fmtPct(v) { return v != null ? v.toFixed(2) + '%' : 'N/A'; } function renderCard(name, t) { const status = t.latest ? t.latest.status : 'unknown'; const dc = dotClass(status); let cls = 'health-card'; if (t.current_incident) cls += ' incident'; else if (t.test_staleness && t.test_staleness.stale) cls += ' stale'; let html = '
'; html += '
' + esc(t.label) + '
'; html += '
'; html += '
Status
' + esc(status) + '
'; if (t.latest) html += '
Response
' + t.latest.response_time_ms + 'ms
'; if (t.uptime_24h != null) { const c24 = uptimeClass(t.uptime_24h); html += '
Uptime 24h
' + fmtPct(t.uptime_24h) + '
'; } if (t.uptime_7d != null) { const c7 = uptimeClass(t.uptime_7d); html += '
Uptime 7d
' + fmtPct(t.uptime_7d) + '
'; } html += '
'; if (t.latency_24h) { html += '
'; html += '
Avg
' + t.latency_24h.avg_ms.toFixed(0) + 'ms
'; html += '
P95
' + t.latency_24h.p95_ms + 'ms
'; html += '
Min/Max
' + t.latency_24h.min_ms + '/' + t.latency_24h.max_ms + 'ms
'; html += '
'; } if (t.tls) { html += '
'; html += '
TLS
' + (t.tls.valid ? 'valid' : 'invalid') + '
'; const dc2 = t.tls.days_remaining > 14 ? 'uptime-ok' : t.tls.days_remaining > 7 ? 'uptime-warn' : 'uptime-danger'; html += '
Expires
' + t.tls.days_remaining + 'd
'; html += '
'; } if (t.whois) { html += '
'; const wd = t.whois.days_remaining; const wc = wd > 30 ? 'uptime-ok' : wd > 14 ? 'uptime-warn' : 'uptime-danger'; html += '
Domain
' + (wd != null ? wd + 'd' : 'N/A') + '
'; if (t.whois.registrar) html += '
Registrar
' + esc(t.whois.registrar) + '
'; html += '
'; } if (t.dns_status && t.dns_status.length > 0) { const m = t.dns_status.filter(function(d) { return d.matches; }).length; html += '
DNS
' + m + '/' + t.dns_status.length + ' match
'; } if (t.route_status && t.route_status.length > 0) { const ok = t.route_status.filter(function(r) { return r.ok; }).length; html += '
Routes
' + ok + '/' + t.route_status.length + ' OK
'; } if (t.current_incident) { html += '
Incident: ' + esc(t.current_incident.from_status) + ' \u2192 ' + esc(t.current_incident.to_status) + '
'; } if (t.test_staleness && t.test_staleness.stale) { html += '
Tests stale: ' + esc(t.test_staleness.reason) + '
'; } if (t.test_duration_drift) { html += '
' + esc(t.test_duration_drift) + '
'; } html += '
'; return html; } function esc(s) { if (!s) return ''; var d = document.createElement('div'); d.textContent = s; return d.innerHTML; } function renderDetails(targets) { let html = ''; // Recent incidents let hasIncidents = false; let incHtml = ''; for (const name in targets) { const t = targets[name]; if (t.incidents && t.incidents.length > 0) { hasIncidents = true; for (let i = 0; i < t.incidents.length; i++) { const inc = t.incidents[i]; const dur = inc.duration_secs ? Math.round(inc.duration_secs / 60) + 'm' : 'ongoing'; incHtml += '
' + esc(t.label) + ': ' + esc(inc.from_status) + ' \u2192 ' + esc(inc.to_status) + '' + dur + '
'; } } } if (hasIncidents) { html += '
Recent Incidents
' + incHtml + '
'; } // DNS details let hasDns = false; let dnsHtml = ''; for (const name in targets) { const t = targets[name]; if (t.dns_status && t.dns_status.length > 0) { hasDns = true; for (let i = 0; i < t.dns_status.length; i++) { const d = t.dns_status[i]; const mc = d.matches ? 'dot-ok' : 'dot-error'; dnsHtml += '
' + esc(d.name) + ' ' + esc(d.record_type) + '' + esc(d.actual.join(', ')) + '
'; } } } if (hasDns) { html += '
DNS Details
' + dnsHtml + '
'; } // Route details let hasRoutes = false; let routeHtml = ''; for (const name in targets) { const t = targets[name]; if (t.route_status && t.route_status.length > 0) { hasRoutes = true; for (let i = 0; i < t.route_status.length; i++) { const r = t.route_status[i]; const rc = r.ok ? 'dot-ok' : 'dot-error'; routeHtml += '
' + esc(t.label) + ' ' + esc(r.path) + '' + r.status_code + ' (' + r.response_time_ms + 'ms)
'; } } } if (hasRoutes) { html += '
Route Details
' + routeHtml + '
'; } return html; } function renderMesh(data) { if (!data || !data.instances) return ''; let html = '
Peer Mesh
'; html += '
'; for (const name in data.instances) { const inst = data.instances[name]; html += '
' + esc(name) + '
'; if (inst.instance) { html += '
Version
' + esc(inst.instance.version) + '
'; } if (inst.targets) { html += '
'; for (const tn in inst.targets) { const tt = inst.targets[tn]; const dc = dotClass(tt.status); html += '
' + esc(tt.label || tn) + '
'; } html += '
'; } html += '
'; } html += '
'; return html; } async function refresh() { try { const resp = await fetch('/api/status', { headers: headers() }); if (!resp.ok) return; const data = await resp.json(); const targets = data.targets || {}; // Global dot let worst = 'operational'; for (const name in targets) { const s = targets[name].latest ? targets[name].latest.status : 'unknown'; if (s === 'error' || s === 'unreachable') worst = 'error'; else if (s === 'degraded' && worst !== 'error') worst = 'degraded'; else if (s === 'unknown' && worst === 'operational') worst = 'unknown'; } document.getElementById('global-dot').className = 'summary-dot ' + dotClass(worst).replace('dot-', 'dot-'); // Sort target names const names = Object.keys(targets).sort(); let gridHtml = ''; for (let i = 0; i < names.length; i++) { gridHtml += renderCard(names[i], targets[names[i]]); } document.getElementById('target-grid').innerHTML = gridHtml; // Details document.getElementById('details-section').innerHTML = renderDetails(targets); // Update timestamp document.getElementById('last-updated').textContent = 'Updated ' + new Date().toLocaleTimeString(); // Mesh if (HAS_MESH) { try { const mr = await fetch('/api/mesh', { headers: headers() }); if (mr.ok) { const md = await mr.json(); document.getElementById('mesh-section').innerHTML = renderMesh(md); } } catch(e) {} } } catch(e) { console.error('Dashboard refresh failed:', e); } } function tick() { countdown--; if (countdown <= 0) { countdown = 30; refresh(); } document.getElementById('refresh-timer').textContent = 'Refresh: ' + countdown + 's'; } refresh(); timer = setInterval(tick, 1000); "##; #[cfg(test)] mod tests { use super::*; #[test] fn escape_js_backslash() { assert_eq!(escape_js(r"a\b"), r"a\\b"); } #[test] fn escape_js_double_quote() { assert_eq!(escape_js(r#"a"b"#), r#"a\"b"#); } #[test] fn escape_js_both() { assert_eq!(escape_js(r#"a\"b"#), r#"a\\\"b"#); } #[test] fn escape_js_clean_string() { assert_eq!(escape_js("hello"), "hello"); } #[test] fn escape_js_empty() { assert_eq!(escape_js(""), ""); } #[test] fn escape_js_newline() { assert_eq!(escape_js("a\nb"), "a\\nb"); } #[test] fn escape_js_carriage_return() { assert_eq!(escape_js("a\rb"), "a\\rb"); } #[test] fn escape_js_script_close_tag() { assert_eq!(escape_js(""), "\\x3c/script>"); } #[test] fn escape_js_null() { assert_eq!(escape_js("a\0b"), "a\\0b"); } }