From bcaa35fd277aba9fcd88c6f0cc80c4c353cdfd94 Mon Sep 17 00:00:00 2001 From: gadgethd <111318106+gadgethd@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:47:50 +0100 Subject: [PATCH] fix(map): remove inferred nodes and restore historic hops --- backend/src/db/index.ts | 101 +++++------------ backend/src/mqtt/client.ts | 10 +- backend/src/nodes/presence.test.ts | 26 +++++ backend/src/nodes/presence.ts | 37 ++++++ backend/src/nodes/publicMap.test.ts | 5 +- backend/src/nodes/publicMap.ts | 6 +- backend/src/repositories/nodes.test.ts | 36 ++++++ backend/src/repositories/nodes.ts | 14 ++- backend/src/repositories/pathEvidence.test.ts | 94 +++++++++++++++ backend/src/repositories/pathEvidence.ts | 107 ++++++++++++++++++ backend/src/stats/statsRepository.test.ts | 8 +- backend/src/stats/statsRepository.ts | 5 +- frontend/src/App.tsx | 64 +---------- frontend/src/components/Map/MapLibreMap.tsx | 17 +-- frontend/src/components/Map/NodeLegend.tsx | 1 - .../src/components/Map/geojsonBuilders.ts | 22 +--- frontend/src/components/Map/mapConfig.ts | 4 - .../components/Map/mapSourceLayers.test.ts | 3 +- .../src/components/Map/mapSourceLayers.ts | 2 - frontend/src/components/Map/types.ts | 3 - frontend/src/config/features.ts | 4 - frontend/src/config/runtimeFeatures.test.ts | 9 +- frontend/src/config/runtimeFeatures.ts | 7 +- frontend/src/hooks/useNodes.test.ts | 69 +++++------ frontend/src/hooks/useNodes.ts | 7 -- frontend/src/styles/globals.css | 2 - frontend/src/styles/map-app.css | 11 -- frontend/src/vite-env.d.ts | 1 - 28 files changed, 403 insertions(+), 272 deletions(-) create mode 100644 backend/src/nodes/presence.test.ts create mode 100644 backend/src/nodes/presence.ts create mode 100644 backend/src/repositories/nodes.test.ts create mode 100644 backend/src/repositories/pathEvidence.test.ts create mode 100644 backend/src/repositories/pathEvidence.ts diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index c81c1ba..3555b63 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -19,6 +19,14 @@ import { dbQueryDuration, updateDbPoolMetrics, } from '../metrics.js'; +import { + nodeEffectiveLastSeenSql, + nodeEffectiveOnlineSql, +} from '../nodes/presence.js'; +import { + reactivateHistoricPathNodes, + type HistoricPathNode, +} from '../repositories/pathEvidence.js'; const { Pool } = pg; const COORDINATE_RECALC_THRESHOLD_M = Number(process.env['NODE_COORDINATE_RECALC_THRESHOLD_M'] ?? 25); @@ -370,10 +378,18 @@ export async function refreshRecentPathEvidence( ), node_hashes AS ( SELECT 2 AS path_hash_size_bytes, UPPER(LEFT(node_id, 4)) AS hash, node_id - FROM nodes WHERE (role = 2 OR role IS NULL) AND ${pathEvidenceNodeScope} + FROM nodes + WHERE (role = 2 OR role IS NULL) AND ${pathEvidenceNodeScope} + AND lat BETWEEN -90 AND 90 + AND lon BETWEEN -180 AND 180 + AND NOT (ABS(lat) < 1e-9 AND ABS(lon) < 1e-9) UNION ALL SELECT 3 AS path_hash_size_bytes, UPPER(LEFT(node_id, 6)) AS hash, node_id - FROM nodes WHERE (role = 2 OR role IS NULL) AND ${pathEvidenceNodeScope} + FROM nodes + WHERE (role = 2 OR role IS NULL) AND ${pathEvidenceNodeScope} + AND lat BETWEEN -90 AND 90 + AND lon BETWEEN -180 AND 180 + AND NOT (ABS(lat) < 1e-9 AND ABS(lon) < 1e-9) ), unique_node_hashes AS ( SELECT path_hash_size_bytes, hash, MIN(node_id) AS node_id @@ -409,8 +425,8 @@ export async function refreshRecentPathEvidence( /** * Real-time counterpart to {@link refreshRecentPathEvidence}: given the multibyte * path hashes of a single freshly-ingested packet, credit each repeater whose - * prefix uniquely matches with `last_path_evidence_at = seenAt`. Returns the node - * IDs that were updated so the caller can broadcast a live "seen now" update. + * prefix uniquely matches with `last_path_evidence_at = seenAt`. Returns the + * preserved historic node rows so the caller can broadcast their coordinates. * * Only 2- and 3-byte hashes are accepted (single-byte is too collision-prone), and * prefixes shared by more than one repeater are skipped (ambiguous). Best-effort: @@ -422,52 +438,14 @@ export async function recordMultibyteEvidence( seenAt: Date, routeType?: number, network?: string, -): Promise { - // On Direct/TransportDirect packets the path is a future route, not an - // observed relay path. Crediting it would invent node presence. - if ((routeType !== 0 && routeType !== 1) || (sizeBytes !== 2 && sizeBytes !== 3)) return []; - const prefixLen = sizeBytes * 2; // hex chars: 2 bytes → 4, 3 bytes → 6 - const hashes = Array.from(new Set( - pathHashes - .map((h) => String(h).trim().toUpperCase()) - .filter((h) => h.length === prefixLen && /^[0-9A-F]+$/.test(h)), - )); - if (hashes.length === 0) return []; - const pathEvidenceNodeScope = network === 'test' - ? "n.network = 'test'" - : "n.network IS DISTINCT FROM 'test'"; - // prefixLen is a server-controlled integer (4 or 6); inlining it lets Postgres - // use the upper(left(node_id,N)) functional indexes (idx_nodes_path_hash_2/3). - const res = await pool.query<{ node_id: string }>( - `WITH input(hash) AS ( - SELECT UNNEST($1::text[]) - ), - candidates AS ( - SELECT n.node_id, i.hash - FROM nodes n - JOIN input i ON UPPER(LEFT(n.node_id, ${prefixLen})) = i.hash - WHERE (n.role = 2 OR n.role IS NULL) AND ${pathEvidenceNodeScope} - ), - unique_hashes AS ( - SELECT hash FROM candidates GROUP BY hash HAVING COUNT(*) = 1 - ), - matched AS ( - SELECT c.node_id - FROM candidates c - JOIN unique_hashes u ON u.hash = c.hash - ), - updated AS ( - UPDATE nodes n - SET last_path_evidence_at = $2::timestamptz - FROM matched m - WHERE n.node_id = m.node_id - AND (n.last_path_evidence_at IS NULL OR n.last_path_evidence_at < $2::timestamptz) - RETURNING n.node_id - ) - SELECT node_id FROM updated`, - [hashes, seenAt.toISOString()], - ); - return res.rows.map((r) => r.node_id); +): Promise { + return reactivateHistoricPathNodes(query, { + pathHashes, + sizeBytes, + seenAt, + routeType, + network, + }); } export async function upsertNode(nodeId: string, updates: { @@ -726,30 +704,13 @@ export async function getNodes( -- multibyte relay evidence are independent proofs of presence. Always -- expose the newest proof; preferring observer metadata with COALESCE -- could make a freshly advertised node appear days older on the map. - GREATEST( - n.last_seen, - n.last_rx_at, - n.last_status_at, - n.last_path_evidence_at - ) AS last_seen, - COALESCE( - CASE - WHEN GREATEST(n.last_rx_at, n.last_status_at) IS NOT NULL - THEN GREATEST(n.last_rx_at, n.last_status_at) > NOW() - INTERVAL '15 minutes' - ELSE NULL - END, - CASE - WHEN n.last_path_evidence_at IS NOT NULL - AND n.last_path_evidence_at > NOW() - INTERVAL '60 minutes' - THEN TRUE - ELSE n.is_online - END - ) AS is_online, + ${nodeEffectiveLastSeenSql('n')} AS last_seen, + ${nodeEffectiveOnlineSql('n')} AS is_online, n.advert_count ${optionalFields} FROM nodes n ${whereClause} - ORDER BY GREATEST(n.last_seen, n.last_rx_at, n.last_status_at, n.last_path_evidence_at) DESC`, + ORDER BY ${nodeEffectiveLastSeenSql('n')} DESC`, scope.params ); return res.rows; diff --git a/backend/src/mqtt/client.ts b/backend/src/mqtt/client.ts index 0d0ceeb..89dcc5a 100644 --- a/backend/src/mqtt/client.ts +++ b/backend/src/mqtt/client.ts @@ -954,19 +954,19 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise { invalidateResolveCache(finalHash); // A repeater appearing in a multibyte (2–3 byte) path hash almost certainly - // relayed this packet, so treat it as proof it is online right now: refresh its - // last_path_evidence_at and broadcast a live "seen now" update. Non-MQTT - // repeaters (no direct reception) only get refreshed this way. Best-effort. + // relayed this packet. Resolve only a unique historic row with stored + // coordinates, then broadcast the full row so clients which no longer hold + // the stale node can restore it at its original location. Best-effort. if (decodedPathHashSizeBytes != null && decodedPathHashSizeBytes >= 2 && path && path.length > 0) { try { - const nodeIds = await recordMultibyteEvidence( + const historicNodes = await recordMultibyteEvidence( path, decodedPathHashSizeBytes, new Date(), decodedRouteType, network, ); - for (const nodeId of nodeIds) emitNode(nodeId, { network }); + for (const node of historicNodes) emitNodeUpsert({ ...node }); } catch (err) { console.error('[mqtt] recordMultibyteEvidence error:', (err as Error).message); } diff --git a/backend/src/nodes/presence.test.ts b/backend/src/nodes/presence.test.ts new file mode 100644 index 0000000..904bd42 --- /dev/null +++ b/backend/src/nodes/presence.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + nodeEffectiveLastSeenSql, + nodeEffectiveOnlineSql, +} from './presence.js'; + +test('effective node presence gives fresh path evidence online precedence', () => { + const lastSeenSql = nodeEffectiveLastSeenSql('node'); + const onlineSql = nodeEffectiveOnlineSql('node', '$4::timestamptz'); + + assert.match(lastSeenSql, /node\.last_seen/); + assert.match(lastSeenSql, /node\.last_rx_at/); + assert.match(lastSeenSql, /node\.last_status_at/); + assert.match(lastSeenSql, /node\.last_path_evidence_at/); + assert.match( + onlineSql, + /node\.last_path_evidence_at > \$4::timestamptz - INTERVAL '60 minutes'[\s\S]*THEN TRUE/, + ); + assert.ok( + onlineSql.indexOf('last_path_evidence_at') < onlineSql.indexOf('last_rx_at'), + 'fresh path evidence must be evaluated before older direct observations', + ); + assert.match(onlineSql, /ELSE node\.is_online/); + assert.throws(() => nodeEffectiveOnlineSql('node; DROP TABLE nodes')); +}); diff --git a/backend/src/nodes/presence.ts b/backend/src/nodes/presence.ts new file mode 100644 index 0000000..33b3ab4 --- /dev/null +++ b/backend/src/nodes/presence.ts @@ -0,0 +1,37 @@ +function safeAlias(alias: string): string { + if (!/^[a-z][a-z0-9_]*$/i.test(alias)) { + throw new Error(`invalid SQL alias: ${alias}`); + } + return alias; +} + +/** Newest direct or path-derived proof that a node was present. */ +export function nodeEffectiveLastSeenSql(alias = 'n'): string { + const table = safeAlias(alias); + return `GREATEST( + ${table}.last_seen, + ${table}.last_rx_at, + ${table}.last_status_at, + ${table}.last_path_evidence_at + )`; +} + +/** + * Path evidence is an independent online signal. It must take precedence over + * an older direct-RX/status timestamp rather than being hidden by it. + */ +export function nodeEffectiveOnlineSql( + alias = 'n', + referenceSql = 'NOW()', +): string { + const table = safeAlias(alias); + return `CASE + WHEN ${table}.last_path_evidence_at IS NOT NULL + AND ${table}.last_path_evidence_at > ${referenceSql} - INTERVAL '60 minutes' + THEN TRUE + WHEN GREATEST(${table}.last_rx_at, ${table}.last_status_at) IS NOT NULL + THEN GREATEST(${table}.last_rx_at, ${table}.last_status_at) + > ${referenceSql} - INTERVAL '15 minutes' + ELSE ${table}.is_online + END`; +} diff --git a/backend/src/nodes/publicMap.test.ts b/backend/src/nodes/publicMap.test.ts index d6c5c27..f2ad6b8 100644 --- a/backend/src/nodes/publicMap.test.ts +++ b/backend/src/nodes/publicMap.test.ts @@ -26,7 +26,10 @@ test('public map predicate is the single role, coordinate, privacy, and freshnes assert.match(sql, /NOT \(ABS\(n\.lat\) < 5 AND ABS\(n\.lon\) < 5\)/); assert.match(sql, /n\.name NOT LIKE '%🚫%'/); assert.match(sql, /n\.role IS NULL OR n\.role NOT IN \(1, 3\)/); - assert.match(sql, /GREATEST\(n\.last_seen, n\.last_path_evidence_at\)/); + assert.match(sql, /n\.last_seen/); + assert.match(sql, /n\.last_rx_at/); + assert.match(sql, /n\.last_status_at/); + assert.match(sql, /n\.last_path_evidence_at/); assert.match(sql, /> \$2::timestamptz - INTERVAL '28 days'/); assert.match(sql, /<= \$2::timestamptz/); }); diff --git a/backend/src/nodes/publicMap.ts b/backend/src/nodes/publicMap.ts index d25e55d..3923333 100644 --- a/backend/src/nodes/publicMap.ts +++ b/backend/src/nodes/publicMap.ts @@ -1,3 +1,5 @@ +import { nodeEffectiveLastSeenSql } from './presence.js'; + export const PUBLIC_MAP_ALLOWED_FIELDS = [ 'node_id', 'name', @@ -47,9 +49,9 @@ export function publicMapFreshPredicate( const table = safeAlias(alias); return `( ${publicMapBasePredicate(table)} - AND GREATEST(${table}.last_seen, ${table}.last_path_evidence_at) + AND ${nodeEffectiveLastSeenSql(table)} > ${referenceSql} - INTERVAL '28 days' - AND GREATEST(${table}.last_seen, ${table}.last_path_evidence_at) + AND ${nodeEffectiveLastSeenSql(table)} <= ${referenceSql} )`; } diff --git a/backend/src/repositories/nodes.test.ts b/backend/src/repositories/nodes.test.ts new file mode 100644 index 0000000..268a082 --- /dev/null +++ b/backend/src/repositories/nodes.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { networkFilters } from '../api/utils/networkFilters.js'; +import { createNodeRepository } from './nodes.js'; + +test('public map projects path evidence as the effective seen and online state', async () => { + let capturedSql = ''; + let capturedParams: unknown[] | undefined; + const repository = createNodeRepository(async (sql, params) => { + capturedSql = sql; + capturedParams = params; + return { rows: [] }; + }); + const snapshot = '2026-08-03T12:00:00.000Z'; + + await repository.listPublicMapRows( + ['node_id', 'last_seen', 'is_online'], + networkFilters('ukmesh'), + snapshot, + null, + 100, + ); + + assert.match(capturedSql, /GREATEST\([\s\S]*n\.last_path_evidence_at[\s\S]*\)::text AS last_seen/); + assert.match( + capturedSql, + /n\.last_path_evidence_at > \$2::timestamptz - INTERVAL '60 minutes'[\s\S]*THEN TRUE[\s\S]*AS is_online/, + ); + assert.doesNotMatch(capturedSql, /n\.last_seen::text AS last_seen/); + assert.deepEqual(capturedParams, [ + ['ukmesh', 'northeast', 'teesside'], + snapshot, + null, + 101, + ]); +}); diff --git a/backend/src/repositories/nodes.ts b/backend/src/repositories/nodes.ts index e279ceb..f5b964d 100644 --- a/backend/src/repositories/nodes.ts +++ b/backend/src/repositories/nodes.ts @@ -1,6 +1,10 @@ import type { QueryResultRow } from 'pg'; import type { NetworkFilters } from '../api/utils/networkFilters.js'; import { publicMapFreshPredicate } from '../nodes/publicMap.js'; +import { + nodeEffectiveLastSeenSql, + nodeEffectiveOnlineSql, +} from '../nodes/presence.js'; type QueryFn = ( text: string, @@ -182,7 +186,15 @@ export function createNodeRepository(query: QueryFn): NodeRepository { const cursorParameter = snapshotParameter + 1; const limitParameter = cursorParameter + 1; const selectedFields = fields - .map((field) => field === 'last_seen' ? 'n.last_seen::text AS last_seen' : `n.${field}`) + .map((field) => { + if (field === 'last_seen') { + return `${nodeEffectiveLastSeenSql('n')}::text AS last_seen`; + } + if (field === 'is_online') { + return `${nodeEffectiveOnlineSql('n', `$${snapshotParameter}::timestamptz`)} AS is_online`; + } + return `n.${field}`; + }) .join(', '); const result = await query>( `SELECT ${selectedFields} diff --git a/backend/src/repositories/pathEvidence.test.ts b/backend/src/repositories/pathEvidence.test.ts new file mode 100644 index 0000000..a4513d2 --- /dev/null +++ b/backend/src/repositories/pathEvidence.test.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + reactivateHistoricPathNodes, + type HistoricPathNode, + type QueryFn, +} from './pathEvidence.js'; + +const NODE_ID = `ABCDEF${'1'.repeat(58)}`; +const SEEN_AT = new Date('2026-08-03T12:34:56.000Z'); + +test('historic path activation resolves only unique coordinate-bearing relays', async () => { + let capturedSql = ''; + let capturedParams: unknown[] | undefined; + const expected: HistoricPathNode = { + node_id: NODE_ID, + name: 'Historic relay', + lat: 54.5, + lon: -1.2, + iata: 'MME', + role: 2, + last_seen: SEEN_AT.toISOString(), + is_online: true, + hardware_model: null, + public_key: NODE_ID, + advert_count: 4, + elevation_m: 120, + network: 'ukmesh', + }; + const query: QueryFn = async (sql, params) => { + capturedSql = sql; + capturedParams = params; + return { rows: [expected] }; + }; + + const rows = await reactivateHistoricPathNodes(query, { + pathHashes: ['abcdef', 'ABCDEF', 'not-hex', '1234'], + sizeBytes: 3, + seenAt: SEEN_AT, + routeType: 1, + network: 'ukmesh', + }); + + assert.deepEqual(rows, [expected]); + assert.deepEqual(capturedParams, [['ABCDEF'], SEEN_AT.toISOString()]); + assert.match(capturedSql, /UPPER\(LEFT\(n\.node_id, 6\)\) = i\.hash/); + assert.match(capturedSql, /n\.network IS DISTINCT FROM 'test'/); + assert.match(capturedSql, /n\.lat BETWEEN -90 AND 90/); + assert.match(capturedSql, /n\.lon BETWEEN -180 AND 180/); + assert.match(capturedSql, /NOT \(ABS\(n\.lat\) < 1e-9 AND ABS\(n\.lon\) < 1e-9\)/); + assert.match(capturedSql, /HAVING COUNT\(\*\) = 1/); + assert.match(capturedSql, /SET last_path_evidence_at = \$2::timestamptz/); + assert.match(capturedSql, /n\.lat,[\s\S]*n\.lon/); + assert.match(capturedSql, /TRUE AS is_online/); +}); + +test('historic path activation rejects future routes, short hashes, and missing evidence', async () => { + let calls = 0; + const query: QueryFn = async () => { + calls += 1; + return { rows: [] }; + }; + + assert.deepEqual(await reactivateHistoricPathNodes(query, { + pathHashes: ['ABCDEF'], sizeBytes: 3, seenAt: SEEN_AT, routeType: 2, + }), []); + assert.deepEqual(await reactivateHistoricPathNodes(query, { + pathHashes: ['AB'], sizeBytes: 1, seenAt: SEEN_AT, routeType: 1, + }), []); + assert.deepEqual(await reactivateHistoricPathNodes(query, { + pathHashes: ['ZZZZ'], sizeBytes: 2, seenAt: SEEN_AT, routeType: 0, + }), []); + assert.equal(calls, 0); +}); + +test('historic test-network activation cannot match public inventory rows', async () => { + let capturedSql = ''; + const query: QueryFn = async (sql) => { + capturedSql = sql; + return { rows: [] }; + }; + + await reactivateHistoricPathNodes(query, { + pathHashes: ['ABCD'], + sizeBytes: 2, + seenAt: SEEN_AT, + routeType: 0, + network: 'test', + }); + + assert.match(capturedSql, /UPPER\(LEFT\(n\.node_id, 4\)\) = i\.hash/); + assert.match(capturedSql, /n\.network = 'test'/); + assert.doesNotMatch(capturedSql, /IS DISTINCT FROM 'test'/); +}); diff --git a/backend/src/repositories/pathEvidence.ts b/backend/src/repositories/pathEvidence.ts new file mode 100644 index 0000000..75a7af9 --- /dev/null +++ b/backend/src/repositories/pathEvidence.ts @@ -0,0 +1,107 @@ +import type { QueryResultRow } from 'pg'; + +export type QueryFn = ( + text: string, + params?: unknown[], +) => Promise<{ rows: T[] }>; + +export type HistoricPathNode = QueryResultRow & { + node_id: string; + name: string | null; + lat: number; + lon: number; + iata: string | null; + role: number | null; + last_seen: string | Date; + is_online: true; + hardware_model: string | null; + public_key: string | null; + advert_count: number | null; + elevation_m: number | null; + network: string | null; +}; + +/** + * Resolve trustworthy multibyte relay hashes against the historic inventory. + * Only a unique, coordinate-bearing repeater can be reactivated; its stored + * coordinates are returned unchanged for the live node upsert. + */ +export async function reactivateHistoricPathNodes( + query: QueryFn, + input: { + pathHashes: string[]; + sizeBytes: number; + seenAt: Date; + routeType?: number; + network?: string; + }, +): Promise { + const { pathHashes, sizeBytes, seenAt, routeType, network } = input; + // Direct routes contain a future route. Only Flood/TransportFlood paths are + // evidence that the listed relay actually handled this packet. + if ((routeType !== 0 && routeType !== 1) || (sizeBytes !== 2 && sizeBytes !== 3)) return []; + + const prefixLength = sizeBytes * 2; + const hashes = Array.from(new Set( + pathHashes + .map((hash) => String(hash).trim().toUpperCase()) + .filter((hash) => hash.length === prefixLength && /^[0-9A-F]+$/.test(hash)), + )); + if (hashes.length === 0) return []; + + const networkScope = network === 'test' + ? "n.network = 'test'" + : "n.network IS DISTINCT FROM 'test'"; + // prefixLength is restricted above to 4 or 6, allowing the matching + // functional indexes to be used without interpolating caller input. + const result = await query( + `WITH input(hash) AS ( + SELECT UNNEST($1::text[]) + ), + candidates AS ( + SELECT n.node_id, i.hash + FROM nodes n + JOIN input i ON UPPER(LEFT(n.node_id, ${prefixLength})) = i.hash + WHERE (n.role = 2 OR n.role IS NULL) + AND ${networkScope} + AND n.lat BETWEEN -90 AND 90 + AND n.lon BETWEEN -180 AND 180 + AND NOT (ABS(n.lat) < 1e-9 AND ABS(n.lon) < 1e-9) + ), + unique_hashes AS ( + SELECT hash + FROM candidates + GROUP BY hash + HAVING COUNT(*) = 1 + ), + matched AS ( + SELECT c.node_id + FROM candidates c + JOIN unique_hashes u ON u.hash = c.hash + ), + updated AS ( + UPDATE nodes n + SET last_path_evidence_at = $2::timestamptz + FROM matched m + WHERE n.node_id = m.node_id + AND (n.last_path_evidence_at IS NULL OR n.last_path_evidence_at < $2::timestamptz) + RETURNING + n.node_id, + n.name, + n.lat, + n.lon, + COALESCE(n.observer_iata, n.iata) AS iata, + n.role, + n.last_path_evidence_at AS last_seen, + TRUE AS is_online, + n.hardware_model, + n.public_key, + n.advert_count, + n.elevation_m, + n.network + ) + SELECT * FROM updated`, + [hashes, seenAt.toISOString()], + ); + return result.rows; +} diff --git a/backend/src/stats/statsRepository.test.ts b/backend/src/stats/statsRepository.test.ts index 04e8fac..9c05852 100644 --- a/backend/src/stats/statsRepository.test.ts +++ b/backend/src/stats/statsRepository.test.ts @@ -168,7 +168,8 @@ test('map summary uses the same coordinate, role, and 14-day freshness rules as call.text.includes("<= NOW() - INTERVAL '14 days'"), )?.text; const mapSql = calls.find((call) => - call.text.includes("GREATEST(nodes.last_seen, nodes.last_path_evidence_at)") + call.text.includes('nodes.last_path_evidence_at') + && call.text.includes('nodes.last_rx_at') && call.text.includes("> NOW() - INTERVAL '28 days'") && !call.text.includes("<= NOW() - INTERVAL '14 days'"), )?.text; @@ -180,7 +181,10 @@ test('map summary uses the same coordinate, role, and 14-day freshness rules as assert.match(sql, /nodes\.lon BETWEEN -180 AND 180/); assert.match(sql, /NOT \(ABS\(nodes\.lat\) < 5 AND ABS\(nodes\.lon\) < 5\)/); assert.match(sql, /\(nodes\.role IS NULL OR nodes\.role NOT IN \(1, 3\)\)/); - assert.match(sql, /GREATEST\(nodes\.last_seen, nodes\.last_path_evidence_at\)/); + assert.match(sql, /nodes\.last_seen/); + assert.match(sql, /nodes\.last_rx_at/); + assert.match(sql, /nodes\.last_status_at/); + assert.match(sql, /nodes\.last_path_evidence_at/); assert.match(sql, /nodes\.name NOT LIKE/); assert.doesNotMatch(sql, /INTERVAL '7 days'/); } diff --git a/backend/src/stats/statsRepository.ts b/backend/src/stats/statsRepository.ts index 8f02bda..d56c8e3 100644 --- a/backend/src/stats/statsRepository.ts +++ b/backend/src/stats/statsRepository.ts @@ -5,6 +5,7 @@ import { publicMapBasePredicate, publicMapFreshPredicate, } from '../nodes/publicMap.js'; +import { nodeEffectiveLastSeenSql } from '../nodes/presence.js'; import { loadStoredChartSnapshot, saveStoredChartSnapshot, @@ -1295,9 +1296,9 @@ export function createStatsRepository(deps: StatsRepositoryDeps) { query(`SELECT COUNT(*) AS count FROM packets WHERE time > NOW() - INTERVAL '24 hours' ${filters.packets}`, filters.params), query(`SELECT COUNT(*) AS count FROM nodes WHERE ${publicMapBasePredicate('nodes')} - AND GREATEST(nodes.last_seen, nodes.last_path_evidence_at) + AND ${nodeEffectiveLastSeenSql('nodes')} <= NOW() - INTERVAL '14 days' - AND GREATEST(nodes.last_seen, nodes.last_path_evidence_at) + AND ${nodeEffectiveLastSeenSql('nodes')} > NOW() - INTERVAL '28 days' ${filters.nodes}`, filters.params), query(`SELECT COUNT(*) AS count FROM nodes diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bce8749..dde0bb3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,14 +11,13 @@ import { MobileControls } from './components/app/MobileControls.js'; import { LoadingIndicator } from './components/LoadingIndicator.js'; import { Dialog, DialogTitle } from './components/ui/Dialog.js'; import { useWebSocket } from './hooks/useWebSocket.js'; -import { nodeStore, type MeshNode } from './hooks/useNodes.js'; +import { nodeStore } from './hooks/useNodes.js'; import { rfNodeCoverageState, useRfCoverage, type RfCoverageTierName } from './hooks/useRfCoverage.js'; import { useDashboardStats, type DashboardStats } from './hooks/useDashboardStats.js'; import { linkStateStore } from './hooks/useLinkState.js'; import { useAppMessageHandler } from './hooks/useAppMessageHandler.js'; import { HEATMAP_CAPABLE, - INFERRED_NODES_CAPABLE, PACKET_ARCS_CAPABLE, RF_COVERAGE_ENABLED, VIEWSHED_ENABLED, @@ -64,7 +63,6 @@ const RF_VISIBILITY_KEY = 'meshcore-rf-coverage-visible-v1'; const RF_TIER_KEY = 'meshcore-rf-coverage-tier-v1'; const MAP_FETCH_TIMEOUT_MS = 4_000; const OTHER_FETCH_TIMEOUT_MS = 15_000; -const EMPTY_NODE_IDS = new Set(); const TimelineControl = React.lazy(() => import('./components/app/TimelineControl.js').then((module) => ({ default: module.TimelineControl }))); const PlannerComparison = React.lazy(() => import('./components/app/PlannerComparison.js').then((module) => ({ default: module.PlannerComparison }))); @@ -72,7 +70,6 @@ export const App: React.FC = () => { const initialLayersSpecifiedRef = useRef(new URLSearchParams(window.location.search).has('layers')); const site = getCurrentSite(); const runtimeFeatures = useRuntimeFeatures(); - const inferredNodesEnabled = INFERRED_NODES_CAPABLE && runtimeFeatures.inferredNodes; const packetArcsEnabled = PACKET_ARCS_CAPABLE && runtimeFeatures.packetArcs; const heatmapEnabled = HEATMAP_CAPABLE && runtimeFeatures.heatmap; const [filters, setFilters] = useState(() => { @@ -122,8 +119,6 @@ export const App: React.FC = () => { // MapLibre map instance — used by MobileControls/NodeSearch for flyTo const [mlMap, setMlMap] = useState(null); const [showDisclaimer, setShowDisclaimer] = useState(() => !localStorage.getItem(DISCLAIMER_KEY)); - const [inferredNodes, setInferredNodes] = useState([]); - const [inferredActiveNodeIds, setInferredActiveNodeIds] = useState>(new Set()); const [packetHistorySegments, setPacketHistorySegments] = useState([]); const [fetchedStats, setFetchedStats] = useState(null); const [initialStateLoaded, setInitialStateLoaded] = useState(false); @@ -177,8 +172,6 @@ export const App: React.FC = () => { useEffect(() => { setFetchedStats(null); - setInferredNodes([]); - setInferredActiveNodeIds(new Set()); setPacketHistorySegments([]); setInitialStateLoaded(false); setInitialPollLoaded(false); @@ -305,13 +298,7 @@ export const App: React.FC = () => { }, []); // Keep the fast poll to live data that changes independently of the socket. - // Expensive inferred/path overlays use their own, slower conditional polls. useEffect(() => { - if (!inferredNodesEnabled) { - setInferredNodes([]); - setInferredActiveNodeIds(new Set()); - return undefined; - } let cancelled = false; let timer: number | null = null; let controller: AbortController | null = null; @@ -377,53 +364,6 @@ export const App: React.FC = () => { }; }, [isPageVisible, networkFilter, observerFilter, scopeState.nodeEpoch]); - useEffect(() => { - let cancelled = false; - let timer: number | null = null; - let controller: AbortController | null = null; - - const scheduleNext = () => { - if (cancelled || !isPageVisible) return; - timer = window.setTimeout(() => { void syncInferredNodes(); }, 60_000); - }; - - const syncInferredNodes = async () => { - if (cancelled || !isPageVisible) return; - controller = new AbortController(); - try { - const response = await fetch( - uncachedEndpoint(withScopeParams('/api/inferred-nodes', { network: networkFilter, observer: observerFilter })), - { - cache: 'no-store', - signal: AbortSignal.any([controller.signal, AbortSignal.timeout(OTHER_FETCH_TIMEOUT_MS)]), - }, - ); - if (!response.ok || cancelled) return; - const payload = await response.json() as { - inferredNodes: MeshNode[]; inferredActiveNodeIds: string[]; - }; - if (!cancelled) { - setInferredNodes(payload.inferredNodes ?? []); - setInferredActiveNodeIds(new Set((payload.inferredActiveNodeIds ?? []).map((value) => value.toLowerCase()))); - } - } catch (err) { - if (!cancelled && (err as DOMException).name !== 'AbortError') { - console.warn('[app] inferred nodes refresh failed'); - } - } finally { - controller = null; - scheduleNext(); - } - }; - - void syncInferredNodes(); - return () => { - cancelled = true; - controller?.abort(); - if (timer) window.clearTimeout(timer); - }; - }, [inferredNodesEnabled, isPageVisible, networkFilter, observerFilter]); - useEffect(() => { if (!filters.packetHistory && !(heatmapEnabled && filters.heatmap)) { setPacketHistorySegments([]); @@ -617,8 +557,6 @@ export const App: React.FC = () => {
{ - inferredNodesRef.current = inferredNodes; - inferredActiveNodeIdsRef.current = inferredActiveNodeIds; - scheduleRefresh({ nodes: true }); - }, [inferredActiveNodeIds, inferredNodes, scheduleRefresh]); - useEffect(() => { showLinksRef.current = showLinks; updatePlannedLinks(); @@ -1000,9 +988,9 @@ export function MapLibreMap({ // -- Popup management ------------------------------------------------------ - // Find the full MeshNode from nodeId (checks nodes and inferredNodes) + // Find the full observed MeshNode from nodeId. const getNode = useCallback((nodeId: string): MeshNode | undefined => { - return nodesRef.current.get(nodeId) ?? inferredNodesRef.current.find((node) => node.node_id === nodeId); + return nodesRef.current.get(nodeId); }, []); // Fetch neighbour links for the selected node's detail panel @@ -1075,7 +1063,6 @@ export function MapLibreMap({ is_stale: ageMs > NODE_STALE_AFTER_MS, is_link_only_stale: false, is_prohibited: isProhibitedMapNode(node), - is_inferred: !!node.is_inferred, replay_active: replayNodeIdsRef.current?.has(node.node_id.toLowerCase()) ?? false, replay_mode: replayNodeIdsRef.current !== null, hex_clash_state: null, diff --git a/frontend/src/components/Map/NodeLegend.tsx b/frontend/src/components/Map/NodeLegend.tsx index fc23bfa..848987b 100644 --- a/frontend/src/components/Map/NodeLegend.tsx +++ b/frontend/src/components/Map/NodeLegend.tsx @@ -22,7 +22,6 @@ export const NodeLegend: React.FC<{ mapLight: boolean }> = ({ mapLight }) => { { label: 'Room server', color: colors.roomServer }, { label: 'Sensor', color: colors.sensor }, { label: 'Offline / stale', color: colors.stale }, - { label: 'Inferred', color: colors.inferred }, { label: 'Selected', color: colors.selected, ring: true }, ]; const linkRows = [ diff --git a/frontend/src/components/Map/geojsonBuilders.ts b/frontend/src/components/Map/geojsonBuilders.ts index 4c6740e..9e6b915 100644 --- a/frontend/src/components/Map/geojsonBuilders.ts +++ b/frontend/src/components/Map/geojsonBuilders.ts @@ -28,8 +28,6 @@ export type MapSourceDirtyFlags = { plannedLinks: boolean; }; -export const MAX_INFERRED_NODE_FEATURES = 2_000; - export const ALL_MAP_SOURCE_DIRTY_FLAGS: MapSourceDirtyFlags = { nodes: true, privacy: true, @@ -88,17 +86,11 @@ export function buildNodeGeoJSON( pathNodeIds: Set | null, replayNodeIds: Set | null = null, staleCutoffMs = Date.now(), - inferredNodes: readonly MeshNode[] = [], - inferredActiveNodeIds: ReadonlySet = new Set(), ): GeoJSON.FeatureCollection { const features: GeoJSON.Feature[] = []; - const activeInferredIds = new Set( - Array.from(inferredActiveNodeIds, (nodeId) => nodeId.trim().toUpperCase()), - ); - const addNode = (node: MeshNode, explicitlyInferred = false) => { + const addNode = (node: MeshNode) => { if (!hasCoords(node)) return; - if (explicitlyInferred && isProhibitedMapNode(node)) return; const ageMs = staleCutoffMs - new Date(node.last_seen).getTime(); const isLinkOnlyStale = ageMs > NODE_HIDE_AFTER_MS && showLinks @@ -140,7 +132,6 @@ export function buildNodeGeoJSON( is_stale: ageMs > NODE_STALE_AFTER_MS, is_link_only_stale: isLinkOnlyStale, is_prohibited: isProhibited, - is_inferred: explicitlyInferred || activeInferredIds.has(node.node_id.trim().toUpperCase()), replay_active: replayNodeIds?.has(node.node_id.toLowerCase()) ?? false, replay_mode: replayNodeIds !== null, hex_clash_state: hexClashState, @@ -160,17 +151,6 @@ export function buildNodeGeoJSON( }; for (const node of nodes.values()) addNode(node); - const existingIds = new Set( - Array.from(nodes.values(), (node) => node.node_id.trim().toUpperCase()), - ); - let addedInferred = 0; - for (const node of inferredNodes) { - if (addedInferred >= MAX_INFERRED_NODE_FEATURES) break; - if (existingIds.has(node.node_id.trim().toUpperCase())) continue; - const before = features.length; - addNode(node, true); - if (features.length > before) addedInferred += 1; - } return { type: 'FeatureCollection', features }; } diff --git a/frontend/src/components/Map/mapConfig.ts b/frontend/src/components/Map/mapConfig.ts index 9b679b5..fb071bc 100644 --- a/frontend/src/components/Map/mapConfig.ts +++ b/frontend/src/components/Map/mapConfig.ts @@ -53,7 +53,6 @@ export const MAP_OVERLAY_COLORS = { companion: '#ff9f43', roomServer: '#a78bfa', sensor: '#34d399', - inferred: '#7dd3fc', replay: '#fbbf24', stale: '#94a3b8', linkOnlyStale: '#94a3b8', @@ -79,14 +78,12 @@ export const MAP_OVERLAY_COLORS = { plannedPending: '#94a3b8', dimmedOpacity: 0.72, staleOpacity: 0.7, - inferredOpacity: 0.85, }, light: { repeater: '#006a8f', companion: '#9a3e00', roomServer: '#6d28d9', sensor: '#087f5b', - inferred: '#0369a1', replay: '#8a5d00', stale: '#4b5563', linkOnlyStale: '#475569', @@ -112,7 +109,6 @@ export const MAP_OVERLAY_COLORS = { plannedPending: '#475569', dimmedOpacity: 0.8, staleOpacity: 0.7, - inferredOpacity: 0.85, }, } as const; diff --git a/frontend/src/components/Map/mapSourceLayers.test.ts b/frontend/src/components/Map/mapSourceLayers.test.ts index 38b89d8..6dfc0eb 100644 --- a/frontend/src/components/Map/mapSourceLayers.test.ts +++ b/frontend/src/components/Map/mapSourceLayers.test.ts @@ -48,11 +48,12 @@ test('map layers avoid observer rings and raster-style glyph dependencies', () = && 'text-field' in layer.layout )), false); assert.equal(layers.some((layer) => layer.id === 'planned-pins-dot'), true); + assert.doesNotMatch(JSON.stringify(layers), /is_inferred/); }); test('map overlay markers and links retain 3:1 contrast in both themes', () => { const nodeColors = [ - 'repeater', 'companion', 'roomServer', 'sensor', 'inferred', 'replay', + 'repeater', 'companion', 'roomServer', 'sensor', 'replay', 'stale', 'linkOnlyStale', 'clashRelay', 'clashOffender', ] as const; const linkColors = ['linkUnknown', 'linkGood', 'linkMarginal', 'linkPoor'] as const; diff --git a/frontend/src/components/Map/mapSourceLayers.ts b/frontend/src/components/Map/mapSourceLayers.ts index ad51d1b..68497ec 100644 --- a/frontend/src/components/Map/mapSourceLayers.ts +++ b/frontend/src/components/Map/mapSourceLayers.ts @@ -10,7 +10,6 @@ function nodeColorExpression(colors: OverlayColors): maplibregl.ExpressionSpecif ['==', ['get', 'hex_clash_state'], 'relay'], colors.clashRelay, ['get', 'replay_active'], colors.replay, ['get', 'is_link_only_stale'], colors.linkOnlyStale, - ['get', 'is_inferred'], colors.inferred, ['get', 'is_stale'], colors.stale, ['!', ['get', 'is_online']], colors.stale, ['==', ['get', 'role'], 1], colors.companion, @@ -27,7 +26,6 @@ function nodeOpacityExpression(colors: OverlayColors): maplibregl.ExpressionSpec ['get', 'is_link_only_stale'], colors.staleOpacity, ['get', 'is_stale'], colors.staleOpacity, ['!', ['get', 'is_online']], colors.staleOpacity, - ['get', 'is_inferred'], colors.inferredOpacity, 1, ]; } diff --git a/frontend/src/components/Map/types.ts b/frontend/src/components/Map/types.ts index fae2073..df88cda 100644 --- a/frontend/src/components/Map/types.ts +++ b/frontend/src/components/Map/types.ts @@ -22,7 +22,6 @@ export interface NodeFeatureProps { is_stale: boolean; is_link_only_stale: boolean; is_prohibited: boolean; - is_inferred: boolean; replay_active: boolean; replay_mode: boolean; hex_clash_state: 'offender' | 'relay' | null; @@ -68,8 +67,6 @@ export interface CustomLosSegment { } export interface MapLibreMapProps { - inferredNodes: import('../../hooks/useNodes.js').MeshNode[]; - inferredActiveNodeIds: Set; showLinks: boolean; showTerrain: boolean; showClientNodes: boolean; diff --git a/frontend/src/config/features.ts b/frontend/src/config/features.ts index 61c5171..5be8c52 100644 --- a/frontend/src/config/features.ts +++ b/frontend/src/config/features.ts @@ -13,10 +13,6 @@ export const RF_COVERAGE_ENABLED = envFlagEnabled( import.meta.env.VITE_RF_COVERAGE_ENABLED, false, ); -export const INFERRED_NODES_CAPABLE = envFlagEnabled( - import.meta.env.VITE_INFERRED_NODES_ENABLED, - true, -); export const PACKET_ARCS_CAPABLE = envFlagEnabled( import.meta.env.VITE_PACKET_ARCS_ENABLED, true, diff --git a/frontend/src/config/runtimeFeatures.test.ts b/frontend/src/config/runtimeFeatures.test.ts index 5a22802..4c13c58 100644 --- a/frontend/src/config/runtimeFeatures.test.ts +++ b/frontend/src/config/runtimeFeatures.test.ts @@ -11,14 +11,12 @@ import { test('runtime feature parser accepts only the bounded versioned DTO', () => { assert.deepEqual(parseRuntimeFeatureConfig({ version: 1, - inferredNodes: true, packetArcs: false, heatmap: true, privacyGeneration: 9, refreshAfterSeconds: 15, ignored: 'safe', }), { - inferredNodes: true, packetArcs: false, heatmap: true, privacyGeneration: 9, @@ -28,9 +26,9 @@ test('runtime feature parser accepts only the bounded versioned DTO', () => { for (const malformed of [ null, {}, - { version: 2, inferredNodes: true, packetArcs: true, heatmap: true, privacyGeneration: 1, refreshAfterSeconds: 30 }, - { version: 1, inferredNodes: 'true', packetArcs: true, heatmap: true, privacyGeneration: 1, refreshAfterSeconds: 30 }, - { version: 1, inferredNodes: true, packetArcs: true, heatmap: true, privacyGeneration: 1, refreshAfterSeconds: 301 }, + { version: 2, packetArcs: true, heatmap: true, privacyGeneration: 1, refreshAfterSeconds: 30 }, + { version: 1, packetArcs: 'true', heatmap: true, privacyGeneration: 1, refreshAfterSeconds: 30 }, + { version: 1, packetArcs: true, heatmap: true, privacyGeneration: 1, refreshAfterSeconds: 301 }, ]) { assert.deepEqual(parseRuntimeFeatureConfig(malformed), FAIL_CLOSED_RUNTIME_FEATURES); } @@ -40,7 +38,6 @@ test('runtime feature refresh fails closed on transport and malformed responses' resetRuntimeFeaturesForTests(); const enabledResponse = new Response(JSON.stringify({ version: 1, - inferredNodes: true, packetArcs: true, heatmap: true, privacyGeneration: 3, diff --git a/frontend/src/config/runtimeFeatures.ts b/frontend/src/config/runtimeFeatures.ts index 70ca7ce..389b9d2 100644 --- a/frontend/src/config/runtimeFeatures.ts +++ b/frontend/src/config/runtimeFeatures.ts @@ -1,7 +1,6 @@ import { useSyncExternalStore } from 'react'; export type RuntimeFeatureSnapshot = { - inferredNodes: boolean; packetArcs: boolean; heatmap: boolean; privacyGeneration: number; @@ -18,7 +17,6 @@ const MAX_REFRESH_SECONDS = 300; const REQUEST_TIMEOUT_MS = 3_000; export const FAIL_CLOSED_RUNTIME_FEATURES: RuntimeFeatureSnapshot = Object.freeze({ - inferredNodes: false, packetArcs: false, heatmap: false, privacyGeneration: 0, @@ -29,8 +27,7 @@ let snapshot: RuntimeFeatureSnapshot = FAIL_CLOSED_RUNTIME_FEATURES; const listeners = new Set<() => void>(); function sameSnapshot(a: RuntimeFeatureSnapshot, b: RuntimeFeatureSnapshot): boolean { - return a.inferredNodes === b.inferredNodes - && a.packetArcs === b.packetArcs + return a.packetArcs === b.packetArcs && a.heatmap === b.heatmap && a.privacyGeneration === b.privacyGeneration && a.refreshAfterSeconds === b.refreshAfterSeconds; @@ -50,7 +47,6 @@ function isRecord(value: unknown): value is Record { export function parseRuntimeFeatureConfig(value: unknown): RuntimeFeatureSnapshot { if (!isRecord(value) || value['version'] !== 1 - || typeof value['inferredNodes'] !== 'boolean' || typeof value['packetArcs'] !== 'boolean' || typeof value['heatmap'] !== 'boolean' || typeof value['privacyGeneration'] !== 'number' @@ -65,7 +61,6 @@ export function parseRuntimeFeatureConfig(value: unknown): RuntimeFeatureSnapsho const parsed = value as RuntimeFeatureResponse; return { - inferredNodes: parsed.inferredNodes, packetArcs: parsed.packetArcs, heatmap: parsed.heatmap, privacyGeneration: parsed.privacyGeneration, diff --git a/frontend/src/hooks/useNodes.test.ts b/frontend/src/hooks/useNodes.test.ts index a313685..9b27a58 100644 --- a/frontend/src/hooks/useNodes.test.ts +++ b/frontend/src/hooks/useNodes.test.ts @@ -115,20 +115,27 @@ test('map marks nodes stale after 14 days and hides ordinary nodes after 28 days assert.equal(byId.get(OTHER_NODE_ID)?.['is_stale'], true); }); -test('inferred fixtures appear, update known-node styling, disappear, and exclude private rows', () => { - const known = meshNode(NODE_ID, 0); - const inferred = meshNode('inferred:2:C3D4', 0, { - name: 'Inferred C3D4', - is_inferred: true, - }); - const privateInferred = meshNode('inferred:2:FFFF', 0, { - name: 'Hidden 🚫', - is_inferred: true, - }); - const nodes = new Map([[known.node_id, known]]); +test('a full historic-node upsert restores its stored location as an observed online node', () => { + const epoch = nodeStore.reset('historic-path-node'); + nodeStore.handleInitialState({ nodes: [], packets: [] }, epoch); + nodeStore.handleNodeUpsert({ + node_id: NODE_ID.toLowerCase(), + name: 'Historic relay', + lat: 54.5, + lon: -1.2, + role: 2, + last_seen: new Date(NOW).toISOString(), + is_online: true, + }, epoch); - const populated = buildNodeGeoJSON( - nodes, + const restored = nodeStore.getState().nodes.get(NODE_ID); + assert.ok(restored); + assert.equal(restored.lat, 54.5); + assert.equal(restored.lon, -1.2); + assert.equal(restored.is_online, true); + + const geojson = buildNodeGeoJSON( + nodeStore.getState().nodes, new Map(), true, false, @@ -139,36 +146,14 @@ test('inferred fixtures appear, update known-node styling, disappear, and exclud null, null, NOW, - [inferred, privateInferred], - new Set([NODE_ID.toLowerCase()]), ); - assert.deepEqual( - populated.features.map((feature) => feature.properties?.['node_id']), - [NODE_ID, inferred.node_id], - ); - assert.equal(populated.features[0]?.properties?.['is_inferred'], true); - assert.equal(populated.features[1]?.properties?.['is_inferred'], true); - - const cleared = buildNodeGeoJSON( - nodes, - new Map(), - true, - false, - new Set(), - new Set(), - new Set(), - false, - null, - null, - NOW, - [], - new Set(), - ); - assert.deepEqual( - cleared.features.map((feature) => feature.properties?.['node_id']), - [NODE_ID], - ); - assert.equal(cleared.features[0]?.properties?.['is_inferred'], false); + assert.equal(geojson.features.length, 1); + assert.deepEqual(geojson.features[0]?.geometry, { + type: 'Point', + coordinates: [-1.2, 54.5], + }); + assert.equal(geojson.features[0]?.properties?.['is_online'], true); + assert.equal('is_inferred' in (geojson.features[0]?.properties ?? {}), false); }); test('one privacy-safe packet creates a bounded arc and expiry removes it', () => { diff --git a/frontend/src/hooks/useNodes.ts b/frontend/src/hooks/useNodes.ts index 109c3a8..cb21387 100644 --- a/frontend/src/hooks/useNodes.ts +++ b/frontend/src/hooks/useNodes.ts @@ -31,13 +31,6 @@ export interface MeshNode { public_key?: string; advert_count?: number; elevation_m?: number; - is_inferred?: boolean; - inferred_prefix?: string; - inferred_hash_size_bytes?: number; - inferred_observations?: number; - inferred_packet_count?: number; - inferred_prev_name?: string | null; - inferred_next_name?: string | null; } export interface LivePacketData { diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 77e9291..f9c9e05 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -55,8 +55,6 @@ --offline: #546e7a; --offline-text: #93a7b2; --danger: #ff1744; - --inferred: #6ddc7a; - /* Device-type colours */ --companion: #ff9800; /* ChatNode (role 1) — orange */ --room-server: #ce93d8; /* RoomServer (role 3) — lavender */ diff --git a/frontend/src/styles/map-app.css b/frontend/src/styles/map-app.css index e4d51e5..06d6981 100644 --- a/frontend/src/styles/map-app.css +++ b/frontend/src/styles/map-app.css @@ -902,17 +902,6 @@ } .node-marker--room .node-marker__pulse { background: var(--room-server); } -.node-marker--inferred .node-marker__core { - background: rgba(109, 220, 122, 0.18); - border-color: rgba(109, 220, 122, 0.92); - box-shadow: 0 0 0 1px rgba(109, 220, 122, 0.28), 0 0 14px rgba(109, 220, 122, 0.18); -} - -.node-marker--inferred .node-marker__pulse { - background: rgba(109, 220, 122, 0.7); - opacity: 0.32; -} - .node-marker--restore .node-marker__core, .node-marker--restore .node-marker__pulse { animation: node-restore-fade 1.2s ease; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 2fb78ec..4ace1f2 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -10,7 +10,6 @@ interface ImportMetaEnv { readonly VITE_SITE_APP_URL: string; readonly VITE_SITE_HOME_URL: string; readonly VITE_RF_COVERAGE_ENABLED: string | undefined; - readonly VITE_INFERRED_NODES_ENABLED: string | undefined; readonly VITE_PACKET_ARCS_ENABLED: string | undefined; readonly VITE_HEATMAP_ENABLED: string | undefined; }