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 | |
| 23 | use crate::srv::admin::AdminPrincipal; |
| 24 | |
| 25 | use oxedyne_fe2o3_core::prelude::*; |
| 26 | |
| 27 | pub 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. |
| 33 | pub const UPLOT_JS: &str = include_str!("assets/uplot.js"); |
| 34 | pub 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`. |
| 40 | pub const FE2O3_LOGO_SVG: &str = include_str!("assets/fe2o3_logo.svg"); |
| 41 | |
| 42 | pub const OXEDYNE_LOGO_SVG: &str = include_str!("assets/oxedyne_logo.svg"); |
| 43 | |
| 44 | pub 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. |
| 48 | pub 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". |
| 54 | pub 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 | |
| 56 | pub 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. |
| 65 | pub 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. |
| 153 | pub 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. |
| 179 | pub 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. |
| 390 | pub 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. |
| 416 | pub 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 | |
| 429 | pub struct NavEntry { |
| 430 | pub label: &'static str, |
| 431 | pub href: &'static str, |
| 432 | pub group: Option<&'static str>, |
| 433 | } |
| 434 | |
| 435 | pub 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. |
| 457 | pub 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. |
| 522 | pub 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 | |
| 559 | pub 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`. |
| 590 | fn 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: `&`, `<`, `>`, `"`, `'`. |
| 606 | pub 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("&"), |
| 611 | '<' => out.push_str("<"), |
| 612 | '>' => out.push_str(">"), |
| 613 | '"' => out.push_str("""), |
| 614 | '\'' => out.push_str("'"), |
| 615 | _ => out.push(c), |
| 616 | } |
| 617 | } |
| 618 | out |
| 619 | } |