From 40678bc466da325b869c27cd03e7f2c50e20a50e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Wed, 30 Sep 2026 22:58:05 +0200 Subject: [PATCH] feat(settings): show the running build's version and commit in the admin panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in the UI said which build was loaded. /api/version has reported version, build and source_commit since the harness started versioning itself apart from the public semver, but the only way to read it was to curl the endpoint — so "is the preview actually running the commit I just merged?" took a terminal to answer. Pins a footer under the settings sidebar nav showing the registered version (plus the harness build when it differs) and the short source commit, with the full hash on hover. It sits outside the nav's scroll container so it stays at the bottom-left, and it is .admin-only, so syncAdminVisibility() hides it from non-admins the same way it hides the Admin nav group. The commit resolves at import via `git rev-parse HEAD` and is the string "unknown" when that fails — a read-only Docker tree with no .git. The footer treats "unknown" as absent and stays hidden when nothing is left to show, rather than printing it. The collapsed rail and the two narrow tab-rail layouts hide it too: neither has a bottom-left to write in. --- .../css/cookbook-research-memory-settings.css | 41 +++- static/index.html | 9 + static/js/settings/sidebar.js | 78 ++++++ .../test_settings_sidebar_build_provenance.py | 232 ++++++++++++++++++ 4 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 tests/test_settings_sidebar_build_provenance.py diff --git a/static/css/cookbook-research-memory-settings.css b/static/css/cookbook-research-memory-settings.css index 67f425dae..9f6df2679 100644 --- a/static/css/cookbook-research-memory-settings.css +++ b/static/css/cookbook-research-memory-settings.css @@ -1171,6 +1171,38 @@ details.hwfit-serve-advanced label:has(.hwfit-sf[data-field="vllm_kv_cache_dtype overflow-y: auto; overflow-x: hidden; } +/* Build provenance at the bottom of the sidebar. The nav above it takes the + free space, so this needs no absolute positioning to sit at the bottom. + `[hidden]` needs restating: an author `display` rule outranks the UA + stylesheet's [hidden] rule, so the attribute alone would not hide it. */ +.settings-sidebar-build { + display: flex; + min-width: 0; + flex: 0 0 auto; + flex-direction: column; + gap: 1px; + margin-top: 8px; + padding: 4px 12px 2px; + border-top: 1px solid var(--border); + font-size: 9px; + color: color-mix(in srgb, var(--fg) 45%, transparent); +} +.settings-sidebar-build[hidden] { + display: none; +} +/* Same treatment as the "Admin" group label this sits under. */ +.settings-sidebar-build-version { + font-weight: 600; + letter-spacing: 1px; + text-transform: uppercase; +} +.settings-sidebar-build-commit { + overflow: hidden; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.75; +} .settings-nav-search-wrap { position: relative; margin: 0 0 8px; @@ -1326,6 +1358,9 @@ body.settings-sidebar-resize-active { .settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content { display: none; } +.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-build { + display: none; +} .settings-sidebar.settings-sidebar-collapsed .settings-sidebar-resize-handle { display: none; } @@ -1356,7 +1391,8 @@ body.settings-sidebar-resize-active { .settings-nav-search-wrap, .settings-sidebar-resize-handle, - .settings-sidebar-toggle { + .settings-sidebar-toggle, + .settings-sidebar-build { display: none; } @@ -1384,7 +1420,8 @@ body.settings-sidebar-resize-active { .settings-nav-search-wrap, .settings-sidebar-resize-handle, - .settings-sidebar-toggle { + .settings-sidebar-toggle, + .settings-sidebar-build { display: none; } } diff --git a/static/index.html b/static/index.html index dcae7008b..9e537d027 100644 --- a/static/index.html +++ b/static/index.html @@ -1532,6 +1532,15 @@ System + + +
diff --git a/static/js/settings/sidebar.js b/static/js/settings/sidebar.js index 7e1a34aeb..f4787a40f 100644 --- a/static/js/settings/sidebar.js +++ b/static/js/settings/sidebar.js @@ -121,10 +121,88 @@ export function setSettingsSidebarWidth(modalEl, width, options = {}) { return next; } +/* ── Build provenance ── + The bottom-left of the panel answers "which build am I actually looking + at": the version this build registers and the commit its process loaded. + Both come from /api/version, which resolves the commit once at import via + `git rev-parse HEAD` and reports the string "unknown" when it cannot — a + read-only Docker tree with no .git, for instance. */ +const BUILD_COMMIT_SHORT_LEN = 8; +const BUILD_UNKNOWN = 'unknown'; +const BUILD_COMMIT_SHA = /^[0-9a-f]{7,64}$/i; + +function buildField(value) { + const text = String(value ?? '').trim(); + return text && text.toLowerCase() !== BUILD_UNKNOWN ? text : ''; +} + +/** Turn an /api/version payload into the two sidebar lines, or null when it + * carries nothing worth a footer. */ +export function formatSettingsBuildInfo(payload) { + const version = buildField(payload?.version); + const build = buildField(payload?.build); + const commit = buildField(payload?.source_commit); + + const parts = []; + if (version) parts.push(`v${version}`); + // The preview harness versions itself separately from the public semver, so + // show both — but never the same number twice. + if (build && build !== version) parts.push(`build ${build}`); + + // An ODYSSEUS_SOURCE_COMMIT override need not be a sha; leave those intact + // and let the CSS ellipsis deal with the width. + const commitLabel = BUILD_COMMIT_SHA.test(commit) + ? commit.slice(0, BUILD_COMMIT_SHORT_LEN).toLowerCase() + : commit; + + if (!parts.length && !commitLabel) return null; + + return { + versionLabel: parts.join(' \u00b7 '), + commitLabel, + commitTitle: commit, + }; +} + +function paintSettingsBuildInfo(modalEl, info) { + const host = modalEl?.querySelector('#settings-sidebar-build'); + if (!host) return; + + const versionEl = host.querySelector('#settings-sidebar-build-version'); + const commitEl = host.querySelector('#settings-sidebar-build-commit'); + + if (!info) { + host.hidden = true; + return; + } + + if (versionEl) versionEl.textContent = info.versionLabel; + if (commitEl) { + commitEl.textContent = info.commitLabel; + // The short hash is what fits; keep the full one reachable on hover. + if (info.commitTitle) commitEl.title = info.commitTitle; + } + + host.hidden = false; +} + +async function loadSettingsBuildInfo(modalEl) { + try { + const res = await fetch('/api/version', { credentials: 'same-origin' }); + if (!res.ok) return; + paintSettingsBuildInfo(modalEl, formatSettingsBuildInfo(await res.json())); + } catch { + // Provenance is informational: a failed probe leaves the footer hidden + // rather than putting an error in the middle of the settings nav. + } +} + export function bindSettingsSidebar(modalEl) { if (!modalEl || _bound.has(modalEl)) return; _bound.add(modalEl); + loadSettingsBuildInfo(modalEl); + const sidebar = modalEl.querySelector('.settings-sidebar'); const handle = modalEl.querySelector('#settings-sidebar-resize-handle'); const toggle = modalEl.querySelector('#settings-sidebar-toggle'); diff --git a/tests/test_settings_sidebar_build_provenance.py b/tests/test_settings_sidebar_build_provenance.py new file mode 100644 index 000000000..c360545c6 --- /dev/null +++ b/tests/test_settings_sidebar_build_provenance.py @@ -0,0 +1,232 @@ +"""The settings sidebar footer that names the running build. + +The admin panel had no way to answer "which build is this?" from the UI: the +version and the source commit were only reachable by hand-querying +``/api/version``. A footer pinned under the sidebar nav now shows the version +the build registers plus the commit its process loaded. + +Three things can break independently, so each is covered on its own: + +* ``formatSettingsBuildInfo`` decides what the two lines say, including what + to do with the ``"unknown"`` the backend reports when it cannot resolve a + commit. That is real logic, so it is exercised in node against the shipped + module rather than asserted on as text. +* the footer's *position* is the requirement — below the scrolling nav, inside + the sidebar. Only the markup's structure records that. +* the layouts that have no bottom-left to write in (the collapsed rail, the + narrow tab rail) must hide it, and the ``[hidden]`` attribute only works + here because the CSS restates it against the element's own ``display`` rule. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8") +SIDEBAR_CSS = ( + ROOT / "static" / "css" / "cookbook-research-memory-settings.css" +).read_text(encoding="utf-8") + +requires_node = pytest.mark.skipif( + not shutil.which("node"), reason="node binary not on PATH" +) + + +def _format_build_info(payload): + """Run the shipped formatter over one payload and return its result.""" + source = ( + "import { formatSettingsBuildInfo } from " + "'./static/js/settings/sidebar.js';\n" + f"console.log(JSON.stringify(formatSettingsBuildInfo({json.dumps(payload)})));\n" + ) + result = subprocess.run( + ["node", "--input-type=module", "-e", source], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ) + return json.loads(result.stdout) + + +# --------------------------------------------------------------------------- +# What the footer says +# --------------------------------------------------------------------------- + + +@requires_node +def test_shows_registered_version_and_short_commit(): + info = _format_build_info( + { + "version": "1.0.3", + "build": "0.20.19", + "source_commit": "8a28c5f9c0ffee1234567890abcdef1234567890", + } + ) + + assert info["versionLabel"] == "v1.0.3 · build 0.20.19" + assert info["commitLabel"] == "8a28c5f9" + # The short hash is what fits the sidebar; the full one stays on hover. + assert info["commitTitle"] == "8a28c5f9c0ffee1234567890abcdef1234567890" + + +@requires_node +def test_build_equal_to_version_is_not_printed_twice(): + info = _format_build_info( + {"version": "1.0.3", "build": "1.0.3", "source_commit": "abcdef1234"} + ) + + assert info["versionLabel"] == "v1.0.3" + + +@requires_node +def test_unresolvable_commit_leaves_only_the_version(): + """`/api/version` reports the string "unknown" when `git rev-parse` fails + — a read-only Docker tree with no `.git`. Printing it would be worse than + printing nothing.""" + info = _format_build_info( + {"version": "1.0.3", "build": "unknown", "source_commit": "unknown"} + ) + + assert info["versionLabel"] == "v1.0.3" + assert info["commitLabel"] == "" + assert info["commitTitle"] == "" + + +@requires_node +def test_commit_only_build_still_gets_a_footer(): + info = _format_build_info({"source_commit": "0123456789abcdef"}) + + assert info["versionLabel"] == "" + assert info["commitLabel"] == "01234567" + + +@requires_node +def test_non_sha_commit_override_is_left_intact(): + """`ODYSSEUS_SOURCE_COMMIT` can be any string; truncating a tag to eight + characters would corrupt it, so only real hashes get shortened.""" + info = _format_build_info( + {"version": "1.0.3", "source_commit": "release-2026-09-30"} + ) + + assert info["commitLabel"] == "release-2026-09-30" + + +@requires_node +@pytest.mark.parametrize( + "payload", + [ + {}, + {"version": "", "build": "", "source_commit": ""}, + {"version": "unknown", "build": "unknown", "source_commit": "unknown"}, + ], +) +def test_nothing_to_report_means_no_footer(payload): + assert _format_build_info(payload) is None + + +# --------------------------------------------------------------------------- +# Where the footer sits +# --------------------------------------------------------------------------- + + +def _settings_sidebar_markup(): + start = INDEX_HTML.index('
') + end = INDEX_HTML.index('
', start) + return INDEX_HTML[start:end] + + +def test_footer_is_the_last_thing_in_the_sidebar(): + """The ask is bottom-left. The nav above it takes the free space, so the + footer lands at the bottom by being the sidebar's final child — and it has + to sit outside the nav's own scroll container to stay pinned there.""" + sidebar = _settings_sidebar_markup() + + nav_start = sidebar.index('
') + footer_start = sidebar.index('id="settings-sidebar-build"') + assert nav_start < footer_start, "footer must come after the nav" + + # Nothing but the footer between the nav's closing tag and the sidebar's. + tail = sidebar[footer_start:] + assert '