Files
6krrt/admin/frontend/navbar.js

306 lines
14 KiB
JavaScript

/* 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:
* <span id="nav-widgets"></span> in the navbar right cluster
* <ul id="nav-links"></ul> where the shared NAV_LINKS render
* <script src="navbar.js"></script>
* and call navbarSse(state) from their own EventSource handlers.
*/
(function () {
'use strict';
var API = '/admin/api/';
/* ── Nav links ───────────────────────────────────────────────────────────
* The seven nav entries used to be hardcoded into every page's navbar and
* drifted -- the file this comment lives in exists because that strip
* already proved it does not survive duplication. Each page now ships an
* empty <ul id="nav-links"> and the DOMContentLoaded handler below fills
* it from this one list.
*
* The markers around the next two blocks are load-bearing for
* tests/test_admin_js_units.py: that harness slices the blocks out of
* this file and runs them through node, so each block must stay
* syntactically complete on its own. */
/* NAV_LINKS:BEGIN */
var NAV_LINKS = [
{ href: '/admin/models', label: 'Models' },
{ href: '/admin/profiles', label: 'Profiles' },
{ href: '/admin/proficiency', label: 'Proficiency' },
{ href: '/admin/decisions', label: 'Decisions' },
{ href: '/admin/quota', label: 'Quota' },
{ href: '/admin/controls', label: 'Controls' },
{ href: '/admin/providers', label: 'Providers' },
];
/* NAV_LINKS:END */
/* ACTIVE_NAV_HREF:BEGIN */
function activeNavHref(pathname, links) {
// Exact match only. Every href starts with /admin/, so anything looser
// than full equality lights up on the landing page too, and a
// near-miss like /admin/model (singular) deserves no highlight.
for (var i = 0; i < links.length; i++) {
if (links[i].href === pathname) return links[i].href;
}
return null;
}
/* ACTIVE_NAV_HREF:END */
/* ── 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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 '' +
'<div class="nav-profile-row' + (isActive ? ' active' : '') + '">' +
'<button type="button" class="nav-profile-pick" data-profile="' + esc(name) + '">' +
'<span class="nav-profile-tick">' + (isActive ? '&#10003;' : '') + '</span>' +
'<span class="nav-profile-name">' + esc(name) + '</span>' +
'</button>' +
'<button type="button" class="nav-profile-pin' + (isBoot ? ' on' : '') + '"' +
' data-pin="' + esc(name) + '"' +
' title="' + (isBoot ? 'Boots to this profile' : 'Also make this the boot default') + '"' +
' aria-label="Set ' + esc(name) + ' as the boot default">&#9679;</button>' +
'</div>';
}).join('') +
'<div class="nav-profile-foot">' +
'active: <strong>' + esc(_state.active) + '</strong><br>' +
'boots to: <strong>' + esc(_state.boots_to) + '</strong>' +
(drifted ? '<br><span class="nav-profile-drift">runtime override - a restart reverts this</span>' : '') +
'</div>';
}
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 = '' +
'<span class="nav-stale-dot"></span>' +
'<span>This page is out of date</span>' +
'<button type="button" class="nav-stale-reload">Reload</button>' +
'<button type="button" class="nav-stale-close" aria-label="Dismiss">&times;</button>';
// 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();
});
// Nav links render before the nav-widgets block so a page that ships
// the list mount but not the widget cluster still gets its nav.
var navMount = document.getElementById('nav-links');
if (navMount) {
var activeHref = activeNavHref(window.location.pathname, NAV_LINKS);
navMount.innerHTML = NAV_LINKS.map(function (l) {
var activeCls = l.href === activeHref ? ' active' : '';
return '<li class="nav-item' + activeCls + '"><a class="nav-link" href="' + l.href + '">' + l.label + '</a></li>';
}).join('');
}
var host = document.getElementById('nav-widgets');
if (!host) return;
host.innerHTML = '' +
'<span id="nav-sse-dot" class="nav-sse-dot" role="status" title="connecting"></span>' +
'<div class="dropdown nav-profile">' +
'<button class="nav-profile-btn" type="button" data-bs-toggle="dropdown"' +
' aria-expanded="false" title="Which profile bare `auto` resolves to">' +
'<span class="nav-profile-cap">profile</span>' +
'<span id="nav-profile-label">&hellip;</span>' +
'<span class="nav-profile-caret">&#9662;</span>' +
'</button>' +
'<div class="dropdown-menu dropdown-menu-end nav-profile-menu-wrap">' +
'<div id="nav-profile-menu"></div>' +
'<div id="nav-profile-flash" class="nav-profile-flash"></div>' +
'</div>' +
'</div>';
// 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();
});
})();