Skip to main content

max / makenotwork

14.9 KB · 408 lines History Blame Raw
1 //! Optional HTML dashboard served at `GET /`.
2
3 use axum::extract::State as AxumState;
4 use axum::http::header::SET_COOKIE;
5 use axum::response::{Html, IntoResponse};
6
7 use crate::api::ApiState;
8
9 /// Handler for `GET /`: returns the dashboard HTML page.
10 ///
11 /// The long-lived `api_token` is never baked into the served JS, where anyone
12 /// who can reach `/` would read it and replay it against `/api/*`. The browser
13 /// gets an ephemeral, per-process dashboard secret as an httpOnly,
14 /// SameSite=Strict cookie instead. The page JS holds no token and calls the API
15 /// with `credentials: 'same-origin'`; `require_bearer_token` accepts the cookie.
16 pub async fn dashboard_handler(AxumState(state): AxumState<ApiState>) -> impl IntoResponse {
17 let instance_name = state.config.instance_name();
18 let version = env!("CARGO_PKG_VERSION");
19 let has_mesh = state.mesh.is_some();
20 let html = Html(render_dashboard(&instance_name, version, has_mesh));
21
22 match state.dashboard_token.as_deref() {
23 Some(token) => {
24 // httpOnly (unreadable by JS/XSS), SameSite=Strict (no cross-site
25 // send), Path=/ so it accompanies the /api/* fetches.
26 let cookie = format!("pom_dash={token}; HttpOnly; SameSite=Strict; Path=/");
27 ([(SET_COOKIE, cookie)], html).into_response()
28 }
29 None => html.into_response(),
30 }
31 }
32
33 fn render_dashboard(instance_name: &str, version: &str, has_mesh: bool) -> String {
34 let js = format!("const HAS_MESH = {has_mesh};\n{JS}");
35 format!(
36 r#"<!DOCTYPE html>
37 <html lang="en">
38 <head>
39 <meta charset="utf-8">
40 <meta name="viewport" content="width=device-width, initial-scale=1">
41 <title>PoM: {instance_name}</title>
42 <link rel="preconnect" href="https://fonts.googleapis.com">
43 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
44 <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Lato:wght@400;700&display=swap" rel="stylesheet">
45 <style>{CSS}</style>
46 </head>
47 <body>
48 <div class="health-container">
49 <div class="summary-bar">
50 <div class="summary-left">
51 <span class="summary-dot" id="global-dot"></span>
52 <span class="summary-title">PoM</span>
53 <span class="summary-instance">{instance_name}</span>
54 </div>
55 <div class="summary-right">
56 <span class="summary-version">v{version}</span>
57 <span class="summary-refresh" id="refresh-timer">Refresh: 30s</span>
58 <span class="summary-updated" id="last-updated"></span>
59 </div>
60 </div>
61 <div class="health-grid" id="target-grid"></div>
62 <div id="mesh-section"></div>
63 <div id="details-section"></div>
64 </div>
65 <script>
66 {js}
67 </script>
68 </body>
69 </html>"#,
70 )
71 }
72
73 const CSS: &str = r"
74 :root {
75 --background: #ede8e1;
76 --text: #3d3530;
77 --surface-muted: #ddd7c5;
78 --light-background: #f4f0eb;
79 --border: #d0cbb8;
80 --ok: #22c55e;
81 --warn: #f59e0b;
82 --error: #ef4444;
83 --unknown: #9ca3af;
84 }
85 *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
86 body {
87 font-family: 'Lato', sans-serif;
88 background: var(--background);
89 color: var(--text);
90 line-height: 1.5;
91 }
92 .health-container { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; }
93 .summary-bar {
94 display: flex; justify-content: space-between; align-items: center;
95 margin-bottom: 1.5rem; padding: 0.75rem 1rem;
96 background: var(--surface-muted); border-radius: 6px;
97 }
98 .summary-left, .summary-right { display: flex; align-items: center; gap: 0.75rem; }
99 .summary-dot {
100 width: 10px; height: 10px; border-radius: 50%;
101 background: var(--unknown); display: inline-block; flex-shrink: 0;
102 }
103 .summary-title { font-family: 'IBM Plex Mono', monospace; font-weight: 500; font-size: 1.1rem; }
104 .summary-instance { font-family: 'IBM Plex Mono', monospace; font-size: 0.85rem; opacity: 0.6; }
105 .summary-version { font-family: 'IBM Plex Mono', monospace; font-size: 0.8rem; opacity: 0.7; }
106 .summary-refresh { font-family: 'IBM Plex Mono', monospace; font-size: 0.8rem; opacity: 0.7; }
107 .summary-updated { font-family: 'IBM Plex Mono', monospace; font-size: 0.8rem; opacity: 0.7; }
108 .health-grid {
109 display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
110 gap: 1.5rem; margin-bottom: 2rem;
111 }
112 .health-card {
113 background: var(--surface-muted); border-radius: 6px; padding: 1.25rem;
114 }
115 .health-card.incident { border-left: 3px solid var(--error); }
116 .health-card.stale { border-left: 3px solid var(--warn); }
117 .card-header {
118 display: flex; align-items: center; gap: 0.5rem;
119 font-family: 'IBM Plex Mono', monospace; font-size: 0.9rem; font-weight: 500;
120 margin-bottom: 0.75rem;
121 }
122 .status-dot {
123 width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0;
124 }
125 .dot-ok { background: var(--ok); }
126 .dot-warn { background: var(--warn); }
127 .dot-error { background: var(--error); }
128 .dot-unknown { background: var(--unknown); }
129 dl {
130 display: grid; grid-template-columns: auto 1fr;
131 gap: 0.25rem 0.75rem; font-size: 0.85rem;
132 }
133 dt { opacity: 0.7; }
134 dd { text-align: right; font-family: 'IBM Plex Mono', monospace; }
135 .section-title {
136 font-family: 'IBM Plex Mono', monospace; font-size: 1rem; font-weight: 500;
137 border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; margin: 1.5rem 0 1rem;
138 }
139 details { margin-bottom: 0.75rem; }
140 details summary {
141 font-family: 'IBM Plex Mono', monospace; font-size: 0.85rem;
142 cursor: pointer; padding: 0.5rem; background: var(--light-background);
143 border-radius: 4px; list-style: none;
144 }
145 details summary::before { content: '\25b6 '; font-size: 0.7rem; }
146 details[open] summary::before { content: '\25bc '; }
147 details .detail-content { padding: 0.5rem; font-size: 0.85rem; }
148 .incident-line {
149 display: flex; justify-content: space-between; align-items: center;
150 padding: 0.4rem 0; border-bottom: 1px solid var(--border); font-size: 0.85rem;
151 }
152 .incident-line:last-child { border-bottom: none; }
153 .uptime-ok { color: var(--ok); }
154 .uptime-warn { color: var(--warn); }
155 .uptime-danger { color: var(--error); }
156 .alert-bar {
157 padding: 0.4rem 0.6rem; font-size: 0.8rem; border-radius: 4px;
158 margin-top: 0.5rem; font-family: 'IBM Plex Mono', monospace;
159 }
160 .alert-bar.incident-bar { background: rgba(239,68,68,0.1); border-left: 3px solid var(--error); }
161 .alert-bar.stale-bar { background: rgba(245,158,11,0.1); border-left: 3px solid var(--warn); }
162 @media (max-width: 600px) {
163 .summary-bar { flex-direction: column; gap: 0.5rem; }
164 .health-grid { grid-template-columns: 1fr; }
165 }
166 ";
167
168 const JS: &str = r#"
169 let countdown = 30;
170 let timer = null;
171
172 // The dashboard authenticates to /api/* with the httpOnly pom_dash session
173 // cookie (set by GET /), sent automatically by `credentials: 'same-origin'`.
174 // No token is ever exposed to page JS.
175 const FETCH_OPTS = { credentials: 'same-origin' };
176
177 function dotClass(status) {
178 if (!status) return 'dot-unknown';
179 const s = status.toLowerCase();
180 if (s === 'operational') return 'dot-ok';
181 if (s === 'degraded') return 'dot-warn';
182 if (s === 'error' || s === 'unreachable') return 'dot-error';
183 return 'dot-unknown';
184 }
185
186 function uptimeClass(pct) {
187 if (pct >= 99) return 'uptime-ok';
188 if (pct >= 95) return 'uptime-warn';
189 return 'uptime-danger';
190 }
191
192 function fmtPct(v) {
193 return v != null ? v.toFixed(2) + '%' : 'N/A';
194 }
195
196 function renderCard(name, t) {
197 const status = t.latest ? t.latest.status : 'unknown';
198 const dc = dotClass(status);
199 let cls = 'health-card';
200 if (t.current_incident) cls += ' incident';
201 else if (t.test_staleness && t.test_staleness.stale) cls += ' stale';
202
203 let html = '<div class="' + cls + '">';
204 html += '<div class="card-header"><span class="status-dot ' + dc + '"></span>' + esc(t.label) + '</div>';
205 html += '<dl>';
206 html += '<dt>Status</dt><dd>' + esc(status) + '</dd>';
207 if (t.latest) html += '<dt>Response</dt><dd>' + t.latest.response_time_ms + 'ms</dd>';
208 if (t.uptime_24h != null) {
209 const c24 = uptimeClass(t.uptime_24h);
210 html += '<dt>Uptime 24h</dt><dd class="' + c24 + '">' + fmtPct(t.uptime_24h) + '</dd>';
211 }
212 if (t.uptime_7d != null) {
213 const c7 = uptimeClass(t.uptime_7d);
214 html += '<dt>Uptime 7d</dt><dd class="' + c7 + '">' + fmtPct(t.uptime_7d) + '</dd>';
215 }
216 html += '</dl>';
217
218 if (t.latency_24h) {
219 html += '<dl>';
220 html += '<dt>Avg</dt><dd>' + t.latency_24h.avg_ms.toFixed(0) + 'ms</dd>';
221 html += '<dt>P95</dt><dd>' + t.latency_24h.p95_ms + 'ms</dd>';
222 html += '<dt>Min/Max</dt><dd>' + t.latency_24h.min_ms + '/' + t.latency_24h.max_ms + 'ms</dd>';
223 html += '</dl>';
224 }
225
226 if (t.tls) {
227 html += '<dl>';
228 html += '<dt>TLS</dt><dd>' + (t.tls.valid ? 'valid' : 'invalid') + '</dd>';
229 const dc2 = t.tls.days_remaining > 14 ? 'uptime-ok' : t.tls.days_remaining > 7 ? 'uptime-warn' : 'uptime-danger';
230 html += '<dt>Expires</dt><dd class="' + dc2 + '">' + t.tls.days_remaining + 'd</dd>';
231 html += '</dl>';
232 }
233
234 if (t.whois) {
235 html += '<dl>';
236 const wd = t.whois.days_remaining;
237 const wc = wd > 30 ? 'uptime-ok' : wd > 14 ? 'uptime-warn' : 'uptime-danger';
238 html += '<dt>Domain</dt><dd class="' + wc + '">' + (wd != null ? wd + 'd' : 'N/A') + '</dd>';
239 if (t.whois.registrar) html += '<dt>Registrar</dt><dd>' + esc(t.whois.registrar) + '</dd>';
240 html += '</dl>';
241 }
242
243 if (t.dns_status && t.dns_status.length > 0) {
244 const m = t.dns_status.filter(function(d) { return d.matches; }).length;
245 html += '<dl><dt>DNS</dt><dd>' + m + '/' + t.dns_status.length + ' match</dd></dl>';
246 }
247 if (t.route_status && t.route_status.length > 0) {
248 const ok = t.route_status.filter(function(r) { return r.ok; }).length;
249 html += '<dl><dt>Routes</dt><dd>' + ok + '/' + t.route_status.length + ' OK</dd></dl>';
250 }
251
252 if (t.current_incident) {
253 html += '<div class="alert-bar incident-bar">Incident: ' + esc(t.current_incident.from_status) + ' \u2192 ' + esc(t.current_incident.to_status) + '</div>';
254 }
255 if (t.test_staleness && t.test_staleness.stale) {
256 html += '<div class="alert-bar stale-bar">Tests stale: ' + esc(t.test_staleness.reason) + '</div>';
257 }
258 if (t.test_duration_drift) {
259 html += '<div class="alert-bar stale-bar">' + esc(t.test_duration_drift) + '</div>';
260 }
261
262 html += '</div>';
263 return html;
264 }
265
266 function esc(s) {
267 if (!s) return '';
268 var d = document.createElement('div');
269 d.textContent = s;
270 return d.innerHTML;
271 }
272
273 function renderDetails(targets) {
274 let html = '';
275 // Recent incidents
276 let hasIncidents = false;
277 let incHtml = '';
278 for (const name in targets) {
279 const t = targets[name];
280 if (t.incidents && t.incidents.length > 0) {
281 hasIncidents = true;
282 for (let i = 0; i < t.incidents.length; i++) {
283 const inc = t.incidents[i];
284 const dur = inc.duration_secs ? Math.round(inc.duration_secs / 60) + 'm' : 'ongoing';
285 incHtml += '<div class="incident-line"><span>' + esc(t.label) + ': ' + esc(inc.from_status) + ' \u2192 ' + esc(inc.to_status) + '</span><span>' + dur + '</span></div>';
286 }
287 }
288 }
289 if (hasIncidents) {
290 html += '<details><summary>Recent Incidents</summary><div class="detail-content">' + incHtml + '</div></details>';
291 }
292
293 // DNS details
294 let hasDns = false;
295 let dnsHtml = '';
296 for (const name in targets) {
297 const t = targets[name];
298 if (t.dns_status && t.dns_status.length > 0) {
299 hasDns = true;
300 for (let i = 0; i < t.dns_status.length; i++) {
301 const d = t.dns_status[i];
302 const mc = d.matches ? 'dot-ok' : 'dot-error';
303 dnsHtml += '<div class="incident-line"><span><span class="status-dot ' + mc + '" style="display:inline-block;vertical-align:middle;margin-right:4px"></span>' + esc(d.name) + ' ' + esc(d.record_type) + '</span><span>' + esc(d.actual.join(', ')) + '</span></div>';
304 }
305 }
306 }
307 if (hasDns) {
308 html += '<details><summary>DNS Details</summary><div class="detail-content">' + dnsHtml + '</div></details>';
309 }
310
311 // Route details
312 let hasRoutes = false;
313 let routeHtml = '';
314 for (const name in targets) {
315 const t = targets[name];
316 if (t.route_status && t.route_status.length > 0) {
317 hasRoutes = true;
318 for (let i = 0; i < t.route_status.length; i++) {
319 const r = t.route_status[i];
320 const rc = r.ok ? 'dot-ok' : 'dot-error';
321 routeHtml += '<div class="incident-line"><span><span class="status-dot ' + rc + '" style="display:inline-block;vertical-align:middle;margin-right:4px"></span>' + esc(t.label) + ' ' + esc(r.path) + '</span><span>' + r.status_code + ' (' + r.response_time_ms + 'ms)</span></div>';
322 }
323 }
324 }
325 if (hasRoutes) {
326 html += '<details><summary>Route Details</summary><div class="detail-content">' + routeHtml + '</div></details>';
327 }
328
329 return html;
330 }
331
332 function renderMesh(data) {
333 if (!data || !data.instances) return '';
334 let html = '<div class="section-title">Peer Mesh</div>';
335 html += '<div class="health-grid">';
336 for (const name in data.instances) {
337 const inst = data.instances[name];
338 html += '<div class="health-card"><div class="card-header">' + esc(name) + '</div>';
339 if (inst.instance) {
340 html += '<dl><dt>Version</dt><dd>' + esc(inst.instance.version) + '</dd></dl>';
341 }
342 if (inst.targets) {
343 html += '<dl>';
344 for (const tn in inst.targets) {
345 const tt = inst.targets[tn];
346 const dc = dotClass(tt.status);
347 html += '<dt>' + esc(tt.label || tn) + '</dt><dd><span class="status-dot ' + dc + '" style="display:inline-block;vertical-align:middle"></span></dd>';
348 }
349 html += '</dl>';
350 }
351 html += '</div>';
352 }
353 html += '</div>';
354 return html;
355 }
356
357 async function refresh() {
358 try {
359 const resp = await fetch('/api/status', FETCH_OPTS);
360 if (!resp.ok) return;
361 const data = await resp.json();
362 const targets = data.targets || {};
363
364 // Global dot
365 let worst = 'operational';
366 for (const name in targets) {
367 const s = targets[name].latest ? targets[name].latest.status : 'unknown';
368 if (s === 'error' || s === 'unreachable') worst = 'error';
369 else if (s === 'degraded' && worst !== 'error') worst = 'degraded';
370 else if (s === 'unknown' && worst === 'operational') worst = 'unknown';
371 }
372 document.getElementById('global-dot').className = 'summary-dot ' + dotClass(worst).replace('dot-', 'dot-');
373
374 const names = Object.keys(targets).sort();
375 let gridHtml = '';
376 for (let i = 0; i < names.length; i++) {
377 gridHtml += renderCard(names[i], targets[names[i]]);
378 }
379 document.getElementById('target-grid').innerHTML = gridHtml;
380
381 document.getElementById('details-section').innerHTML = renderDetails(targets);
382
383 document.getElementById('last-updated').textContent = 'Updated ' + new Date().toLocaleTimeString();
384
385 if (HAS_MESH) {
386 try {
387 const mr = await fetch('/api/mesh', FETCH_OPTS);
388 if (mr.ok) {
389 const md = await mr.json();
390 document.getElementById('mesh-section').innerHTML = renderMesh(md);
391 }
392 } catch(e) {}
393 }
394 } catch(e) {
395 console.error('Dashboard refresh failed:', e);
396 }
397 }
398
399 function tick() {
400 countdown--;
401 if (countdown <= 0) { countdown = 30; refresh(); }
402 document.getElementById('refresh-timer').textContent = 'Refresh: ' + countdown + 's';
403 }
404
405 refresh();
406 timer = setInterval(tick, 1000);
407 "#;
408