306 lines
14 KiB
JavaScript
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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 ? '✓' : '') + '</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">●</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">×</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">…</span>' +
|
|
'<span class="nav-profile-caret">▾</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();
|
|
});
|
|
})();
|