/* Shared navbar right-cluster: SSE status dot + routing-profile switcher. * * Why a shared file when every other admin page keeps its JS inline: this * widget goes on ALL EIGHT pages, and the navbar has already proved it does * not survive duplication -- adding the Quota nav link was an eight-file edit, * and #generated-at rotted into a permanent em-dash on five pages because only * two ever populated it. One file, one behaviour. * * Pages opt in with: * in the navbar right cluster * * and call navbarSse(state) from their own EventSource handlers. */ (function () { 'use strict'; var API = '/admin/api/'; /* ── SSE status dot ────────────────────────────────────────────────────── * Was a text badge reading "live" / "connecting…" / "reconnecting…". The * text is the least informative part -- the colour already carries it -- and * it cost ~90px of a cluster that now has to hold a profile name too. State * stays reachable via the tooltip and, for screen readers, aria-label. */ var SSE_STATES = { connecting: { color: '#fbbf24', label: 'connecting to the decision stream' }, live: { color: '#4ade80', label: 'live - streaming decisions' }, offline: { color: '#f87171', label: 'offline - reconnecting' }, }; var _sse = { state: 'connecting', detail: null }; var _updated = null; function paintDot() { var dot = document.getElementById('nav-sse-dot'); if (!dot) return; var s = SSE_STATES[_sse.state] || SSE_STATES.offline; dot.style.background = s.color; dot.style.boxShadow = '0 0 6px ' + s.color; var text = s.label + (_sse.detail ? ' - ' + _sse.detail : ''); if (_updated) text += '\ndata updated ' + _updated; dot.setAttribute('title', text); dot.setAttribute('aria-label', text); } window.navbarSse = function (state, detail) { _sse = { state: state, detail: detail || null }; paintDot(); }; /* Poll freshness, which used to be #generated-at -- a navbar span that only * two of the seven pages ever wrote to, so on the other five it was a * hardcoded em-dash that never updated and told the operator nothing. It * rides the dot's tooltip now: same information, no permanent placeholder, * and the corner is free for the profile switcher. */ window.navbarUpdated = function (when) { _updated = when || null; paintDot(); }; /* ── Profile switcher ─────────────────────────────────────────────────── */ function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } var _state = null; function render() { var label = document.getElementById('nav-profile-label'); var menu = document.getElementById('nav-profile-menu'); if (!label || !menu || !_state) return; label.textContent = _state.active; // Runtime and persisted diverge whenever someone switched without // pinning. Surfacing it is the point: otherwise a restart silently // reverts routing and nothing on screen ever said it would. var drifted = _state.active !== _state.boots_to; label.style.color = drifted ? '#fbbf24' : ''; menu.innerHTML = _state.available.map(function (name) { var isActive = name === _state.active; var isBoot = name === _state.boots_to; return '' + ''; }).join('') + ''; } function load() { return fetch(API + 'active-profile') .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { if (d) { _state = d; render(); } }) .catch(function () { /* navbar must never break the page */ }); } function post(url, body) { return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }).then(function (r) { if (!r.ok) return r.json().then(function (e) { throw new Error(e.detail || r.status); }); return r.json(); }); } function flash(msg, ok) { var el = document.getElementById('nav-profile-flash'); if (!el) return; el.textContent = msg; el.style.color = ok ? '#4ade80' : '#f87171'; setTimeout(function () { if (el.textContent === msg) el.textContent = ''; }, 4000); } function wire() { var menu = document.getElementById('nav-profile-menu'); if (!menu) return; menu.addEventListener('click', function (ev) { var pick = ev.target.closest('[data-profile]'); var pin = ev.target.closest('[data-pin]'); if (pin) { // Persist AND apply: the write path mirrors into the running cfg, so // this is "active now and after the next restart" with no bounce. ev.stopPropagation(); var pinName = pin.getAttribute('data-pin'); post(API + 'config/routing.default_profile', { value: pinName }) .then(function () { flash(pinName + ' is now the boot default', true); return load(); }) .catch(function (e) { flash(String(e.message || e), false); }); return; } if (pick) { var name = pick.getAttribute('data-profile'); if (_state && name === _state.active) return; post(API + 'runtime/active_profile', { value: name }) .then(function () { flash('auto now resolves to ' + name, true); return load(); }) .catch(function (e) { flash(String(e.message || e), false); }); } }); } /* ── Stale-page notice ─────────────────────────────────────────────────── * The failure: a tab opened BEFORE a frontend edit keeps running the old JS * forever, and eventually posts a field the shipped code no longer sends and * gets an unreadable 422. It is NOT a caching bug -- every admin route * already sends Cache-Control: no-cache, and FileResponse supplies ETag and * Last-Modified, so any *load* revalidates and gets fresh bytes. The page * simply never loads again, so no server header can reach it. Hence the page * asks. * * The interval is a constant here, not a config knob: nothing the router * itself reads depends on it (config.yaml is for config the router reads), * the endpoint stats eleven files and reads none, and a knob would need a * key in config.py and config.yaml whose only consumer is a browser poll. * 30s is under the time it takes to notice a page misbehaving and well over * the cost of the check. */ var FRONTEND_POLL_MS = 30000; var _frontendDigest = null; function mountStaleNotice() { var el = document.createElement('div'); el.id = 'nav-stale'; el.className = 'nav-stale'; el.setAttribute('role', 'status'); el.innerHTML = '' + '' + 'This page is out of date' + '' + ''; // Never reload on our own: an operator may be mid-edit in // a profile modal or a dirty config row, and losing that to a surprise // reload is worse than the staleness. Offer it, do not take it. el.querySelector('.nav-stale-reload').addEventListener('click', function () { window.location.reload(); }); // Dismiss hides it until the NEXT change: the new digest was already // adopted, so a further edit is a new fact and says so again. el.querySelector('.nav-stale-close').addEventListener('click', function () { el.classList.remove('show'); }); document.body.appendChild(el); } // `force` is the load-time call. A page opened in a BACKGROUND tab is hidden // from the first frame, so a plain hidden-guard here would leave the baseline // unset -- and then adopt whatever the digest happens to be when the operator // finally looks, silently swallowing every change made in between. Take the // baseline unconditionally; only the repeat checks are skippable. function checkFrontend(force) { if (!force && document.hidden) return; return fetch(API + 'frontend-version') .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { if (!d || !d.digest) return; if (_frontendDigest === null) { _frontendDigest = d.digest; return; } if (d.digest === _frontendDigest) return; _frontendDigest = d.digest; var el = document.getElementById('nav-stale'); if (el) el.classList.add('show'); }) .catch(function () { /* navbar must never break the page */ }); } document.addEventListener('DOMContentLoaded', function () { mountStaleNotice(); checkFrontend(true); setInterval(checkFrontend, FRONTEND_POLL_MS); // The highest-value moment to re-check is the operator coming back to a tab // that has been sitting open -- which is the exact shape of the failure. document.addEventListener('visibilitychange', function () { if (!document.hidden) checkFrontend(); }); var host = document.getElementById('nav-widgets'); if (!host) return; host.innerHTML = '' + '' + ''; // paintDot(), NOT navbarSse('connecting'): the page's own init() runs // before DOMContentLoaded and has usually already reported 'live' by now. // Resetting the state here would repaint the dot amber and leave it there, // since nothing re-reports until the connection next changes. paintDot(); wire(); load(); }); })();