From 114b6eea1f1a68cdcd61045eb1a558359ec9271e Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Mon, 30 Mar 2026 23:20:35 -0700 Subject: [PATCH] Show build age next to commit hash in UI (#311) ## Summary - show relative build age next to the commit hash in the nav stats version badge (e.g. `abc1234 (3h ago)`) - use `stats.buildTime` from `/api/stats` and existing `timeAgo()` formatting in `public/app.js` - keep behavior unchanged when `buildTime` is missing/unknown ## What changed - updated `formatVersionBadge()` signature to accept `buildTime` - appended a `build-age` span after the commit link when `buildTime` is valid - passed `stats.buildTime` from `updateNavStats()` - updated frontend helper tests for the new function signature - added regression tests for build-age rendering/skip behavior - bumped cache busters in `public/index.html` ## API check - verified Go server already exposes `buildTime` on `/api/stats` and `/api/health` via `cmd/server/routes.go` - no backend API changes required ## Tests - `node test-frontend-helpers.js` - `node test-packet-filter.js` - `node test-aging.js` All passed locally. ## Browser validation - Not run in this environment (no browser session available). Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/server/routes_test.go | 6 +++++ public/app.js | 11 +++++--- public/index.html | 56 +++++++++++++++++++-------------------- test-frontend-helpers.js | 54 ++++++++++++++++++++++++++++--------- 4 files changed, 83 insertions(+), 44 deletions(-) diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index 7b8a474a..02f1a899 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -121,6 +121,9 @@ func TestHealthEndpoint(t *testing.T) { if _, ok := body["commit"]; !ok { t.Error("expected commit field in health response") } + if bt, ok := body["buildTime"]; !ok || bt == nil { + t.Error("expected non-nil buildTime field in health response") + } // Verify memory has spec-defined fields (no heapMB or goRuntime per api-spec.md) mem, ok := body["memory"].(map[string]interface{}) @@ -201,6 +204,9 @@ func TestStatsEndpoint(t *testing.T) { if _, ok := body["commit"]; !ok { t.Error("expected commit field in stats response") } + if bt, ok := body["buildTime"]; !ok || bt == nil { + t.Error("expected non-nil buildTime field in stats response") + } } func TestPacketsEndpoint(t *testing.T) { diff --git a/public/app.js b/public/app.js index c7515348..b2804392 100644 --- a/public/app.js +++ b/public/app.js @@ -210,8 +210,13 @@ function formatEngineBadge(engine) { return ` ${engine}`; } -function formatVersionBadge(version, commit, engine) { +function formatVersionBadge(version, commit, engine, buildTime) { if (!version && !commit && !engine) return ''; + var buildAge = ''; + if (buildTime && buildTime !== 'unknown') { + var age = timeAgo(buildTime); + if (age && age !== '—') buildAge = ' (' + age + ')'; + } var port = (typeof location !== 'undefined' && location.port) || ''; var isProd = !port || port === '80' || port === '443'; var GH = 'https://github.com/Kpa-clawbot/corescope'; @@ -222,7 +227,7 @@ function formatVersionBadge(version, commit, engine) { } if (commit && commit !== 'unknown') { var short = commit.length > 7 ? commit.slice(0, 7) : commit; - parts.push('' + short + ''); + parts.push('' + short + '' + buildAge); } if (engine) parts.push('' + engine + ''); if (parts.length === 0) return ''; @@ -698,7 +703,7 @@ window.addEventListener('DOMContentLoaded', () => { const stats = await api('/stats', { ttl: CLIENT_TTL.stats }); const el = document.getElementById('navStats'); if (el) { - el.innerHTML = `${stats.totalPackets} pkts · ${stats.totalNodes} nodes · ${stats.totalObservers} obs${formatVersionBadge(stats.version, stats.commit, stats.engine)}`; + el.innerHTML = `${stats.totalPackets} pkts · ${stats.totalNodes} nodes · ${stats.totalObservers} obs${formatVersionBadge(stats.version, stats.commit, stats.engine, stats.buildTime)}`; el.querySelectorAll('.stat-val').forEach(s => s.classList.add('updated')); setTimeout(() => { el.querySelectorAll('.stat-val').forEach(s => s.classList.remove('updated')); }, 600); } diff --git a/public/index.html b/public/index.html index fb55251f..2ede25e9 100644 --- a/public/index.html +++ b/public/index.html @@ -22,9 +22,9 @@ - - - + + + @@ -81,31 +81,31 @@
- - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index cd071748..cbafae1d 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -1388,7 +1388,7 @@ console.log('\n=== app.js: formatVersionBadge ==='); // --- Prod tests (no port / port 80 / port 443) --- test('prod: shows version + commit + engine with links', () => { const { formatVersionBadge } = makeBadgeSandbox(''); - const result = formatVersionBadge('2.6.0', 'abc1234def5678', 'node'); + const result = formatVersionBadge('2.6.0', 'abc1234def5678', 'node', null); assert.ok(result.includes('version-badge'), 'should have version-badge class'); assert.ok(result.includes(`href="${GH}/releases/tag/v2.6.0"`), 'version links to release'); assert.ok(result.includes('>v2.6.0'), 'version text has v prefix'); @@ -1398,17 +1398,17 @@ console.log('\n=== app.js: formatVersionBadge ==='); }); test('prod port 80: shows version', () => { const { formatVersionBadge } = makeBadgeSandbox('80'); - const result = formatVersionBadge('2.6.0', null, 'node'); + const result = formatVersionBadge('2.6.0', null, 'node', null); assert.ok(result.includes('>v2.6.0'), 'port 80 is prod — shows version'); }); test('prod port 443: shows version', () => { const { formatVersionBadge } = makeBadgeSandbox('443'); - const result = formatVersionBadge('2.6.0', null, 'node'); + const result = formatVersionBadge('2.6.0', null, 'node', null); assert.ok(result.includes('>v2.6.0'), 'port 443 is prod — shows version'); }); test('prod: version already has v prefix', () => { const { formatVersionBadge } = makeBadgeSandbox(''); - const result = formatVersionBadge('v2.6.0', null, null); + const result = formatVersionBadge('v2.6.0', null, null, null); assert.ok(result.includes('>v2.6.0'), 'should not double the v prefix'); assert.ok(!result.includes('vv'), 'should not have vv'); }); @@ -1416,7 +1416,7 @@ console.log('\n=== app.js: formatVersionBadge ==='); // --- Staging tests (non-standard port) --- test('staging: hides version, shows commit + engine', () => { const { formatVersionBadge } = makeBadgeSandbox('3000'); - const result = formatVersionBadge('2.6.0', 'abc1234def5678', 'go'); + const result = formatVersionBadge('2.6.0', 'abc1234def5678', 'go', null); assert.ok(!result.includes('v2.6.0'), 'staging should NOT show version'); assert.ok(result.includes('>abc1234'), 'should show commit hash'); assert.ok(result.includes(`href="${GH}/commit/abc1234def5678"`), 'commit is linked'); @@ -1424,7 +1424,7 @@ console.log('\n=== app.js: formatVersionBadge ==='); }); test('staging port 81: hides version', () => { const { formatVersionBadge } = makeBadgeSandbox('81'); - const result = formatVersionBadge('2.6.0', 'abc1234', 'go'); + const result = formatVersionBadge('2.6.0', 'abc1234', 'go', null); assert.ok(!result.includes('v2.6.0'), 'port 81 is staging — no version'); assert.ok(result.includes('>abc1234'), 'commit shown'); }); @@ -1432,46 +1432,74 @@ console.log('\n=== app.js: formatVersionBadge ==='); // --- Shared behavior --- test('commit link uses full hash', () => { const { formatVersionBadge } = makeBadgeSandbox(''); - const result = formatVersionBadge(null, 'abc1234def567890123456789abcdef012345678', 'node'); + const result = formatVersionBadge(null, 'abc1234def567890123456789abcdef012345678', 'node', null); assert.ok(result.includes(`href="${GH}/commit/abc1234def567890123456789abcdef012345678"`), 'link uses full hash'); assert.ok(result.includes('>abc1234'), 'display is truncated to 7'); }); test('skips commit when "unknown"', () => { const { formatVersionBadge } = makeBadgeSandbox(''); - const result = formatVersionBadge('2.6.0', 'unknown', 'node'); + const result = formatVersionBadge('2.6.0', 'unknown', 'node', null); assert.ok(result.includes('>v2.6.0'), 'should show version'); assert.ok(!result.includes('unknown'), 'should not show unknown commit'); assert.ok(result.includes('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>node<'), 'should show engine name'); }); test('skips commit when missing', () => { const { formatVersionBadge } = makeBadgeSandbox(''); - const result = formatVersionBadge('2.6.0', null, 'go'); + const result = formatVersionBadge('2.6.0', null, 'go', null); assert.ok(result.includes('>v2.6.0'), 'should show version'); assert.ok(result.includes('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>go<'), 'should show engine name'); }); test('shows only engine when version/commit missing', () => { const { formatVersionBadge } = makeBadgeSandbox('3000'); - const result = formatVersionBadge(null, null, 'go'); + const result = formatVersionBadge(null, null, 'go', null); assert.ok(result.includes('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>go<'), 'should show engine name'); assert.ok(result.includes('version-badge'), 'should use version-badge class'); }); test('short commit not truncated in display', () => { const { formatVersionBadge } = makeBadgeSandbox(''); - const result = formatVersionBadge('1.0.0', 'abc1234', 'node'); + const result = formatVersionBadge('1.0.0', 'abc1234', 'node', null); assert.ok(result.includes('>abc1234'), 'should show full short commit'); }); test('version only on prod', () => { const { formatVersionBadge } = makeBadgeSandbox(''); - const result = formatVersionBadge('2.6.0', null, null); + const result = formatVersionBadge('2.6.0', null, null, null); assert.ok(result.includes('>v2.6.0'), 'should show version'); assert.ok(!result.includes('·'), 'should not have separator for single part'); }); test('staging: only engine when no commit', () => { const { formatVersionBadge } = makeBadgeSandbox('8080'); - const result = formatVersionBadge('2.6.0', null, 'go'); + const result = formatVersionBadge('2.6.0', null, 'go', null); assert.ok(!result.includes('2.6.0'), 'no version on staging'); assert.ok(result.includes('engine-badge'), 'engine badge shown'); assert.ok(result.includes('>go<'), 'engine name shown'); }); + test('shows build age next to commit when buildTime is valid', () => { + const { formatVersionBadge } = makeBadgeSandbox(''); + const recent = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); + const result = formatVersionBadge('2.6.0', 'abc1234def5678', 'go', recent); + assert.ok(result.includes('>abc1234'), 'commit shown'); + assert.ok(result.includes('build-age'), 'build age span shown'); + assert.ok(result.includes('(3h ago)'), 'build age text shown'); + }); + test('does not show build age for unknown buildTime', () => { + const { formatVersionBadge } = makeBadgeSandbox(''); + const result = formatVersionBadge('2.6.0', 'abc1234def5678', 'go', 'unknown'); + assert.ok(!result.includes('build-age'), 'no build age for unknown buildTime'); + }); + test('does not show build age for null buildTime', () => { + const { formatVersionBadge } = makeBadgeSandbox(''); + const result = formatVersionBadge('2.6.0', 'abc1234def5678', 'go', null); + assert.ok(!result.includes('build-age'), 'no build age for null buildTime'); + }); + test('does not show build age for undefined buildTime', () => { + const { formatVersionBadge } = makeBadgeSandbox(''); + const result = formatVersionBadge('2.6.0', 'abc1234def5678', 'go'); + assert.ok(!result.includes('build-age'), 'no build age for undefined buildTime'); + }); + test('does not show build age for invalid buildTime', () => { + const { formatVersionBadge } = makeBadgeSandbox(''); + const result = formatVersionBadge('2.6.0', 'abc1234def5678', 'go', 'not-a-date'); + assert.ok(!result.includes('build-age'), 'no build age for invalid buildTime'); + }); } // ===== CSS: version-badge link contrast (issue #139) =====