Skip to main content

max / makenotwork

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