diff --git a/cmd/server/first_seen_1166_test.go b/cmd/server/first_seen_1166_test.go
new file mode 100644
index 00000000..f056c127
--- /dev/null
+++ b/cmd/server/first_seen_1166_test.go
@@ -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)
+ }
+}
diff --git a/public/nodes.js b/public/nodes.js
index 3e2363a7..69784fa9 100644
--- a/public/nodes.js
+++ b/public/nodes.js
@@ -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 @@
Role |
Scope |
Last Seen |
+ First Seen |
Adverts |
@@ -1313,7 +1325,7 @@
if (!tbody) return;
if (!nodes.length) {
- tbody.innerHTML = '| No nodes found |
';
+ tbody.innerHTML = '| No nodes found |
';
return;
}
@@ -1347,6 +1359,7 @@
${n.role} |
${n.default_scope ? escapeHtml(n.default_scope) : ''} |
${renderNodeTimestampHtml(n.last_heard || n.last_seen)} |
+ ${n.first_seen ? renderNodeTimestampHtml(n.first_seen) : '—'} |
${n.advert_count || 0} |
`;
}).join('');
diff --git a/test-issue-1166-first-seen-column.js b/test-issue-1166-first-seen-column.js
new file mode 100644
index 00000000..067f4d93
--- /dev/null
+++ b/test-issue-1166-first-seen-column.js
@@ -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 with text "First Seen" (case-insensitive)
+ assert.ok(/ | ]*>\s*First Seen\s*<\/th>/i.test(src),
+ 'expected | First Seen | 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 ');
+});
+
+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);
|