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>
This commit is contained in:
Kpa-clawbot
2026-03-30 23:20:35 -07:00
committed by GitHub
co-authored by Kpa-clawbot Copilot
parent 90db37e07f
commit 114b6eea1f
4 changed files with 83 additions and 44 deletions
+6
View File
@@ -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) {
+8 -3
View File
@@ -210,8 +210,13 @@ function formatEngineBadge(engine) {
return ` <span class="engine-badge">${engine}</span>`;
}
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 = ' <span class="build-age">(' + age + ')</span>';
}
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('<a href="' + GH + '/commit/' + commit + '" target="_blank" rel="noopener">' + short + '</a>');
parts.push('<a href="' + GH + '/commit/' + commit + '" target="_blank" rel="noopener">' + short + '</a>' + buildAge);
}
if (engine) parts.push('<span class="engine-badge">' + engine + '</span>');
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 = `<span class="stat-val">${stats.totalPackets}</span> pkts · <span class="stat-val">${stats.totalNodes}</span> nodes · <span class="stat-val">${stats.totalObservers}</span> obs${formatVersionBadge(stats.version, stats.commit, stats.engine)}`;
el.innerHTML = `<span class="stat-val">${stats.totalPackets}</span> pkts · <span class="stat-val">${stats.totalNodes}</span> nodes · <span class="stat-val">${stats.totalObservers}</span> 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);
}
+28 -28
View File
@@ -22,9 +22,9 @@
<meta name="twitter:title" content="CoreScope">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1774926567">
<link rel="stylesheet" href="home.css?v=1774926567">
<link rel="stylesheet" href="live.css?v=1774926567">
<link rel="stylesheet" href="style.css?v=1774928028">
<link rel="stylesheet" href="home.css?v=1774928028">
<link rel="stylesheet" href="live.css?v=1774928028">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous">
@@ -81,31 +81,31 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1774926567"></script>
<script src="customize.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774926567"></script>
<script src="hop-resolver.js?v=1774926567"></script>
<script src="hop-display.js?v=1774926567"></script>
<script src="app.js?v=1774926567"></script>
<script src="home.js?v=1774926567"></script>
<script src="packet-filter.js?v=1774926567"></script>
<script src="packets.js?v=1774926567"></script>
<script src="geo-filter-overlay.js?v=1774926567"></script>
<script src="map.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774926567" onerror="console.error('Failed to load:', this.src)"></script>
<script src="roles.js?v=1774928028"></script>
<script src="customize.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774928028"></script>
<script src="hop-resolver.js?v=1774928028"></script>
<script src="hop-display.js?v=1774928028"></script>
<script src="app.js?v=1774928028"></script>
<script src="home.js?v=1774928028"></script>
<script src="packet-filter.js?v=1774928028"></script>
<script src="packets.js?v=1774928028"></script>
<script src="geo-filter-overlay.js?v=1774928028"></script>
<script src="map.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774928028" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>
+41 -13
View File
@@ -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</a>'), '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</a>'), '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</a>'), '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</a>'), '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</a>'), '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</a>'), '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</a>'), '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</a>'), '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</a>'), '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</a>'), '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</a>'), '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</a>'), '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) =====