feat(nodes): sortable First Seen column on Nodes table (#1166) (#1587)

## Summary

Adds a sortable **First Seen** column to the Nodes table so users can
spot newly observed repeaters in their region (per the reporter's use
case).

Closes #1166

## Backend

`/api/nodes` already exposes `first_seen` per node via `db.scanNodeRow`
(sourced from the existing `nodes.first_seen` column — no schema
migration, no recomputation, no extra query cost). The red test pins
that contract.

## Frontend (`public/nodes.js`)

- New `<th data-sort-key="first_seen" data-sort-default="desc">First
Seen</th>` between Last Seen and Adverts.
- Cell renders via `renderNodeTimestampHtml(n.first_seen)` — same
relative-time + absolute-ISO `title=` tooltip as the Last Seen column.
Empty values render as `—`.
- `sortNodes` gains a `first_seen` branch with **empty-last** semantics:
nodes without a `first_seen` always sort to the bottom regardless of
asc/desc direction, so unknowns never clutter the top of the table.
- Empty-state `colspan` bumped 7 → 8.

## TDD

- **Red commit** `112442f4` — `test-issue-1166-first-seen-column.js` +
`cmd/server/first_seen_1166_test.go`. The backend half passes on red
(field already returned); 5 frontend assertions fail on assertions
(column header missing, sort branch missing, empty-last violated).
- **Green commit** `9274b36c` — only `public/nodes.js`. All 6 tests
pass.

Verified red is real-fail (assertion-shaped) by checking out the red
commit's `nodes.js` and re-running the test: 5 failures, all on
`assert.strictEqual`, none on parse/import.

## Test results

```
node test-issue-1166-first-seen-column.js  → 6 passed, 0 failed
node test-frontend-helpers.js              → 611 passed, 0 failed
go test ./cmd/server/...                   → ok (45.16s, all pass)
```

## Files changed

- `public/nodes.js` (+14 / −1)
- `test-issue-1166-first-seen-column.js` (new)
- `cmd/server/first_seen_1166_test.go` (new)

## Scope guardrails

- No schema migration.
- No new files outside the worktree's three allowed surfaces.
- No refactor of other Nodes columns.
- Empty cells handled in both render (em-dash) and sort (always last).

---------

Co-authored-by: fix-1166-bot <bot@corescope.local>
This commit is contained in:
Kpa-clawbot
2026-06-04 16:27:48 -07:00
committed by GitHub
parent a529b5feab
commit 7533b3b67b
3 changed files with 241 additions and 1 deletions
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"encoding/json"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/mux"
)
// TestFirstSeen_1166_HandleNodesSurface pins issue #1166: the /api/nodes
// response carries a `first_seen` ISO timestamp per node so the frontend
// can show a sortable "First Seen" column.
func TestFirstSeen_1166_HandleNodesSurface(t *testing.T) {
db := setupCapabilityTestDB(t)
defer db.conn.Close()
if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil {
t.Fatal(err)
}
pk := "cccc000000000000000000000000000000000000000000000000000000000000"
first := time.Now().Add(-72 * time.Hour).UTC().Format("2006-01-02T15:04:05.000Z")
last := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
if _, err := db.conn.Exec(`INSERT INTO nodes
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES (?, 'rpt', 'repeater', 37.5, -122.0, ?, ?, 5)`,
pk, last, first); err != nil {
t.Fatal(err)
}
store := NewPacketStore(db, nil)
cfg := &Config{Port: 3000}
hub := NewHub()
srv := NewServer(db, cfg, hub)
srv.store = store
router := mux.NewRouter()
srv.RegisterRoutes(router)
req := httptest.NewRequest("GET", "/api/nodes?limit=10", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != 200 {
t.Fatalf("/api/nodes status: want 200, got %d body=%s", rr.Code, rr.Body.String())
}
var resp struct {
Nodes []map[string]interface{} `json:"nodes"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v body=%s", err, rr.Body.String())
}
var got map[string]interface{}
for _, n := range resp.Nodes {
if k, _ := n["public_key"].(string); k == pk {
got = n
break
}
}
if got == nil {
t.Fatalf("node missing from /api/nodes response")
}
fs, hasFS := got["first_seen"]
if !hasFS {
t.Fatalf("first_seen absent from /api/nodes response (issue #1166)")
}
s, _ := fs.(string)
if s == "" {
t.Errorf("first_seen empty, want ISO timestamp, got %v", fs)
}
if s != first {
t.Errorf("first_seen = %q, want %q", s, first)
}
}
+14 -1
View File
@@ -65,6 +65,17 @@
} else if (col === 'advert_count') {
va = a.advert_count || 0; vb = b.advert_count || 0;
return (va - vb) * dir;
} else if (col === 'first_seen') {
// Issue #1166: sort by node first_seen. Empty cells always sort
// LAST (regardless of direction) so unknown timestamps don't
// clutter the top of the table.
var aHas = a.first_seen ? 1 : 0;
var bHas = b.first_seen ? 1 : 0;
if (aHas !== bHas) return bHas - aHas; // dated rows before empties
if (!aHas) return 0;
va = new Date(a.first_seen).getTime();
vb = new Date(b.first_seen).getTime();
return (va - vb) * dir;
}
return 0;
});
@@ -1214,6 +1225,7 @@
<th scope="col" data-sort-key="role" data-priority="2">Role</th>
<th scope="col" data-sort-key="default_scope" data-priority="3">Scope</th>
<th scope="col" data-sort-key="last_seen" data-sort-default="desc" data-priority="1">Last Seen</th>
<th scope="col" data-sort-key="first_seen" data-sort-default="desc" data-priority="3">First Seen</th>
<th scope="col" data-sort-key="advert_count" data-sort-default="desc" data-priority="2">Adverts</th>
</tr></thead>
<tbody id="nodesBody"></tbody>
@@ -1313,7 +1325,7 @@
if (!tbody) return;
if (!nodes.length) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted" style="padding:24px">No nodes found</td></tr>';
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-muted" style="padding:24px">No nodes found</td></tr>';
return;
}
@@ -1347,6 +1359,7 @@
<td><span class="badge" style="background:${roleColor}20;color:${roleColor}">${n.role}</span></td>
<td style="font-family:var(--mono);font-size:12px">${n.default_scope ? escapeHtml(n.default_scope) : ''}</td>
<td class="${lastSeenClass}">${renderNodeTimestampHtml(n.last_heard || n.last_seen)}</td>
<td>${n.first_seen ? renderNodeTimestampHtml(n.first_seen) : '<span class="text-muted">—</span>'}</td>
<td>${n.advert_count || 0}</td>
</tr>`;
}).join('');
+152
View File
@@ -0,0 +1,152 @@
/* Regression test for issue #1166: "First Seen" column on the Nodes table.
*
* Asserts both surface (column header + sort key in the table markup)
* AND sort behavior (sortNodes handles 'first_seen' column with empty-last
* semantics).
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(' ✅ ' + name); }
catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); }
}
console.log('\n=== issue #1166: First Seen column ===');
// --- 1. Source-level assertions on public/nodes.js ---
const src = fs.readFileSync(__dirname + '/public/nodes.js', 'utf8');
test('nodes table header includes a "First Seen" column', () => {
// Match a <th> with text "First Seen" (case-insensitive)
assert.ok(/<th[^>]*>\s*First Seen\s*<\/th>/i.test(src),
'expected <th>First Seen</th> in public/nodes.js (nodes table header)');
});
test('First Seen header carries data-sort-key="first_seen"', () => {
assert.ok(/data-sort-key="first_seen"/.test(src),
'expected data-sort-key="first_seen" on the First Seen <th>');
});
test('renderRows emits a first_seen cell', () => {
// The render function must reference n.first_seen somewhere so the
// cell renders the value (presence is what we gate on; richer
// formatting is enforced by sortNodes / timestamp helper tests).
assert.ok(/n\.first_seen/.test(src),
'expected renderRows to read n.first_seen for the cell value');
});
// --- 2. sortNodes behavior via VM sandbox (mirrors test-frontend-helpers harness) ---
function makeSandbox() {
const ctx = {
window: { addEventListener: () => {}, dispatchEvent: () => {} },
document: {
readyState: 'complete',
createElement: () => ({ id: '', textContent: '', innerHTML: '' }),
head: { appendChild: () => {} },
getElementById: () => null,
addEventListener: () => {},
querySelectorAll: () => [],
querySelector: () => null,
},
console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp,
Error, TypeError, parseInt, parseFloat, isNaN, isFinite,
encodeURIComponent, decodeURIComponent,
setTimeout: () => {}, clearTimeout: () => {},
setInterval: () => {}, clearInterval: () => {},
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
performance: { now: () => Date.now() },
localStorage: (() => {
const store = {};
return {
getItem: k => store[k] || null,
setItem: (k, v) => { store[k] = String(v); },
removeItem: k => { delete store[k]; },
};
})(),
location: { hash: '' },
CustomEvent: class CustomEvent {},
Map, Set, Promise, URLSearchParams,
addEventListener: () => {}, dispatchEvent: () => {},
requestAnimationFrame: (cb) => setTimeout(cb, 0),
};
ctx.getHashParams = function() { return new URLSearchParams(''); };
ctx.registerPage = () => {};
ctx.RegionFilter = { init: () => {}, getSelected: () => null, onRegionChange: () => {} };
ctx.onWS = () => {};
ctx.offWS = () => {};
ctx.invalidateApiCache = () => {};
ctx.favStar = () => '';
ctx.bindFavStars = () => {};
ctx.getFavorites = () => [];
ctx.isFavorite = () => false;
ctx.connectWS = () => {};
ctx.HopResolver = { init: () => {}, resolve: () => ({}), ready: () => false };
ctx.api = () => Promise.resolve({ nodes: [], counts: {} });
ctx.CLIENT_TTL = { nodeList: 90000, nodeDetail: 240000, nodeHealth: 240000 };
ctx.initTabBar = () => {};
ctx.makeColumnsResizable = () => {};
ctx.debounce = (fn) => fn;
vm.createContext(ctx);
return ctx;
}
function loadInCtx(ctx, file) {
vm.runInContext(fs.readFileSync(file, 'utf8'), ctx);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
}
const ctx = makeSandbox();
loadInCtx(ctx, __dirname + '/public/roles.js');
loadInCtx(ctx, __dirname + '/public/app.js');
loadInCtx(ctx, __dirname + '/public/nodes.js');
const sortNodes = ctx.window._nodesSortNodes;
const setState = ctx.window._nodesSetSortState;
test('sortNodes exposes first_seen as a sort column (desc, newest first)', () => {
setState({ column: 'first_seen', direction: 'desc' });
const now = Date.now();
const arr = [
{ name: 'Old', first_seen: new Date(now - 100000).toISOString() },
{ name: 'Newest', first_seen: new Date(now).toISOString() },
{ name: 'Mid', first_seen: new Date(now - 50000).toISOString() },
];
const r = sortNodes([...arr]);
assert.strictEqual(r[0].name, 'Newest');
assert.strictEqual(r[2].name, 'Old');
});
test('sortNodes by first_seen asc (oldest first)', () => {
setState({ column: 'first_seen', direction: 'asc' });
const now = Date.now();
const arr = [
{ name: 'Newest', first_seen: new Date(now).toISOString() },
{ name: 'Old', first_seen: new Date(now - 100000).toISOString() },
];
const r = sortNodes([...arr]);
assert.strictEqual(r[0].name, 'Old');
assert.strictEqual(r[1].name, 'Newest');
});
test('sortNodes by first_seen puts empty cells LAST regardless of direction', () => {
const now = Date.now();
const arr = [
{ name: 'NoFS' },
{ name: 'WithFS', first_seen: new Date(now).toISOString() },
{ name: 'NullFS', first_seen: null },
];
setState({ column: 'first_seen', direction: 'desc' });
let r = sortNodes([...arr]);
assert.strictEqual(r[0].name, 'WithFS', 'desc: dated node first');
assert.notStrictEqual(r[2].name, 'WithFS', 'desc: empty cells should not sort above dated');
setState({ column: 'first_seen', direction: 'asc' });
r = sortNodes([...arr]);
assert.strictEqual(r[0].name, 'WithFS', 'asc: dated node still first (empty cells last)');
});
console.log('\n' + (failed ? '❌' : '✅') + ' ' + passed + ' passed, ' + failed + ' failed');
if (failed) process.exit(1);