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 '