Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_steel/src/srv/admin/assets.rs

26.8 KiB, 184 runs

created by r1870400018:10320, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Embedded front-end assets and HTML layout helpers for the
2//! admin dashboard.
3//!
4//! The dashboard's CSS is shipped as a single string compiled into
5//! the `steel` binary via `include_str!`, then inlined into every
6//! response inside a `<style>` tag. Inlining (rather than serving
7//! `/admin/css/style.css` from a separate route) keeps the asset
8//! pipeline trivial -- no extra route, no cache headers, no
9//! authenticated-vs-unauthenticated path split for static files,
10//! no separate disk directory to sync to a production host.
11//!
12//! The same inlining treatment applies to the uPlot charting
13//! library (vendored at `assets/uplot.js` + `assets/uplot.css`)
14//! used by the traffic view.
15//!
16//! Palette and typography are defined in the CSS file. Headers
17//! are deliberately text-only and minimal -- no logo, no brand
18//! mark, no placeholder artwork.
19//!
20//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
21//! Anthropic Claude
22
23use crate::srv::admin::AdminPrincipal;
24
25use oxedyne_fe2o3_core::prelude::*;
26
27pub const STYLE_CSS: &str = include_str!("assets/style.css");
28
29// uPlot (https://github.com/leeoniya/uPlot), MIT licensed, v1.6.32 vendored.
30// About 51 KB of source, perhaps half that over the wire once compressed --
31// trivial compared with a framework runtime. The companion CSS covers axis
32// styling, crosshair rendering and legend layout.
33pub const UPLOT_JS: &str = include_str!("assets/uplot.js");
34pub const UPLOT_CSS: &str = include_str!("assets/uplot.css");
35
36// Inlined marks: fe2o3 in the sidebar banner of every authenticated page and
37// in the login card, Oxedyne in the page header brand area, Ozone beside the
38// `Database` page heading. Dark mode recolours the fe2o3 wordmark through an
39// SVG-targeting override in `style.css`.
40pub const FE2O3_LOGO_SVG: &str = include_str!("assets/fe2o3_logo.svg");
41
42pub const OXEDYNE_LOGO_SVG: &str = include_str!("assets/oxedyne_logo.svg");
43
44pub const OZONE_LOGO_SVG: &str = include_str!("assets/ozone_logo.svg");
45
46// Served under `/admin/assets/oxanium.ttf` so the browser caches it between
47// pages; the `@font-face` rule in `style.css` points at the same route.
48pub const FONT_OXANIUM_TTF: &[u8] = include_bytes!("assets/oxanium.ttf");
49
50// Header-chrome icons: 20 x 20 glyphs, one path each, no fills that could be
51// theme-dependent. Sun and moon share outer geometry so the theme toggle
52// animates cleanly. The moon shows while dark mode is active, so the toggle
53// reads "switch to light mode".
54pub const ICON_SUN_SVG: &str = r#"<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>"#;
55
56pub const ICON_MOON_SVG: &str = r#"<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>"#;
57
58// Fetches the host-sampler JSON at `/admin/host.json`, builds four small uPlot
59// sparklines against the four `<div class="spark-plot">` placeholders and
60// updates the four current-value labels. Runs once on `DOMContentLoaded`, then
61// again whenever the auto-refresh loop pokes the function it exposes on
62// `window`, so chart and headline update without a full page reload. An empty
63// payload degrades gracefully: the sparkline keeps its placeholder and the
64// headline its previous value.
65pub const OVERVIEW_SPARKLINE_JS: &str = r#"
66(function() {
67 var charts = {};
68 var SPECS = [
69 { key: 'cpu', label: 'CPU %', color: 'rgb(243, 60, 87)', fmt: fmtPct },
70 { key: 'mem', label: 'Memory %', color: 'rgb(46, 125, 50)', fmt: fmtPct },
71 { key: 'disk', label: 'Disk B/s', color: 'rgb(237, 108, 2)', fmt: fmtBps },
72 { key: 'net', label: 'Net B/s', color: 'rgb(51, 102, 204)', fmt: fmtBps },
73 ];
74 function fmtPct(v) {
75 if (v == null) return '\u2014';
76 return Math.round(v) + ' %';
77 }
78 function fmtBps(v) {
79 if (v == null) return '\u2014';
80 if (v < 1024) return Math.round(v) + ' B/s';
81 if (v < 1048576) return (v / 1024).toFixed(1) + ' KiB/s';
82 if (v < 1073741824) return (v / 1048576).toFixed(1) + ' MiB/s';
83 return (v / 1073741824).toFixed(2) + ' GiB/s';
84 }
85 function buildChart(spec, t, values) {
86 var container = document.getElementById('spark-' + spec.key);
87 if (!container || typeof uPlot === 'undefined') return null;
88 var width = container.clientWidth || 160;
89 var opts = {
90 width: width,
91 height: 60,
92 cursor: { show: false },
93 legend: { show: false },
94 scales: {
95 x: { time: true },
96 y: { auto: true },
97 },
98 axes: [
99 { show: false },
100 { show: false },
101 ],
102 series: [
103 {},
104 {
105 label: spec.label,
106 stroke: spec.color,
107 width: 1.5,
108 fill: spec.color.replace('rgb', 'rgba').replace(')', ', 0.14)'),
109 points: { show: false },
110 },
111 ],
112 };
113 return new uPlot(opts, [t, values], container);
114 }
115 function renderAll(data) {
116 var t = data.t || [];
117 SPECS.forEach(function(spec) {
118 var values = data[spec.key] || [];
119 var last = values.length ? values[values.length - 1] : null;
120 var valNode = document.getElementById('spark-' + spec.key + '-val');
121 if (valNode) valNode.textContent = spec.fmt(last);
122 var existing = charts[spec.key];
123 if (existing) {
124 existing.setData([t, values]);
125 return;
126 }
127 if (t.length > 0) {
128 charts[spec.key] = buildChart(spec, t, values);
129 }
130 });
131 }
132 function load() {
133 fetch('/admin/host.json', { credentials: 'same-origin' })
134 .then(function(r) { return r.ok ? r.json() : null; })
135 .then(function(data) { if (data) renderAll(data); })
136 .catch(function() {});
137 }
138 window.steelSparkRefresh = load;
139 if (document.readyState === 'loading') {
140 document.addEventListener('DOMContentLoaded', load);
141 } else {
142 load();
143 }
144})();
145"#;
146
147// A five-second polling interval that calls whichever refresh function the
148// current page exposed on `window` -- `steelSparkRefresh` for the Overview host
149// strip, `steelTrafficRefresh` for the Traffic view -- and pauses while the
150// browser tab is hidden, so a background tab does not burn bandwidth. Any page
151// can include it unconditionally: with no refresh function defined the loop is
152// a no-op.
153pub const AUTO_REFRESH_JS: &str = r#"
154(function() {
155 var INTERVAL_MS = 5000;
156 function tick() {
157 if (document.hidden) return;
158 if (typeof window.steelSparkRefresh === 'function') {
159 try { window.steelSparkRefresh(); } catch (e) {}
160 }
161 if (typeof window.steelTrafficRefresh === 'function') {
162 try { window.steelTrafficRefresh(); } catch (e) {}
163 }
164 }
165 setInterval(tick, INTERVAL_MS);
166 document.addEventListener('visibilitychange', function() {
167 if (!document.hidden) tick();
168 });
169})();
170"#;
171
172// The Fleet page. Draws the rows from the JSON blob the page was served with,
173// then from `/admin/fleet.json` every thirty seconds while the tab is visible --
174// the watcher reads its peers once a minute, so faster would redraw the same
175// numbers. Every string is placed with `textContent`, never parsed as markup:
176// host names and notes come from configuration on another machine. The stamp
177// under the rows says when the page last drew and from what, so a tab that has
178// stopped refreshing cannot pass for a fleet that has stopped changing.
179pub const FLEET_JS: &str = r#"
180(function() {
181 var REFRESH_MS = 30000;
182 var SVGNS = 'http://www.w3.org/2000/svg';
183 var rowsEl = document.getElementById('fleet-rows');
184 var noticeEl = document.getElementById('fleet-notice');
185 var stampEl = document.getElementById('fleet-stamp');
186 var dataEl = document.getElementById('fleet-data');
187 var lastData = null;
188 if (!rowsEl) return;
189 function el(tag, cls, text) {
190 var e = document.createElement(tag);
191 if (cls) e.className = cls;
192 if (text !== undefined && text !== null) e.textContent = text;
193 return e;
194 }
195 function none(v) { return v === null || v === undefined; }
196 function clock(secs) { return new Date(secs * 1000).toLocaleTimeString(); }
197 function kib(k) {
198 if (none(k)) return '—';
199 if (k < 1024) return k + ' KiB';
200 if (k < 1048576) return Math.round(k / 1024) + ' MiB';
201 return (k / 1048576).toFixed(2) + ' GiB';
202 }
203 function ago(s) {
204 if (s < 90) return s + ' s ago';
205 if (s < 5400) return Math.round(s / 60) + ' min ago';
206 if (s < 172800) return Math.round(s / 3600) + ' h ago';
207 return Math.round(s / 86400) + ' d ago';
208 }
209 function lasted(s) {
210 if (s < 3600) return Math.floor(s / 60) + ' min';
211 if (s < 172800) return Math.floor(s / 3600) + ' h';
212 return Math.floor(s / 86400) + ' d';
213 }
214 function span(s) {
215 if (s < 120) return s + ' s';
216 if (s < 7200) return Math.round(s / 60) + ' min';
217 if (s < 172800) return Math.floor(s / 3600) + ' h ' + Math.round((s % 3600) / 60) + ' min';
218 return Math.floor(s / 86400) + ' d ' + Math.round((s % 86400) / 3600) + ' h';
219 }
220 function shown(cell) {
221 var v = cell.v;
222 if (none(v)) return '—';
223 switch (cell.unit) {
224 case 'pct': return v + '%';
225 case 'load': return (v / 100).toFixed(2);
226 case 'ms': return v + ' ms';
227 case 'guard': return v === 0 ? 'ok' : 'failed';
228 case 'mail': return v === 0 ? 'all bound' : v + ' not bound';
229 case 'secs': return span(v);
230 case 'kib': return kib(v);
231 case 'seal':
232 if (v > 0) return v + (v === 1 ? ' database shut' : ' databases shut');
233 return cell.sealed === 1 ? 'sealed, holds none' : 'open';
234 case 'res':
235 if (cell.procs === 0) return 'not running';
236 return none(cell.cap) ? kib(cell.rss) : kib(cell.rss) + ' of ' + kib(cell.cap);
237 default: return String(v);
238 }
239 }
240 function under(cell, local) {
241 if (cell.unit === 'res' && cell.procs > 0) {
242 var parts = [none(cell.pct) ? 'no cap set' : cell.pct + '% of its cap'];
243 if (cell.procs > 1) parts.push(cell.procs + ' processes');
244 return parts.join(' · ');
245 }
246 if (cell.unit === 'ms' && local) return 'not probed: this host';
247 if (cell.unit === 'seal' && cell.v === 0 && cell.sealed === 1) return 'no database to open';
248 return '';
249 }
250 function hint(cell) {
251 if (none(cell.d)) return cell.k + ': no threshold for this peer, so no colour.';
252 return cell.k + ': distress at ' + cell.d
253 + (none(cell.c) ? ', no separate clear' : ', clears at ' + cell.c) + '.';
254 }
255 function spark(series) {
256 var svg = document.createElementNS(SVGNS, 'svg');
257 svg.setAttribute('class', 'fleet-spark');
258 svg.setAttribute('viewBox', '0 0 120 24');
259 svg.setAttribute('preserveAspectRatio', 'none');
260 svg.setAttribute('aria-hidden', 'true');
261 var lo = null, hi = null, i, v;
262 for (i = 0; i < series.length; i++) {
263 v = series[i];
264 if (none(v)) continue;
265 if (lo === null || v < lo) lo = v;
266 if (hi === null || v > hi) hi = v;
267 }
268 if (lo === null) return svg;
269 var range = (hi - lo) || 1;
270 var d = '', pen = false, last = series.length - 1 || 1;
271 for (i = 0; i < series.length; i++) {
272 v = series[i];
273 if (none(v)) { pen = false; continue; }
274 var x = (i / last) * 120;
275 var y = 22 - ((v - lo) / range) * 20;
276 d += (pen ? 'L' : 'M') + x.toFixed(1) + ' ' + y.toFixed(1);
277 pen = true;
278 }
279 var path = document.createElementNS(SVGNS, 'path');
280 path.setAttribute('d', d);
281 svg.appendChild(path);
282 return svg;
283 }
284 function cellEl(cell, local) {
285 var c = el('div', 'fleet-cell' + (cell.tone ? ' tone-' + cell.tone : ''));
286 c.title = hint(cell);
287 c.appendChild(el('div', 'fleet-cell-label', cell.label));
288 c.appendChild(el('div', 'fleet-cell-value', shown(cell)));
289 var u = under(cell, local);
290 if (u) c.appendChild(el('div', 'fleet-cell-sub', u));
291 if (cell.s && cell.s.length > 1) c.appendChild(spark(cell.s));
292 return c;
293 }
294 function rowEl(row) {
295 var r = el('section', 'fleet-row' + (row.dim ? ' is-dim' : ''));
296 var head = el('div', 'fleet-row-head');
297 head.appendChild(el('span', 'fleet-host', row.host));
298 head.appendChild(el('span', 'fleet-state fleet-state-' + row.state, row.state));
299 var meta = [];
300 if (row.local) meta.push('this host, read locally');
301 else if (!none(row.age)) meta.push('read ' + ago(row.age));
302 if (!none(row.uptime)) meta.push('up ' + lasted(row.uptime));
303 if (row.note) meta.push(row.note);
304 head.appendChild(el('span', 'fleet-row-meta', meta.join(' · ')));
305 r.appendChild(head);
306 if (row.panes.length) {
307 var panes = el('div', 'fleet-panes');
308 row.panes.forEach(function(pane) {
309 var p = el('div', 'fleet-pane fleet-pane-' + pane.id);
310 p.appendChild(el('div', 'fleet-pane-title', pane.title));
311 var cells = el('div', 'fleet-cells');
312 pane.cells.forEach(function(cell) { cells.appendChild(cellEl(cell, row.local)); });
313 p.appendChild(cells);
314 panes.appendChild(p);
315 });
316 r.appendChild(panes);
317 }
318 if (row.services.length) {
319 var sv = el('div', 'fleet-services');
320 sv.appendChild(el('span', 'fleet-services-label', 'Also watched on this host'));
321 row.services.forEach(function(s) {
322 var text = s.name + ' ' + s.state + (none(s.probe_ms) ? '' : ' · ' + s.probe_ms + ' ms');
323 var item = el('span', 'fleet-service fleet-state-' + s.state, text);
324 if (s.note) item.title = s.note;
325 sv.appendChild(item);
326 });
327 r.appendChild(sv);
328 }
329 return r;
330 }
331 function notice(data) {
332 noticeEl.textContent = '';
333 var text = '';
334 if (data.peers === 0) {
335 text = 'This host watches no other machine, so only its own row is shown. '
336 + 'The machines it watches are listed under watch.peers in its configuration.';
337 } else if (!data.watching) {
338 text = 'A watch list is configured, but this host’s watcher is not running '
339 + '(it needs alerting and an outbound TLS client), so no peer has been read.';
340 } else if (data.link_down) {
341 text = 'No machine answered this host’s watcher at all in its last round, so the '
342 + 'silence is read as this host’s own link rather than every machine at once. '
343 + 'Nothing is judged until one answers, and each row keeps the last reading '
344 + 'heard, greyed as it ages.';
345 }
346 if (text) noticeEl.appendChild(el('p', 'notice warn', text));
347 }
348 function draw(data, failed) {
349 if (data) {
350 lastData = data;
351 notice(data);
352 var frag = document.createDocumentFragment();
353 data.rows.forEach(function(row) { frag.appendChild(rowEl(row)); });
354 rowsEl.textContent = '';
355 rowsEl.appendChild(frag);
356 }
357 if (!stampEl || !lastData) return;
358 var now = Math.floor(Date.now() / 1000);
359 var who = lastData.whoami || 'this host';
360 stampEl.textContent = failed
361 ? 'Refresh failed at ' + clock(now) + '; still showing what ' + who
362 + ' read at ' + clock(lastData.now) + '. Signing in again may be needed.'
363 : 'Drawn at ' + clock(now) + ' from what ' + who + ' read at '
364 + clock(lastData.now) + '. Peers are read every ' + lastData.interval_secs
365 + ' s; this page asks again every ' + (REFRESH_MS / 1000) + ' s.';
366 rowsEl.classList.toggle('is-behind', !!failed);
367 }
368 function load() {
369 fetch('/admin/fleet.json', { credentials: 'same-origin', cache: 'no-store' })
370 .then(function(r) { return r.ok ? r.json() : null; })
371 .then(function(data) { draw(data, !data); })
372 .catch(function() { draw(null, true); });
373 }
374 try {
375 draw(JSON.parse(dataEl.textContent), false);
376 } catch (e) {
377 load();
378 }
379 setInterval(function() { if (!document.hidden) load(); }, REFRESH_MS);
380 document.addEventListener('visibilitychange', function() {
381 if (!document.hidden) load();
382 });
383})();
384"#;
385
386// Reads the persisted theme preference from `localStorage` and applies the
387// `dark` class to `<html>`, hooked to the theme toggle button by id. Runs
388// synchronously in `<head>`, so the class lands before first paint and a user
389// with dark saved sees no flash of the light theme.
390pub const THEME_JS: &str = r#"
391(function() {
392 var KEY = 'steelAdminTheme';
393 function apply(theme) {
394 var root = document.documentElement;
395 if (theme === 'dark') root.classList.add('dark');
396 else root.classList.remove('dark');
397 var btn = document.getElementById('theme-toggle');
398 if (btn) btn.setAttribute('data-theme', theme);
399 }
400 try { apply(localStorage.getItem(KEY) || 'light'); } catch (e) {}
401 window.addEventListener('DOMContentLoaded', function() {
402 var btn = document.getElementById('theme-toggle');
403 if (!btn) return;
404 btn.addEventListener('click', function() {
405 var cur = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
406 var next = cur === 'dark' ? 'light' : 'dark';
407 try { localStorage.setItem(KEY, next); } catch (e) {}
408 apply(next);
409 });
410 });
411})();
412"#;
413
414/// Pass to [`render_layout`] when the body renders a chart. Inlined as `<style>`
415/// and `<script>` tags, so no extra asset route is required.
416pub fn upload_head_html() -> String {
417 fmt!(
418 "<style>{css}</style>\n\
419 <script>{js}</script>\n",
420 css = UPLOT_CSS,
421 js = UPLOT_JS,
422 )
423}
424
425// ┌───────────────────────────────────────────────────────────────────────────┐
426// │ NAV ENTRIES │
427// └───────────────────────────────────────────────────────────────────────────┘
428
429pub struct NavEntry {
430 pub label: &'static str,
431 pub href: &'static str,
432 pub group: Option<&'static str>,
433}
434
435pub const NAV: &[NavEntry] = &[
436 NavEntry { label: "Overview", href: "/admin", group: Some("Dashboard") },
437 NavEntry { label: "Fleet", href: "/admin/fleet", group: None },
438 NavEntry { label: "Database", href: "/admin/database", group: None },
439 NavEntry { label: "Traffic", href: "/admin/traffic", group: None },
440 NavEntry { label: "Security", href: "/admin/security", group: None },
441 NavEntry { label: "Admins", href: "/admin/admins", group: Some("Management") },
442 NavEntry { label: "Sign out", href: "/admin/logout", group: Some("Session") },
443];
444
445// ┌───────────────────────────────────────────────────────────────────────────┐
446// │ LAYOUT │
447// └───────────────────────────────────────────────────────────────────────────┘
448
449/// Wraps a body fragment in the standard authenticated dashboard layout: header
450/// bar with the current page label and signed-in user, sidebar with nav
451/// entries, main content panel.
452///
453/// `current_path` is matched against each nav entry's `href` to highlight the
454/// active link. Callers are responsible for escaping untrusted content inside
455/// `body_html`. `head_extra` lets a page inject extra `<script>` or `<link>`
456/// tags.
457pub fn render_layout(
458 title: &str,
459 current_path: &str,
460 principal: &AdminPrincipal,
461 body_html: &str,
462 head_extra: &str,
463)
464 -> String
465{
466 let nav_html = render_nav(current_path);
467 fmt!(
468 "<!doctype html>\n\
469 <html lang=\"en\">\n\
470 <head>\n\
471 <meta charset=\"utf-8\">\n\
472 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
473 <meta name=\"robots\" content=\"noindex\">\n\
474 <title>{title} :: Steel admin</title>\n\
475 <style>{css}</style>\n\
476 <script>{theme_js}</script>\n\
477 {head_extra}\
478 </head>\n\
479 <body>\n\
480 <header class=\"steel-header\">\n\
481 <div class=\"brand\">\
482 <a href=\"/admin\" class=\"brand-logo\" aria-label=\"Oxedyne\">{oxedyne}</a>\
483 <span class=\"brand-section\">{section}</span>\
484 </div>\n\
485 <div class=\"session\">\
486 <button id=\"theme-toggle\" class=\"theme-toggle\" type=\"button\" aria-label=\"Toggle theme\">\
487 <span class=\"theme-icon-sun\">{sun}</span>\
488 <span class=\"theme-icon-moon\">{moon}</span>\
489 </button>\
490 <span class=\"session-user\">{user}</span>\
491 <a class=\"session-signout\" href=\"/admin/logout\">Sign out</a>\
492 </div>\n\
493 </header>\n\
494 <div class=\"page\">\n\
495 <nav class=\"sidebar\">\n\
496 <div class=\"sidebar-brand\">{fe2o3}</div>\n\
497 {nav_entries}\
498 </nav>\n\
499 <main class=\"content\">\n\
500 {body}\
501 </main>\n\
502 </div>\n\
503 </body>\n\
504 </html>\n",
505 title = html_escape(title),
506 css = STYLE_CSS,
507 theme_js = THEME_JS,
508 head_extra = head_extra,
509 oxedyne = OXEDYNE_LOGO_SVG,
510 fe2o3 = FE2O3_LOGO_SVG,
511 section = html_escape(title),
512 sun = ICON_SUN_SVG,
513 moon = ICON_MOON_SVG,
514 user = html_escape(&principal.name),
515 nav_entries = nav_html,
516 body = body_html,
517 )
518}
519
520/// No sidebar and no header bar: a centred card with the fe2o3 logo above the
521/// form.
522pub fn render_login_layout(
523 title: &str,
524 body_html: &str,
525)
526 -> String
527{
528 fmt!(
529 "<!doctype html>\n\
530 <html lang=\"en\">\n\
531 <head>\n\
532 <meta charset=\"utf-8\">\n\
533 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
534 <meta name=\"robots\" content=\"noindex\">\n\
535 <title>{title} :: Steel admin</title>\n\
536 <style>{css}</style>\n\
537 <script>{theme_js}</script>\n\
538 </head>\n\
539 <body class=\"login\">\n\
540 <div class=\"login-card\">\n\
541 <div class=\"login-logo\">{logo}</div>\n\
542 <div class=\"login-sub\">Admin dashboard</div>\n\
543 {body}\
544 </div>\n\
545 </body>\n\
546 </html>\n",
547 title = html_escape(title),
548 css = STYLE_CSS,
549 theme_js = THEME_JS,
550 logo = FE2O3_LOGO_SVG,
551 body = body_html,
552 )
553}
554
555// ┌───────────────────────────────────────────────────────────────────────────┐
556// │ FRAGMENTS │
557// └───────────────────────────────────────────────────────────────────────────┘
558
559pub fn render_nav(current_path: &str) -> String {
560 let mut out = String::new();
561 let mut last_group: Option<&'static str> = None;
562 for entry in NAV {
563 if let Some(group) = entry.group {
564 if last_group != Some(group) {
565 out.push_str(&fmt!(
566 "<div class=\"group\">{}</div>\n",
567 html_escape(group),
568 ));
569 last_group = Some(group);
570 }
571 }
572 let class = if is_current(current_path, entry.href) {
573 " class=\"current\""
574 } else {
575 ""
576 };
577 out.push_str(&fmt!(
578 "<a href=\"{}\"{}>{}</a>\n",
579 entry.href,
580 class,
581 html_escape(entry.label),
582 ));
583 }
584 out
585}
586
587/// The home entry (`/admin`) matches only an exact `/admin`; every other entry
588/// matches its prefix, so a sub-page like `/admin/database?prefix=user:` still
589/// highlights `Database`.
590fn is_current(current_path: &str, entry_href: &str) -> bool {
591 if entry_href == "/admin" {
592 current_path == "/admin"
593 } else {
594 current_path == entry_href
595 || current_path.starts_with(&fmt!("{}/", entry_href))
596 || current_path.starts_with(&fmt!("{}?", entry_href))
597 }
598}
599
600// ┌───────────────────────────────────────────────────────────────────────────┐
601// │ HTML ESCAPE │
602// └───────────────────────────────────────────────────────────────────────────┘
603
604/// Replaces the five characters that can break out of a text node or attribute
605/// value into markup: `&`, `<`, `>`, `"`, `'`.
606pub fn html_escape(s: &str) -> String {
607 let mut out = String::with_capacity(s.len());
608 for c in s.chars() {
609 match c {
610 '&' => out.push_str("&amp;"),
611 '<' => out.push_str("&lt;"),
612 '>' => out.push_str("&gt;"),
613 '"' => out.push_str("&quot;"),
614 '\'' => out.push_str("&#39;"),
615 _ => out.push(c),
616 }
617 }
618 out
619}