mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-14 07:26:07 +00:00
## Summary Adds **Firmware** and **Client** columns to the observers table (`#/observers`). Both values already come back from `/api/observers` (`firmware`, `client_version`) — they were just never rendered. Fleet operators have been asking to sort/scan firmware versions to coordinate upgrades. Closes #1789. ## Changes - `public/observers.js` - Two new `<th data-priority="4" data-sort-key="...">` headers (Firmware, Client). Priority 4 matches Clock Offset / Uptime so `TableResponsive` hides them first on narrow viewports. - Two new `<td class="mono">` cells with `data-value="${escapeHtml(raw)}"` for sort and the rendered text escape-wrapped. - `truncateBuildSuffix()` helper trims the long `" Build: ..."` tail from firmware in the displayed text; the full string is preserved in `title=` for hover. - `test-issue-1789-observer-firmware-cols.js` — TDD red→green static-source regression test (same pattern as `test-observers-headings.js`). - `test-observers-headings.js` — updated expected heading list with the two new columns (existing #1039 invariant test). - `test-all.sh` — wires the new test into CI. ## TDD evidence - Red: `c6c4e594c1084d664730666ba069871ac6d9755c` — test commit fails on assertions (not import errors): 5/6 cases fail because the headers/cells/title attr don't yet exist; the column-count invariant still passes because both thead and tbody are unmodified. - Green: `02a0246a185950c903828ac1225d6795f5a3b2f4` — implementation; all 6 cases pass. ## Browser verified To be verified post-deploy on staging (`http://analyzer-stg.00id.net/#/observers`). No backend changes — purely additive frontend render of fields that are already on the wire. ## Perf No new API calls, no extra fetches, two extra template-literal cells per observer row (~10s of observers in prod). O(n) render unchanged. --------- Co-authored-by: clawbot <bot@meshcore.local>
This commit is contained in:
+13
-1
@@ -320,6 +320,16 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo
|
||||
return timeAgo(o.last_packet_at);
|
||||
}
|
||||
|
||||
// #1789 — Firmware strings can be long, e.g.
|
||||
// "v1.16.0-07a3ca9 Build: 2025-..."; truncate the "Build:" suffix for the
|
||||
// display string, keeping the full value in the cell's title= attr.
|
||||
function truncateBuildSuffix(s) {
|
||||
if (!s) return '';
|
||||
const idx = s.indexOf('Build:');
|
||||
if (idx < 0) return s;
|
||||
return s.slice(0, idx).replace(/[\s,;-]+$/, '');
|
||||
}
|
||||
|
||||
function uptimeStr(firstSeen) {
|
||||
if (!firstSeen) return '—';
|
||||
const ms = Date.now() - new Date(firstSeen).getTime();
|
||||
@@ -374,7 +384,7 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo
|
||||
<caption class="sr-only">Observer status and statistics</caption>
|
||||
<thead><tr>
|
||||
<th scope="col" data-priority="1" data-sort-key="status" data-type="numeric">Status</th><th scope="col" data-priority="1" data-sort-key="name">Name</th><th scope="col" data-priority="3" data-sort-key="region">Region</th><th scope="col" data-priority="2" data-sort-key="last_seen" data-type="numeric">Last Status</th><th scope="col" data-priority="2" data-sort-key="last_packet_at" data-type="numeric">Last Packet</th>
|
||||
<th scope="col" data-priority="3" data-sort-key="packet_health" data-type="numeric">Packet Health</th><th scope="col" data-priority="4" data-sort-key="packet_count" data-type="numeric">Total Packets</th><th scope="col" data-priority="3" data-sort-key="packets_hour" data-type="numeric">Packets/Hour</th><th scope="col" data-priority="4" data-sort-key="clock_offset" data-type="numeric">Clock Offset</th><th scope="col" data-priority="4" data-sort-key="uptime" data-type="numeric">Uptime</th>
|
||||
<th scope="col" data-priority="3" data-sort-key="packet_health" data-type="numeric">Packet Health</th><th scope="col" data-priority="4" data-sort-key="packet_count" data-type="numeric">Total Packets</th><th scope="col" data-priority="3" data-sort-key="packets_hour" data-type="numeric">Packets/Hour</th><th scope="col" data-priority="4" data-sort-key="clock_offset" data-type="numeric">Clock Offset</th><th scope="col" data-priority="4" data-sort-key="uptime" data-type="numeric">Uptime</th><th scope="col" data-priority="4" data-sort-key="firmware">Firmware</th><th scope="col" data-priority="4" data-sort-key="client_version">Client</th>
|
||||
<th scope="col" data-priority="1" class="col-compare-select" style="width:32px"><span class="sr-only">Select for compare</span></th>
|
||||
</tr></thead>
|
||||
<tbody>${filtered.map(o => {
|
||||
@@ -416,6 +426,8 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo
|
||||
return renderSkewBadge(sev, sk.offsetSec) + ' <span class="text-muted" title="Computed from ' + sk.samples + ' multi-observer packets. Positive = observer ahead of consensus.">(' + sk.samples + ')</span>';
|
||||
})()}</td>
|
||||
<td data-value="${_uptimeMs}">${uptimeStr(o.first_seen)}</td>
|
||||
<td data-testid="obs-cell-firmware" data-value="${escapeHtml(String(o.firmware || ''))}" class="mono"${o.firmware ? ` title="${escapeHtml(String(o.firmware))}"` : ''}>${o.firmware ? escapeHtml(truncateBuildSuffix(String(o.firmware))) : '<span class="text-muted">—</span>'}</td>
|
||||
<td data-testid="obs-cell-client-version" data-value="${escapeHtml(String(o.client_version || ''))}" class="mono"${o.client_version ? ` title="${escapeHtml(String(o.client_version))}"` : ''}>${o.client_version ? escapeHtml(String(o.client_version)) : '<span class="text-muted">—</span>'}</td>
|
||||
<td class="col-compare-select" onclick="event.stopPropagation()" style="text-align:center">
|
||||
<input type="checkbox" data-compare-select value="${escapeHtml(o.id)}"
|
||||
aria-label="Select ${escapeHtml(o.name || o.id)} for comparison"
|
||||
|
||||
@@ -28,6 +28,7 @@ node test-channel-issue-1087.js
|
||||
node test-issue-1409-no-encrypted-flood.js
|
||||
node test-analytics-channels-integration.js
|
||||
node test-observers-headings.js
|
||||
node test-issue-1789-observer-firmware-cols.js
|
||||
node test-issue-1648-m1-emoji-scan.js
|
||||
node test-issue-1648-m2-emoji-scan.js
|
||||
node test-issue-1648-m3-emoji-scan.js
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/* test-issue-1789-observer-firmware-cols.js — Issue #1789 regression test.
|
||||
*
|
||||
* Asserts the observers table renders Firmware and Client (client_version)
|
||||
* columns from data already on the wire from /api/observers. Both headers
|
||||
* must carry data-priority="4" (matches Clock Offset/Uptime so
|
||||
* TableResponsive hides them first on mobile) and data-sort-key, and both
|
||||
* cells must carry a data-value (raw string for sorting) and class="mono".
|
||||
*
|
||||
* For the firmware cell, a long "... Build: ..." suffix on the displayed
|
||||
* text must be truncated, with the full string preserved in a title=
|
||||
* attribute.
|
||||
*
|
||||
* Static-source test (like test-observers-headings.js) — no server needed.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'observers.js'), 'utf8');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(` \u2713 ${name}`); }
|
||||
catch (e) { failed++; console.log(` \u2717 ${name}\n ${e.message}`); }
|
||||
}
|
||||
|
||||
function extractBlock(s, openRe, closeRe) {
|
||||
const m = s.match(openRe);
|
||||
if (!m) throw new Error('open marker not found');
|
||||
const start = m.index + m[0].length;
|
||||
const rest = s.slice(start);
|
||||
const cm = rest.match(closeRe);
|
||||
if (!cm) throw new Error('close marker not found');
|
||||
return rest.slice(0, cm.index);
|
||||
}
|
||||
|
||||
console.log('\u2500\u2500 Observers firmware/client columns (#1789) \u2500\u2500');
|
||||
|
||||
test('thead contains Firmware header with data-priority=4 and data-sort-key=firmware', () => {
|
||||
const thead = extractBlock(src, /<thead><tr>/, /<\/tr><\/thead>/);
|
||||
assert.ok(
|
||||
/<th[^>]*data-priority="4"[^>]*data-sort-key="firmware"[^>]*>\s*Firmware\s*<\/th>/.test(thead) ||
|
||||
/<th[^>]*data-sort-key="firmware"[^>]*data-priority="4"[^>]*>\s*Firmware\s*<\/th>/.test(thead),
|
||||
'Firmware <th> with data-priority="4" and data-sort-key="firmware" not found in thead'
|
||||
);
|
||||
});
|
||||
|
||||
test('thead contains Client header with data-priority=4 and data-sort-key=client_version', () => {
|
||||
const thead = extractBlock(src, /<thead><tr>/, /<\/tr><\/thead>/);
|
||||
assert.ok(
|
||||
/<th[^>]*data-priority="4"[^>]*data-sort-key="client_version"[^>]*>\s*Client\s*<\/th>/.test(thead) ||
|
||||
/<th[^>]*data-sort-key="client_version"[^>]*data-priority="4"[^>]*>\s*Client\s*<\/th>/.test(thead),
|
||||
'Client <th> with data-priority="4" and data-sort-key="client_version" not found in thead'
|
||||
);
|
||||
});
|
||||
|
||||
test('tbody row template contains a firmware <td> with data-value and class="mono"', () => {
|
||||
const tbodyStart = src.indexOf('<tbody>');
|
||||
assert.ok(tbodyStart > 0, '<tbody> not found in observers.js');
|
||||
const after = src.slice(tbodyStart);
|
||||
const trOpen = after.search(/`<tr\b/);
|
||||
const rowEnd = after.indexOf('</tr>', trOpen);
|
||||
const row = after.slice(trOpen, rowEnd);
|
||||
assert.ok(
|
||||
/<td[^>]*class="mono"[^>]*data-value="\$\{[^}]*o\.firmware[^}]*\}"|<td[^>]*data-value="\$\{[^}]*o\.firmware[^}]*\}"[^>]*class="mono"/.test(row),
|
||||
'firmware <td> with data-value reading o.firmware and class="mono" not found in row template'
|
||||
);
|
||||
});
|
||||
|
||||
test('tbody row template contains a client_version <td> with data-value and class="mono"', () => {
|
||||
const tbodyStart = src.indexOf('<tbody>');
|
||||
const after = src.slice(tbodyStart);
|
||||
const trOpen = after.search(/`<tr\b/);
|
||||
const rowEnd = after.indexOf('</tr>', trOpen);
|
||||
const row = after.slice(trOpen, rowEnd);
|
||||
assert.ok(
|
||||
/<td[^>]*class="mono"[^>]*data-value="\$\{[^}]*o\.client_version[^}]*\}"|<td[^>]*data-value="\$\{[^}]*o\.client_version[^}]*\}"[^>]*class="mono"/.test(row),
|
||||
'client_version <td> with data-value reading o.client_version and class="mono" not found in row template'
|
||||
);
|
||||
});
|
||||
|
||||
test('firmware display truncates "Build:" suffix and preserves full string in title=', () => {
|
||||
// Look for any helper or inline expression that strips Build:... when
|
||||
// rendering, AND a title= attribute carrying the unmodified firmware string.
|
||||
assert.ok(
|
||||
/Build:/.test(src),
|
||||
'firmware truncation marker "Build:" not referenced anywhere in observers.js'
|
||||
);
|
||||
assert.ok(
|
||||
/title="\$\{[^}]*escapeHtml\([^)]*o\.firmware[^)]*\)[^}]*\}"/.test(src) ||
|
||||
/title="\$\{[^}]*o\.firmware[^}]*\}"/.test(src),
|
||||
'firmware cell does not carry a title= with full o.firmware string'
|
||||
);
|
||||
});
|
||||
|
||||
test('thead+tbody column counts stay balanced after adding new columns', () => {
|
||||
const thead = extractBlock(src, /<thead><tr>/, /<\/tr><\/thead>/);
|
||||
const thCount = (thead.match(/<th\b/g) || []).length;
|
||||
const tbodyStart = src.indexOf('<tbody>');
|
||||
const after = src.slice(tbodyStart);
|
||||
const trOpen = after.search(/`<tr\b/);
|
||||
const rowEnd = after.indexOf('</tr>', trOpen);
|
||||
const row = after.slice(trOpen, rowEnd);
|
||||
const tdCount = (row.match(/<td\b/g) || []).length;
|
||||
assert.strictEqual(tdCount, thCount,
|
||||
`Observer table column mismatch: ${thCount} <th> vs ${tdCount} <td>`);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -58,7 +58,8 @@ test('expected headings present and ordered', () => {
|
||||
let m;
|
||||
while ((m = re.exec(thead)) !== null) labels.push(m[1].trim());
|
||||
const expected = ['Status', 'Name', 'Region', 'Last Status', 'Last Packet',
|
||||
'Packet Health', 'Total Packets', 'Packets/Hour', 'Clock Offset', 'Uptime'];
|
||||
'Packet Health', 'Total Packets', 'Packets/Hour', 'Clock Offset', 'Uptime',
|
||||
'Firmware', 'Client'];
|
||||
assert.deepStrictEqual(labels, expected,
|
||||
`Headings out of sync.\nGot: ${JSON.stringify(labels)}\nExpected: ${JSON.stringify(expected)}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user