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);