mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-09-02 09:23:44 +00:00
fix(map): remove inferred nodes and restore historic hops
This commit is contained in:
+31
-70
@@ -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;
|
||||
|
||||
@@ -954,19 +954,19 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
)`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
@@ -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'/);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'/);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
-1
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user