fix(map): remove inferred nodes and restore historic hops

This commit is contained in:
gadgethd
2026-08-03 22:47:50 +01:00
parent 209989832e
commit bcaa35fd27
28 changed files with 403 additions and 272 deletions
+31 -70
View File
@@ -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<string[]> {
// 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<HistoricPathNode[]> {
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;
+5 -5
View File
@@ -954,19 +954,19 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
invalidateResolveCache(finalHash);
// A repeater appearing in a multibyte (23 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);
}
+26
View File
@@ -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'));
});
+37
View File
@@ -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`;
}
+4 -1
View File
@@ -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/);
});
+4 -2
View File
@@ -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}
)`;
}
+36
View File
@@ -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,
]);
});
+13 -1
View File
@@ -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 = <T extends QueryResultRow = QueryResultRow>(
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<Record<string, unknown>>(
`SELECT ${selectedFields}
@@ -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'/);
});
+107
View File
@@ -0,0 +1,107 @@
import type { QueryResultRow } from 'pg';
export type QueryFn = <T extends QueryResultRow = QueryResultRow>(
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<HistoricPathNode[]> {
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<HistoricPathNode>(
`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;
}
+6 -2
View File
@@ -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'/);
}
+3 -2
View File
@@ -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
+1 -63
View File
@@ -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<string>();
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<Filters>(() => {
@@ -122,8 +119,6 @@ export const App: React.FC = () => {
// MapLibre map instance — used by MobileControls/NodeSearch for flyTo
const [mlMap, setMlMap] = useState<maplibregl.Map | null>(null);
const [showDisclaimer, setShowDisclaimer] = useState(() => !localStorage.getItem(DISCLAIMER_KEY));
const [inferredNodes, setInferredNodes] = useState<MeshNode[]>([]);
const [inferredActiveNodeIds, setInferredActiveNodeIds] = useState<Set<string>>(new Set());
const [packetHistorySegments, setPacketHistorySegments] = useState<PacketHistorySegment[]>([]);
const [fetchedStats, setFetchedStats] = useState<DashboardStats | null>(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 = () => {
<div className="map-layer">
<MapLibreMap
inferredNodes={inferredNodesEnabled ? inferredNodes : []}
inferredActiveNodeIds={inferredNodesEnabled ? inferredActiveNodeIds : EMPTY_NODE_IDS}
showLinks={filters.links}
showTerrain={filters.terrain}
showClientNodes={filters.clientNodes}
+2 -15
View File
@@ -108,8 +108,6 @@ import {
// ── Main Component ────────────────────────────────────────────────────────────
export function MapLibreMap({
inferredNodes,
inferredActiveNodeIds,
showLinks,
showTerrain,
showClientNodes,
@@ -138,8 +136,6 @@ export function MapLibreMap({
const nodesRef = useRef(nodeStore.getState().nodes);
const viablePairsRef = useRef(linkStateStore.getState().viablePairsArr);
const linkMetricsRef = useRef(linkStateStore.getState().linkMetrics);
const inferredNodesRef = useRef(inferredNodes);
const inferredActiveNodeIdsRef = useRef(inferredActiveNodeIds);
const showLinksRef = useRef(showLinks);
const showTerrainRef = useRef(showTerrain);
const showClientNodesRef = useRef(showClientNodes);
@@ -634,8 +630,6 @@ export function MapLibreMap({
clash.clashModeActive ? null : currentPathNodeIds,
replayNodeIdsRef.current,
Date.now(),
inferredNodesRef.current,
inferredActiveNodeIdsRef.current,
);
(mapRef.current.getSource('nodes') as maplibregl.GeoJSONSource | undefined)?.setData(nodeGeoJSON);
}
@@ -891,12 +885,6 @@ export function MapLibreMap({
// -- Imperative source updates ---------------------------------------------
useEffect(() => {
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,
@@ -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 = [
+1 -21
View File
@@ -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<string> | null,
replayNodeIds: Set<string> | null = null,
staleCutoffMs = Date.now(),
inferredNodes: readonly MeshNode[] = [],
inferredActiveNodeIds: ReadonlySet<string> = new Set<string>(),
): 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 };
}
-4
View File
@@ -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;
@@ -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;
@@ -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,
];
}
-3
View File
@@ -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<string>;
showLinks: boolean;
showTerrain: boolean;
showClientNodes: boolean;
-4
View File
@@ -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,
+3 -6
View File
@@ -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,
+1 -6
View File
@@ -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<string, unknown> {
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,
+27 -42
View File
@@ -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', () => {
-7
View File
@@ -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 {
-2
View File
@@ -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 */
-11
View File
@@ -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;
-1
View File
@@ -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;
}