Files
6krrt/admin/frontend/navbar.js
adlee-was-taken 242a01bf2c feat(admin): an open tab now notices the frontend changed under it
A page opened before a frontend edit keeps running the JavaScript it loaded.
It surfaced as a 422 about a field the shipped code no longer sends, which
reads as a backend bug and is not one.

It is also not an HTTP caching bug, and checking that first was worth the
time: every frontend route already sets Cache-Control: no-cache and
FileResponse supplies ETag/Last-Modified, so any page LOAD revalidates.
Verified against the live service. The tab simply never loads again, so
there is no request to attach a header to. The page has to ask instead.

GET /admin/api/frontend-version returns a digest over (name, st_mtime_ns,
st_size) for every served frontend file -- no contents read, since the
question is only whether the files moved. mtime is in there as well as size
because the case this exists for is a hand-edit during iteration, which
often does not change a length. admin._FRONTEND_FILES is pinned against the
directory listing, because a page added to the portal but not to that list
would be invisible to the poll -- the same failure, reopened.

navbar.js is the one file all eight pages load, so the client half lands
there once: capture the digest on load, re-check every 30s and whenever the
tab becomes visible again (the moment that matches the failure's shape), and
on a change show a fixed pill reading "This page is out of date" with a
Reload button. Fixed and hidden until it fires, so it shifts nothing; not a
slot in the navbar cluster, because appearing there would shove the status
dot and the profile switcher sideways, and the navbar scrolls away while the
reason to reload does not.

It never reloads by itself. An operator may be mid-edit in a profile modal
or holding a dirty config row, and discarding that silently costs more than
the staleness does.

The interval is a constant, not a config knob: the "every knob belongs in
config.yaml" rule is about config the router reads, and nothing in the
routing path depends on a browser timer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRQXz5SYZYVWscxS1QqF6U
2026-09-14 00:33:24 -04:00

258 lines
11 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
* <script src="navbar.js"></script>
* 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 { '&': '&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();
});
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();
});
})();