mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-10-22 14:01:27 +00:00
Harden map privacy and telemetry displays
This commit is contained in:
1 parent
ebb8fe55b4
commit
007d538da9
15 files changed
+1905
-119
No files matched your search
+682
-4
@@ -1,6 +1,7 @@
|
||||
import { Request, Response, Router } from 'express';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||
import { isIP } from 'node:net';
|
||||
import mqtt from 'mqtt';
|
||||
import { getNodes, getNodeHistory, getPathHistoryCache, getRecentPacketEvents, getRecentPackets, query, MIN_LINK_OBSERVATIONS } from '../db/index.js';
|
||||
import { addOwnerNodeForUsername, getMappedOwnerNodeIds, getOwnerNodeIdsForUsername } from '../db/ownerAuth.js';
|
||||
@@ -57,6 +58,8 @@ const STATS_CHARTS_LIMITER = rateLimit({
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many stats chart requests, slow down' },
|
||||
});
|
||||
const PROHIBITED_NODE_MARKER = '🚫';
|
||||
const HIDDEN_NODE_MASK_RADIUS_MILES = 1;
|
||||
|
||||
type OwnerSession = {
|
||||
nodeIds: string[];
|
||||
@@ -119,6 +122,92 @@ function hasControlChars(value: string): boolean {
|
||||
return /[\u0000-\u001F\u007F]/.test(value);
|
||||
}
|
||||
|
||||
function isProhibitedMapNode(node: { name?: string | null } | null | undefined): boolean {
|
||||
return Boolean(node?.name?.includes(PROHIBITED_NODE_MARKER));
|
||||
}
|
||||
|
||||
function hashSeed(input: string): number {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function seededUnitPair(seed: string): [number, number] {
|
||||
const distanceUnit = hashSeed(`${seed}:distance`) / 0xffffffff;
|
||||
const bearingUnit = hashSeed(`${seed}:bearing`) / 0xffffffff;
|
||||
return [distanceUnit, bearingUnit];
|
||||
}
|
||||
|
||||
function stablePointWithinMiles(
|
||||
lat: number,
|
||||
lon: number,
|
||||
seed: string,
|
||||
radiusMiles = HIDDEN_NODE_MASK_RADIUS_MILES,
|
||||
): [number, number] {
|
||||
const radiusKm = radiusMiles * 1.609344;
|
||||
const [distanceUnit, bearingUnit] = seededUnitPair(seed);
|
||||
const distanceKm = Math.sqrt(distanceUnit) * radiusKm;
|
||||
const bearing = bearingUnit * Math.PI * 2;
|
||||
const latRad = lat * (Math.PI / 180);
|
||||
const dLat = (distanceKm / 111) * Math.cos(bearing);
|
||||
const lonScale = Math.max(0.01, Math.cos(latRad));
|
||||
const dLon = (distanceKm / (111 * lonScale)) * Math.sin(bearing);
|
||||
return [lat + dLat, lon + dLon];
|
||||
}
|
||||
|
||||
function maskDecodedPathNodes(
|
||||
rawNodes: Array<{
|
||||
ord: number;
|
||||
node_id: string | null;
|
||||
name: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
last_seen?: string | null;
|
||||
}> | null | undefined,
|
||||
): Array<{
|
||||
ord: number;
|
||||
node_id: string | null;
|
||||
name: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
}> {
|
||||
if (!Array.isArray(rawNodes)) return [];
|
||||
return rawNodes.map((node) => {
|
||||
if (!node || typeof node !== 'object') return node;
|
||||
if (!isProhibitedMapNode(node)) {
|
||||
return {
|
||||
ord: Number(node.ord ?? 0),
|
||||
node_id: node.node_id ?? null,
|
||||
name: node.name ?? null,
|
||||
lat: node.lat ?? null,
|
||||
lon: node.lon ?? null,
|
||||
};
|
||||
}
|
||||
if (typeof node.lat !== 'number' || typeof node.lon !== 'number') {
|
||||
return {
|
||||
ord: Number(node.ord ?? 0),
|
||||
node_id: node.node_id ?? null,
|
||||
name: 'Redacted repeater',
|
||||
lat: node.lat ?? null,
|
||||
lon: node.lon ?? null,
|
||||
};
|
||||
}
|
||||
const activityKey = node.last_seen ?? 'unknown';
|
||||
const seed = `${node.node_id ?? 'unknown'}|${activityKey}`;
|
||||
const [maskedLat, maskedLon] = stablePointWithinMiles(node.lat, node.lon, seed);
|
||||
return {
|
||||
ord: Number(node.ord ?? 0),
|
||||
node_id: node.node_id ?? null,
|
||||
name: 'Redacted repeater',
|
||||
lat: maskedLat,
|
||||
lon: maskedLon,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseOwnerMqttUsernameMap(): Map<string, string[]> {
|
||||
const raw = String(process.env['OWNER_MQTT_USERNAME_MAP'] ?? '').trim();
|
||||
const map = new Map<string, string[]>();
|
||||
@@ -313,6 +402,42 @@ function normalizeObserverQuery(value: unknown): string | undefined {
|
||||
return observer && /^[0-9a-f]{64}$/.test(observer) ? observer : undefined;
|
||||
}
|
||||
|
||||
function normalizeIp(value: string | undefined): string {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return '';
|
||||
const first = raw.split(',')[0]?.trim() ?? '';
|
||||
if (first.startsWith('::ffff:')) return first.slice(7);
|
||||
return first;
|
||||
}
|
||||
|
||||
function isPrivateClientIp(ip: string): boolean {
|
||||
const normalized = normalizeIp(ip);
|
||||
if (!normalized) return false;
|
||||
if (normalized === '::1' || normalized === '127.0.0.1') return true;
|
||||
if (normalized.startsWith('10.')) return true;
|
||||
if (normalized.startsWith('192.168.')) return true;
|
||||
if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(normalized)) return true;
|
||||
if (/^(fc|fd)/i.test(normalized)) return true;
|
||||
if (/^fe80:/i.test(normalized)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function requireLocalOnly(req: Request, res: Response): boolean {
|
||||
const candidates = [
|
||||
req.ip,
|
||||
normalizeIp(String(req.headers['cf-connecting-ip'] ?? '')),
|
||||
normalizeIp(String(req.headers['x-forwarded-for'] ?? '')),
|
||||
normalizeIp(req.socket.remoteAddress ?? ''),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
if (candidates.some((ip) => isPrivateClientIp(ip) || isIP(ip) === 0 && ip === 'localhost')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
res.status(403).json({ error: 'Local access only' });
|
||||
return false;
|
||||
}
|
||||
|
||||
function networkFilters(network?: string, observer?: string): NetworkFilters {
|
||||
const params: string[] = [];
|
||||
let networkParam: string | null = null;
|
||||
@@ -400,6 +525,419 @@ type InferredActiveResponse = {
|
||||
inferredActiveNodeIds: string[];
|
||||
};
|
||||
|
||||
// GET /api/node-status/latest — latest raw status telemetry per scoped node
|
||||
router.get('/node-status/latest', async (req, res) => {
|
||||
try {
|
||||
const requestedNetwork = resolveRequestNetwork(req.query['network'], req.headers);
|
||||
const network = requestedNetwork === 'all' ? undefined : requestedNetwork;
|
||||
const observer = normalizeObserverQuery(req.query['observer']);
|
||||
|
||||
const params: string[] = [];
|
||||
const conditions: string[] = [];
|
||||
if (network) {
|
||||
params.push(network);
|
||||
conditions.push(`nss.network = $${params.length}`);
|
||||
} else {
|
||||
conditions.push(`nss.network IS DISTINCT FROM 'test'`);
|
||||
}
|
||||
if (observer) {
|
||||
params.push(observer);
|
||||
conditions.push(`LOWER(nss.node_id) = LOWER($${params.length})`);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const result = await query<{
|
||||
time: string;
|
||||
node_id: string;
|
||||
network: string | null;
|
||||
battery_mv: number | null;
|
||||
uptime_secs: number | null;
|
||||
tx_air_secs: number | null;
|
||||
rx_air_secs: number | null;
|
||||
channel_utilization: number | null;
|
||||
air_util_tx: number | null;
|
||||
stats: Record<string, unknown> | null;
|
||||
name: string | null;
|
||||
iata: string | null;
|
||||
hardware_model: string | null;
|
||||
firmware_version: string | null;
|
||||
}>(
|
||||
`SELECT * FROM (
|
||||
SELECT DISTINCT ON (nss.node_id)
|
||||
nss.time::text,
|
||||
nss.node_id,
|
||||
nss.network,
|
||||
nss.battery_mv,
|
||||
nss.uptime_secs,
|
||||
nss.tx_air_secs,
|
||||
nss.rx_air_secs,
|
||||
nss.channel_utilization,
|
||||
nss.air_util_tx,
|
||||
nss.stats,
|
||||
n.name,
|
||||
n.iata,
|
||||
n.hardware_model,
|
||||
n.firmware_version
|
||||
FROM node_status_samples nss
|
||||
LEFT JOIN nodes n ON n.node_id = nss.node_id
|
||||
${whereClause}
|
||||
ORDER BY nss.node_id, nss.time DESC
|
||||
) latest
|
||||
ORDER BY time DESC`,
|
||||
params,
|
||||
);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
console.error('[api] GET /node-status/latest', (err as Error).message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/node-status/history — recent telemetry history for a scoped node
|
||||
router.get('/node-status/history', async (req, res) => {
|
||||
try {
|
||||
const ESTIMATED_AIRTIME_SECONDS_PER_PUBLISH = 0.12;
|
||||
const requestedNetwork = resolveRequestNetwork(req.query['network'], req.headers);
|
||||
const network = requestedNetwork === 'all' ? undefined : requestedNetwork;
|
||||
const observer = normalizeObserverQuery(req.query['observer']);
|
||||
const requestedNodeId = String(req.query['nodeId'] ?? '').trim();
|
||||
const hours = Math.max(1, Math.min(Number(req.query['hours'] ?? 24), 168));
|
||||
|
||||
let nodeId = requestedNodeId;
|
||||
if (nodeId && !/^[0-9a-fA-F]{64}$/.test(nodeId)) {
|
||||
res.status(400).json({ error: 'Invalid nodeId format' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nodeId) {
|
||||
const params: string[] = [];
|
||||
const conditions: string[] = [];
|
||||
if (network) {
|
||||
params.push(network);
|
||||
conditions.push(`nss.network = $${params.length}`);
|
||||
} else {
|
||||
conditions.push(`nss.network IS DISTINCT FROM 'test'`);
|
||||
}
|
||||
if (observer) {
|
||||
params.push(observer);
|
||||
conditions.push(`LOWER(nss.node_id) = LOWER($${params.length})`);
|
||||
}
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const latestNode = await query<{ node_id: string }>(
|
||||
`SELECT nss.node_id
|
||||
FROM node_status_samples nss
|
||||
${whereClause}
|
||||
ORDER BY nss.time DESC
|
||||
LIMIT 1`,
|
||||
params,
|
||||
);
|
||||
nodeId = latestNode.rows[0]?.node_id ?? '';
|
||||
if (!nodeId) {
|
||||
res.json({ nodeId: null, points: [] });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await query<{
|
||||
time: string;
|
||||
battery_mv: number | null;
|
||||
uptime_secs: number | null;
|
||||
channel_utilization: number | null;
|
||||
air_util_tx: number | null;
|
||||
heap_free: number | null;
|
||||
heap_min_free: number | null;
|
||||
uptime_ms: number | null;
|
||||
rx_publish_calls: number | null;
|
||||
tx_publish_calls: number | null;
|
||||
tx_queue_depth: number | null;
|
||||
tx_queue_depth_peak: number | null;
|
||||
}>(
|
||||
`SELECT
|
||||
time::text,
|
||||
battery_mv,
|
||||
uptime_secs,
|
||||
channel_utilization,
|
||||
air_util_tx,
|
||||
CASE
|
||||
WHEN jsonb_typeof(stats->'heap_free') = 'number' THEN (stats->>'heap_free')::double precision
|
||||
ELSE NULL
|
||||
END AS heap_free,
|
||||
CASE
|
||||
WHEN jsonb_typeof(stats->'heap_min_free') = 'number' THEN (stats->>'heap_min_free')::double precision
|
||||
ELSE NULL
|
||||
END AS heap_min_free,
|
||||
CASE
|
||||
WHEN jsonb_typeof(stats->'uptime_ms') = 'number' THEN (stats->>'uptime_ms')::double precision
|
||||
ELSE NULL
|
||||
END AS uptime_ms,
|
||||
CASE
|
||||
WHEN jsonb_typeof(stats->'rx_publish_calls') = 'number' THEN (stats->>'rx_publish_calls')::double precision
|
||||
ELSE NULL
|
||||
END AS rx_publish_calls,
|
||||
CASE
|
||||
WHEN jsonb_typeof(stats->'tx_publish_calls') = 'number' THEN (stats->>'tx_publish_calls')::double precision
|
||||
ELSE NULL
|
||||
END AS tx_publish_calls,
|
||||
CASE
|
||||
WHEN jsonb_typeof(stats->'tx_queue_depth') = 'number' THEN (stats->>'tx_queue_depth')::double precision
|
||||
ELSE NULL
|
||||
END AS tx_queue_depth,
|
||||
CASE
|
||||
WHEN jsonb_typeof(stats->'tx_queue_depth_peak') = 'number' THEN (stats->>'tx_queue_depth_peak')::double precision
|
||||
ELSE NULL
|
||||
END AS tx_queue_depth_peak
|
||||
FROM node_status_samples
|
||||
WHERE LOWER(node_id) = LOWER($1)
|
||||
AND time > NOW() - ($2::text || ' hours')::interval
|
||||
ORDER BY time ASC`,
|
||||
[nodeId, String(hours)],
|
||||
);
|
||||
|
||||
const points = result.rows.map((row, index) => {
|
||||
if (row.channel_utilization != null || row.air_util_tx != null) {
|
||||
return row;
|
||||
}
|
||||
|
||||
const previous = index > 0 ? result.rows[index - 1] : null;
|
||||
const currentUptimeMs = row.uptime_ms;
|
||||
const previousUptimeMs = previous?.uptime_ms ?? null;
|
||||
const deltaUptimeSeconds =
|
||||
currentUptimeMs != null && previousUptimeMs != null && currentUptimeMs > previousUptimeMs
|
||||
? (currentUptimeMs - previousUptimeMs) / 1000
|
||||
: null;
|
||||
|
||||
if (!deltaUptimeSeconds || deltaUptimeSeconds <= 0) {
|
||||
return row;
|
||||
}
|
||||
|
||||
const deltaRxCalls =
|
||||
row.rx_publish_calls != null && previous?.rx_publish_calls != null
|
||||
? Math.max(0, row.rx_publish_calls - previous.rx_publish_calls)
|
||||
: 0;
|
||||
const deltaTxCalls =
|
||||
row.tx_publish_calls != null && previous?.tx_publish_calls != null
|
||||
? Math.max(0, row.tx_publish_calls - previous.tx_publish_calls)
|
||||
: 0;
|
||||
|
||||
const estimatedTxPct = Math.min(
|
||||
100,
|
||||
(deltaTxCalls * ESTIMATED_AIRTIME_SECONDS_PER_PUBLISH / deltaUptimeSeconds) * 100,
|
||||
);
|
||||
const estimatedTotalPct = Math.min(
|
||||
100,
|
||||
((deltaTxCalls + deltaRxCalls) * ESTIMATED_AIRTIME_SECONDS_PER_PUBLISH / deltaUptimeSeconds) * 100,
|
||||
);
|
||||
|
||||
return {
|
||||
...row,
|
||||
channel_utilization: estimatedTotalPct,
|
||||
air_util_tx: estimatedTxPct,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
nodeId,
|
||||
points,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[api] GET /node-status/history', (err as Error).message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/local/test-diagnostics — local-only consolidated test-site data
|
||||
router.get('/local/test-diagnostics', async (req, res) => {
|
||||
try {
|
||||
if (!requireLocalOnly(req, res)) return;
|
||||
|
||||
const [nodes, packetsResult, latestStatusRows, statusSamplesResult] = await Promise.all([
|
||||
getNodes('test'),
|
||||
query<{
|
||||
time: string;
|
||||
topic: string;
|
||||
packet_hash: string | null;
|
||||
packet_type: number | null;
|
||||
route_type: number | null;
|
||||
hop_count: number | null;
|
||||
src_node_id: string | null;
|
||||
rx_node_id: string | null;
|
||||
rssi: number | null;
|
||||
snr: number | null;
|
||||
payload: Record<string, unknown> | null;
|
||||
raw_hex: string | null;
|
||||
path_hash_size_bytes: number | null;
|
||||
path_hashes: string[] | null;
|
||||
}>(
|
||||
`SELECT
|
||||
time::text,
|
||||
topic,
|
||||
packet_hash,
|
||||
packet_type,
|
||||
route_type,
|
||||
hop_count,
|
||||
src_node_id,
|
||||
rx_node_id,
|
||||
rssi,
|
||||
snr,
|
||||
payload,
|
||||
raw_hex,
|
||||
path_hash_size_bytes,
|
||||
path_hashes
|
||||
FROM packets
|
||||
WHERE network = 'test'
|
||||
ORDER BY time DESC`,
|
||||
[],
|
||||
),
|
||||
query<{
|
||||
time: string;
|
||||
node_id: string;
|
||||
network: string | null;
|
||||
battery_mv: number | null;
|
||||
uptime_secs: number | null;
|
||||
tx_air_secs: number | null;
|
||||
rx_air_secs: number | null;
|
||||
channel_utilization: number | null;
|
||||
air_util_tx: number | null;
|
||||
stats: Record<string, unknown> | null;
|
||||
name: string | null;
|
||||
iata: string | null;
|
||||
hardware_model: string | null;
|
||||
firmware_version: string | null;
|
||||
}>(
|
||||
`SELECT * FROM (
|
||||
SELECT DISTINCT ON (nss.node_id)
|
||||
nss.time::text,
|
||||
nss.node_id,
|
||||
nss.network,
|
||||
nss.battery_mv,
|
||||
nss.uptime_secs,
|
||||
nss.tx_air_secs,
|
||||
nss.rx_air_secs,
|
||||
nss.channel_utilization,
|
||||
nss.air_util_tx,
|
||||
nss.stats,
|
||||
n.name,
|
||||
n.iata,
|
||||
n.hardware_model,
|
||||
n.firmware_version
|
||||
FROM node_status_samples nss
|
||||
LEFT JOIN nodes n ON n.node_id = nss.node_id
|
||||
WHERE nss.network = 'test'
|
||||
ORDER BY nss.node_id, nss.time DESC
|
||||
) latest
|
||||
ORDER BY time DESC`,
|
||||
[],
|
||||
),
|
||||
query<{
|
||||
time: string;
|
||||
node_id: string;
|
||||
network: string | null;
|
||||
battery_mv: number | null;
|
||||
uptime_secs: number | null;
|
||||
tx_air_secs: number | null;
|
||||
rx_air_secs: number | null;
|
||||
channel_utilization: number | null;
|
||||
air_util_tx: number | null;
|
||||
stats: Record<string, unknown> | null;
|
||||
}>(
|
||||
`SELECT
|
||||
time::text,
|
||||
node_id,
|
||||
network,
|
||||
battery_mv,
|
||||
uptime_secs,
|
||||
tx_air_secs,
|
||||
rx_air_secs,
|
||||
channel_utilization,
|
||||
air_util_tx,
|
||||
stats
|
||||
FROM node_status_samples
|
||||
WHERE network = 'test'
|
||||
ORDER BY time DESC`,
|
||||
[],
|
||||
),
|
||||
]);
|
||||
|
||||
const packets = packetsResult.rows;
|
||||
const latestStatuses = latestStatusRows.rows;
|
||||
const statusSamples = statusSamplesResult.rows;
|
||||
const latestStatus = latestStatusRows.rows[0] ?? null;
|
||||
let history: unknown[] = [];
|
||||
if (latestStatus?.node_id) {
|
||||
const historyRows = await query<{
|
||||
time: string;
|
||||
battery_mv: number | null;
|
||||
uptime_secs: number | null;
|
||||
channel_utilization: number | null;
|
||||
air_util_tx: number | null;
|
||||
heap_free: number | null;
|
||||
heap_min_free: number | null;
|
||||
uptime_ms: number | null;
|
||||
rx_publish_calls: number | null;
|
||||
tx_publish_calls: number | null;
|
||||
tx_queue_depth: number | null;
|
||||
tx_queue_depth_peak: number | null;
|
||||
}>(
|
||||
`SELECT
|
||||
time::text,
|
||||
battery_mv,
|
||||
uptime_secs,
|
||||
channel_utilization,
|
||||
air_util_tx,
|
||||
CASE
|
||||
WHEN jsonb_typeof(stats->'heap_free') = 'number' THEN (stats->>'heap_free')::double precision
|
||||
ELSE NULL
|
||||
END AS heap_free,
|
||||
CASE
|
||||
WHEN jsonb_typeof(stats->'heap_min_free') = 'number' THEN (stats->>'heap_min_free')::double precision
|
||||
ELSE NULL
|
||||
END AS heap_min_free,
|
||||
CASE
|
||||
WHEN jsonb_typeof(stats->'uptime_ms') = 'number' THEN (stats->>'uptime_ms')::double precision
|
||||
ELSE NULL
|
||||
END AS uptime_ms,
|
||||
CASE
|
||||
WHEN jsonb_typeof(stats->'rx_publish_calls') = 'number' THEN (stats->>'rx_publish_calls')::double precision
|
||||
ELSE NULL
|
||||
END AS rx_publish_calls,
|
||||
CASE
|
||||
WHEN jsonb_typeof(stats->'tx_publish_calls') = 'number' THEN (stats->>'tx_publish_calls')::double precision
|
||||
ELSE NULL
|
||||
END AS tx_publish_calls,
|
||||
CASE
|
||||
WHEN jsonb_typeof(stats->'tx_queue_depth') = 'number' THEN (stats->>'tx_queue_depth')::double precision
|
||||
ELSE NULL
|
||||
END AS tx_queue_depth,
|
||||
CASE
|
||||
WHEN jsonb_typeof(stats->'tx_queue_depth_peak') = 'number' THEN (stats->>'tx_queue_depth_peak')::double precision
|
||||
ELSE NULL
|
||||
END AS tx_queue_depth_peak
|
||||
FROM node_status_samples
|
||||
WHERE LOWER(node_id) = LOWER($1)
|
||||
AND network = 'test'
|
||||
AND time > NOW() - INTERVAL '24 hours'
|
||||
ORDER BY time ASC`,
|
||||
[latestStatus.node_id],
|
||||
);
|
||||
history = historyRows.rows;
|
||||
}
|
||||
|
||||
res.json({
|
||||
network: 'test',
|
||||
nodes,
|
||||
packets,
|
||||
latestStatuses,
|
||||
statusSamples,
|
||||
latestStatus,
|
||||
history,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[api] GET /local/test-diagnostics', (err as Error).message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/nodes — all known nodes
|
||||
router.get('/nodes', async (req, res) => {
|
||||
try {
|
||||
@@ -1293,6 +1831,9 @@ router.get('/owner/live', async (req, res) => {
|
||||
rx_air_secs: string | null;
|
||||
channel_utilization: number | null;
|
||||
air_util_tx: number | null;
|
||||
uptime_ms: number | null;
|
||||
rx_publish_calls: number | null;
|
||||
tx_publish_calls: number | null;
|
||||
}>(
|
||||
`SELECT
|
||||
time::text AS time,
|
||||
@@ -1301,7 +1842,19 @@ router.get('/owner/live', async (req, res) => {
|
||||
tx_air_secs::text AS tx_air_secs,
|
||||
rx_air_secs::text AS rx_air_secs,
|
||||
channel_utilization,
|
||||
air_util_tx
|
||||
air_util_tx,
|
||||
CASE
|
||||
WHEN jsonb_typeof(stats->'uptime_ms') = 'number' THEN (stats->>'uptime_ms')::double precision
|
||||
ELSE NULL
|
||||
END AS uptime_ms,
|
||||
CASE
|
||||
WHEN jsonb_typeof(stats->'rx_publish_calls') = 'number' THEN (stats->>'rx_publish_calls')::double precision
|
||||
ELSE NULL
|
||||
END AS rx_publish_calls,
|
||||
CASE
|
||||
WHEN jsonb_typeof(stats->'tx_publish_calls') = 'number' THEN (stats->>'tx_publish_calls')::double precision
|
||||
ELSE NULL
|
||||
END AS tx_publish_calls
|
||||
FROM node_status_samples
|
||||
WHERE LOWER(node_id) = LOWER($1)
|
||||
AND time > NOW() - INTERVAL '24 hours'
|
||||
@@ -1351,6 +1904,7 @@ router.get('/owner/live', async (req, res) => {
|
||||
})();
|
||||
|
||||
const telemetry24h = (() => {
|
||||
const ESTIMATED_AIRTIME_SECONDS_PER_PUBLISH = 0.12;
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
const bucketMs = 5 * 60 * 1000;
|
||||
type Point = {
|
||||
@@ -1366,10 +1920,13 @@ router.get('/owner/live', async (req, res) => {
|
||||
timeMs: new Date(row.time).getTime(),
|
||||
batteryMv: row.battery_mv == null ? null : Number(row.battery_mv),
|
||||
uptimeSecs: row.uptime_secs == null ? null : Number(row.uptime_secs),
|
||||
uptimeMs: row.uptime_ms == null ? null : Number(row.uptime_ms),
|
||||
txAirSecs: row.tx_air_secs == null ? null : Number(row.tx_air_secs),
|
||||
rxAirSecs: row.rx_air_secs == null ? null : Number(row.rx_air_secs),
|
||||
channelUtilization: row.channel_utilization == null ? null : Number(row.channel_utilization),
|
||||
airUtilTx: row.air_util_tx == null ? null : Number(row.air_util_tx),
|
||||
rxPublishCalls: row.rx_publish_calls == null ? null : Number(row.rx_publish_calls),
|
||||
txPublishCalls: row.tx_publish_calls == null ? null : Number(row.tx_publish_calls),
|
||||
}));
|
||||
|
||||
const bucketed = new Map<number, Point>();
|
||||
@@ -1397,6 +1954,40 @@ router.get('/owner/live', async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
if ((channelUtilPct == null || airUtilTxPct == null) && prev) {
|
||||
const currentUptimeMs = sample.uptimeMs;
|
||||
const previousUptimeMs = prev.uptimeMs;
|
||||
const deltaUptimeSeconds =
|
||||
currentUptimeMs != null && previousUptimeMs != null && currentUptimeMs > previousUptimeMs
|
||||
? (currentUptimeMs - previousUptimeMs) / 1000
|
||||
: 0;
|
||||
const deltaRxCalls =
|
||||
sample.rxPublishCalls != null && prev.rxPublishCalls != null
|
||||
? Math.max(0, sample.rxPublishCalls - prev.rxPublishCalls)
|
||||
: 0;
|
||||
const deltaTxCalls =
|
||||
sample.txPublishCalls != null && prev.txPublishCalls != null
|
||||
? Math.max(0, sample.txPublishCalls - prev.txPublishCalls)
|
||||
: 0;
|
||||
|
||||
if (deltaUptimeSeconds > 0) {
|
||||
if (airUtilTxPct == null) {
|
||||
airUtilTxPct = clamp(
|
||||
(deltaTxCalls * ESTIMATED_AIRTIME_SECONDS_PER_PUBLISH / deltaUptimeSeconds) * 100,
|
||||
0,
|
||||
100,
|
||||
);
|
||||
}
|
||||
if (channelUtilPct == null) {
|
||||
channelUtilPct = clamp(
|
||||
((deltaTxCalls + deltaRxCalls) * ESTIMATED_AIRTIME_SECONDS_PER_PUBLISH / deltaUptimeSeconds) * 100,
|
||||
0,
|
||||
100,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bucketStartMs = Math.floor(sample.timeMs / bucketMs) * bucketMs;
|
||||
bucketed.set(bucketStartMs, {
|
||||
bucket: new Date(bucketStartMs).toISOString(),
|
||||
@@ -1643,6 +2234,7 @@ router.get('/stats/charts', STATS_CHARTS_LIMITER, async (req, res) => {
|
||||
COALESCE(NULLIF(TRIM(UPPER(n.iata)), ''), 'UNK') AS iata,
|
||||
COUNT(*) FILTER (WHERE p.time > NOW() - INTERVAL '24 hours') AS packets_24h,
|
||||
COUNT(*) AS packets_7d,
|
||||
COUNT(DISTINCT LOWER(p.rx_node_id)) FILTER (WHERE p.time > NOW() - INTERVAL '1 minute') AS active_observers,
|
||||
COUNT(DISTINCT LOWER(p.rx_node_id)) AS observers,
|
||||
MAX(p.time)::text AS last_packet_at
|
||||
FROM packets p
|
||||
@@ -1683,8 +2275,33 @@ router.get('/stats/charts', STATS_CHARTS_LIMITER, async (req, res) => {
|
||||
),
|
||||
query<{
|
||||
latest_multibyte_at: string | null;
|
||||
latest_multibyte_hash: string | null;
|
||||
multibyte_packets_24h: string;
|
||||
fully_decoded_multibyte_24h: string;
|
||||
latest_fully_decoded_at: string | null;
|
||||
latest_fully_decoded_hash: string | null;
|
||||
latest_fully_decoded_hops: string | null;
|
||||
latest_fully_decoded_path: string | null;
|
||||
latest_fully_decoded_nodes: Array<{
|
||||
ord: number;
|
||||
node_id: string;
|
||||
name: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
last_seen: string | null;
|
||||
}> | null;
|
||||
longest_fully_decoded_at: string | null;
|
||||
longest_fully_decoded_hash: string | null;
|
||||
longest_fully_decoded_hops: string | null;
|
||||
longest_fully_decoded_path: string | null;
|
||||
longest_fully_decoded_nodes: Array<{
|
||||
ord: number;
|
||||
node_id: string;
|
||||
name: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
last_seen: string | null;
|
||||
}> | null;
|
||||
}>(
|
||||
`WITH multibyte AS (
|
||||
SELECT DISTINCT ON (p.packet_hash)
|
||||
@@ -1704,6 +2321,7 @@ router.get('/stats/charts', STATS_CHARTS_LIMITER, async (req, res) => {
|
||||
hop_matches AS (
|
||||
SELECT
|
||||
m.packet_hash,
|
||||
h.hash,
|
||||
h.ord,
|
||||
COUNT(DISTINCT n.node_id)::int AS match_count,
|
||||
MIN(n.node_id) FILTER (WHERE n.node_id IS NOT NULL) AS matched_node_id
|
||||
@@ -1714,22 +2332,67 @@ router.get('/stats/charts', STATS_CHARTS_LIMITER, async (req, res) => {
|
||||
AND (n.role IS NULL OR n.role = 2)
|
||||
AND n.lat IS NOT NULL
|
||||
AND n.lon IS NOT NULL
|
||||
GROUP BY 1, 2
|
||||
GROUP BY 1, 2, 3
|
||||
),
|
||||
packet_eval AS (
|
||||
SELECT
|
||||
m.packet_hash,
|
||||
MAX(m.time)::text AS packet_time,
|
||||
BOOL_AND(hm.match_count = 1) AS all_unique,
|
||||
COUNT(hm.ord)::int AS hop_count,
|
||||
COUNT(DISTINCT hm.matched_node_id) FILTER (WHERE hm.matched_node_id IS NOT NULL)::int AS unique_nodes
|
||||
COUNT(DISTINCT hm.matched_node_id) FILTER (WHERE hm.matched_node_id IS NOT NULL)::int AS unique_nodes,
|
||||
string_agg(COALESCE(n.name, hm.matched_node_id, hm.hash), ' -> ' ORDER BY hm.ord) AS decoded_path,
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'ord', hm.ord,
|
||||
'node_id', hm.matched_node_id,
|
||||
'name', n.name,
|
||||
'lat', n.lat,
|
||||
'lon', n.lon,
|
||||
'last_seen', n.last_seen::text
|
||||
)
|
||||
ORDER BY hm.ord
|
||||
) AS decoded_nodes
|
||||
FROM multibyte m
|
||||
JOIN hop_matches hm ON hm.packet_hash = m.packet_hash
|
||||
LEFT JOIN nodes n ON LOWER(n.node_id) = LOWER(hm.matched_node_id)
|
||||
GROUP BY m.packet_hash
|
||||
),
|
||||
latest_multibyte AS (
|
||||
SELECT m.packet_hash, m.time
|
||||
FROM multibyte m
|
||||
ORDER BY m.time DESC, m.packet_hash DESC
|
||||
LIMIT 1
|
||||
),
|
||||
latest_fully_decoded AS (
|
||||
SELECT pe.packet_hash, pe.packet_time, pe.hop_count, pe.decoded_path, pe.decoded_nodes
|
||||
FROM packet_eval pe
|
||||
WHERE pe.all_unique AND pe.unique_nodes = pe.hop_count
|
||||
ORDER BY pe.packet_time DESC, pe.packet_hash DESC
|
||||
LIMIT 1
|
||||
),
|
||||
longest_fully_decoded AS (
|
||||
SELECT pe.packet_hash, pe.packet_time, pe.hop_count, pe.decoded_path, pe.decoded_nodes
|
||||
FROM packet_eval pe
|
||||
WHERE pe.all_unique AND pe.unique_nodes = pe.hop_count
|
||||
ORDER BY pe.hop_count DESC, pe.packet_time DESC, pe.packet_hash DESC
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT
|
||||
(SELECT MAX(time)::text FROM multibyte) AS latest_multibyte_at,
|
||||
(SELECT packet_hash FROM latest_multibyte) AS latest_multibyte_hash,
|
||||
(SELECT COUNT(*)::text FROM multibyte) AS multibyte_packets_24h,
|
||||
(SELECT COUNT(*)::text FROM packet_eval WHERE all_unique AND unique_nodes = hop_count) AS fully_decoded_multibyte_24h`,
|
||||
(SELECT COUNT(*)::text FROM packet_eval WHERE all_unique AND unique_nodes = hop_count) AS fully_decoded_multibyte_24h,
|
||||
(SELECT packet_time FROM latest_fully_decoded) AS latest_fully_decoded_at,
|
||||
(SELECT packet_hash FROM latest_fully_decoded) AS latest_fully_decoded_hash,
|
||||
(SELECT hop_count::text FROM latest_fully_decoded) AS latest_fully_decoded_hops,
|
||||
(SELECT decoded_path FROM latest_fully_decoded) AS latest_fully_decoded_path,
|
||||
(SELECT decoded_nodes FROM latest_fully_decoded) AS latest_fully_decoded_nodes,
|
||||
(SELECT packet_time FROM longest_fully_decoded) AS longest_fully_decoded_at,
|
||||
(SELECT packet_hash FROM longest_fully_decoded) AS longest_fully_decoded_hash,
|
||||
(SELECT hop_count::text FROM longest_fully_decoded) AS longest_fully_decoded_hops,
|
||||
(SELECT decoded_path FROM longest_fully_decoded) AS longest_fully_decoded_path,
|
||||
(SELECT decoded_nodes FROM longest_fully_decoded) AS longest_fully_decoded_nodes`,
|
||||
filters.params,
|
||||
),
|
||||
]);
|
||||
@@ -1754,6 +2417,7 @@ router.get('/stats/charts', STATS_CHARTS_LIMITER, async (req, res) => {
|
||||
|
||||
const observerRegionsByIata = new Map<string, {
|
||||
iata: string;
|
||||
activeObservers: number;
|
||||
observers: number;
|
||||
packets24h: number;
|
||||
packets7d: number;
|
||||
@@ -1765,6 +2429,7 @@ router.get('/stats/charts', STATS_CHARTS_LIMITER, async (req, res) => {
|
||||
const iata = String(row.iata ?? 'UNK');
|
||||
observerRegionsByIata.set(iata, {
|
||||
iata,
|
||||
activeObservers: Number(row.active_observers ?? 0),
|
||||
observers: Number(row.observers ?? 0),
|
||||
packets24h: Number(row.packets_24h ?? 0),
|
||||
packets7d: Number(row.packets_7d ?? 0),
|
||||
@@ -1802,6 +2467,8 @@ router.get('/stats/charts', STATS_CHARTS_LIMITER, async (req, res) => {
|
||||
}
|
||||
|
||||
const multibyteRow = multibyteSummaryResult.rows[0];
|
||||
const latestFullyDecodedNodes = maskDecodedPathNodes(multibyteRow?.latest_fully_decoded_nodes);
|
||||
const longestFullyDecodedNodes = maskDecodedPathNodes(multibyteRow?.longest_fully_decoded_nodes);
|
||||
|
||||
res.json({
|
||||
packetsPerHour: phResult.rows.map(r => ({ hour: fmtHourMinute(r.hour), count: Number(r.count) })),
|
||||
@@ -1823,6 +2490,17 @@ router.get('/stats/charts', STATS_CHARTS_LIMITER, async (req, res) => {
|
||||
multibytePackets24h: Number(multibyteRow?.multibyte_packets_24h ?? 0),
|
||||
fullyDecodedMultibyte24h: Number(multibyteRow?.fully_decoded_multibyte_24h ?? 0),
|
||||
latestMultibyteAt: multibyteRow?.latest_multibyte_at ?? null,
|
||||
latestMultibyteHash: multibyteRow?.latest_multibyte_hash ?? null,
|
||||
latestFullyDecodedAt: multibyteRow?.latest_fully_decoded_at ?? null,
|
||||
latestFullyDecodedHash: multibyteRow?.latest_fully_decoded_hash ?? null,
|
||||
latestFullyDecodedHops: Number(multibyteRow?.latest_fully_decoded_hops ?? 0) || null,
|
||||
latestFullyDecodedPath: multibyteRow?.latest_fully_decoded_path ?? null,
|
||||
latestFullyDecodedNodes,
|
||||
longestFullyDecodedAt: multibyteRow?.longest_fully_decoded_at ?? null,
|
||||
longestFullyDecodedHash: multibyteRow?.longest_fully_decoded_hash ?? null,
|
||||
longestFullyDecodedHops: Number(multibyteRow?.longest_fully_decoded_hops ?? 0) || null,
|
||||
longestFullyDecodedPath: multibyteRow?.longest_fully_decoded_path ?? null,
|
||||
longestFullyDecodedNodes,
|
||||
},
|
||||
summary: {
|
||||
totalPackets24h: Number(sumResult.rows[0].total_24h),
|
||||
|
||||
@@ -127,10 +127,20 @@ type StatusTelemetrySample = {
|
||||
stats?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function extractStatusTelemetry(json: Record<string, unknown>): StatusTelemetrySample | null {
|
||||
function extractStatusTelemetry(
|
||||
json: Record<string, unknown>,
|
||||
options?: { allowRawStatsOnly?: boolean },
|
||||
): StatusTelemetrySample | null {
|
||||
const stats = toRecord(json['stats']);
|
||||
const hasRawStats = Boolean(stats && Object.keys(stats).length > 0);
|
||||
const batteryMv = readNum(stats, 'battery_mv', 'batteryMv');
|
||||
const uptimeSecs = readNum(stats, 'uptime_secs', 'uptimeSecs');
|
||||
const uptimeSecs = (() => {
|
||||
const direct = readNum(stats, 'uptime_secs', 'uptimeSecs');
|
||||
if (direct != null) return direct;
|
||||
const uptimeMs = readNum(stats, 'uptime_ms', 'uptimeMs');
|
||||
if (uptimeMs == null) return undefined;
|
||||
return Math.floor(uptimeMs / 1000);
|
||||
})();
|
||||
const txAirSecs = readNum(stats, 'tx_air_secs', 'txAirSecs');
|
||||
const rxAirSecs = readNum(stats, 'rx_air_secs', 'rxAirSecs');
|
||||
const channelUtilization = readNum(
|
||||
@@ -158,6 +168,17 @@ function extractStatusTelemetry(json: Record<string, unknown>): StatusTelemetryS
|
||||
&& channelUtilization == null
|
||||
&& airUtilTx == null
|
||||
) {
|
||||
if (options?.allowRawStatsOnly && hasRawStats) {
|
||||
return {
|
||||
batteryMv,
|
||||
uptimeSecs,
|
||||
txAirSecs,
|
||||
rxAirSecs,
|
||||
channelUtilization,
|
||||
airUtilTx,
|
||||
stats,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -387,7 +408,9 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
|
||||
network,
|
||||
allowTestOverride: network === 'test' && nodeId === observerKey,
|
||||
});
|
||||
const telemetry = extractStatusTelemetry(json);
|
||||
const telemetry = extractStatusTelemetry(json, {
|
||||
allowRawStatsOnly: network === 'test',
|
||||
});
|
||||
if (telemetry) {
|
||||
void insertNodeStatusSample({
|
||||
nodeId,
|
||||
|
||||
@@ -288,20 +288,38 @@ function buildFallbackPrefixPath(
|
||||
let nextTowardRx: MeshNode | null = null;
|
||||
for (const h of [...hopHashes].reverse()) {
|
||||
const prefix = normalizePathHash(h);
|
||||
const observerOwnPrefix = rx.role === 2 ? nodePathHash(rx.node_id, prefix) : null;
|
||||
const compareCandidates = (a: MeshNode, b: MeshNode): number => {
|
||||
const hopBias = observerHopPrior(b, prev, observerHopHints) - observerHopPrior(a, prev, observerHopHints);
|
||||
const base = compareFallbackCandidates(a, b, prev, src, nextTowardRx, coverageByNode, linkMetrics);
|
||||
return base + hopBias * 0.6;
|
||||
};
|
||||
let candidates = getNodesForPathHash(pathHashIndex, prefix)
|
||||
.filter((n) => !visited.has(n.node_id) && fallbackEdgeAllowed(n, prev, coverageByNode, linkMetrics))
|
||||
.filter((n) => {
|
||||
if (visited.has(n.node_id)) return false;
|
||||
if (!fallbackEdgeAllowed(n, prev, coverageByNode, linkMetrics)) return false;
|
||||
if (prev.node_id === rx.node_id && observerOwnPrefix === prefix && n.node_id !== rx.node_id) {
|
||||
const meta = linkMetrics.get(linkKey(n.node_id, rx.node_id));
|
||||
const observed = Number(meta?.observed_count ?? 0);
|
||||
const pathLoss = meta?.itm_path_loss_db;
|
||||
return observed >= 120 && pathLoss != null && pathLoss <= 125;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort(compareCandidates);
|
||||
if (candidates.length < 1) {
|
||||
candidates = getNodesForPathHash(pathHashIndex, prefix)
|
||||
.filter((n) => (
|
||||
!visited.has(n.node_id)
|
||||
&& softFallbackCandidateAllowed(n, prev, src, nextTowardRx)
|
||||
))
|
||||
.filter((n) => {
|
||||
if (visited.has(n.node_id)) return false;
|
||||
if (!softFallbackCandidateAllowed(n, prev, src, nextTowardRx)) return false;
|
||||
if (prev.node_id === rx.node_id && observerOwnPrefix === prefix && n.node_id !== rx.node_id) {
|
||||
const meta = linkMetrics.get(linkKey(n.node_id, rx.node_id));
|
||||
const observed = Number(meta?.observed_count ?? 0);
|
||||
const pathLoss = meta?.itm_path_loss_db;
|
||||
return observed >= 120 && pathLoss != null && pathLoss <= 125;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort(compareCandidates);
|
||||
}
|
||||
const chosen = candidates[0];
|
||||
@@ -695,6 +713,19 @@ function resolveBetaPath(
|
||||
const all = getNodesForPathHash(pathHashIndex, prefix);
|
||||
if (all.length === 0) return [];
|
||||
const nextTowardRxNode = nextTowardRx ? (context.nodesById.get(nextTowardRx) ?? null) : null;
|
||||
const observerOwnPrefix = rx.role === 2 ? nodePathHash(rx.node_id, prefix) : null;
|
||||
const isObserverTerminalCollision = (candidate: MeshNode): boolean => (
|
||||
prevNode.node_id === rx.node_id
|
||||
&& observerOwnPrefix === prefix
|
||||
&& candidate.node_id !== rx.node_id
|
||||
);
|
||||
const allowTerminalCollisionCandidate = (candidate: MeshNode): boolean => {
|
||||
if (!isObserverTerminalCollision(candidate)) return true;
|
||||
const meta = context.linkMetrics.get(linkKey(candidate.node_id, rx.node_id));
|
||||
const observed = Number(meta?.observed_count ?? 0);
|
||||
const pathLoss = meta?.itm_path_loss_db;
|
||||
return observed >= 120 && pathLoss != null && pathLoss <= 125;
|
||||
};
|
||||
|
||||
function directionalPrior(c: MeshNode): number {
|
||||
return sourceProgressScore(c, prevNode, src) * 0.75
|
||||
@@ -707,13 +738,15 @@ function resolveBetaPath(
|
||||
|
||||
function sortScore(c: MeshNode): number {
|
||||
const corridorBonus = inCorridor(c, prevNode, prefix) ? 0.25 : -0.6;
|
||||
return directionalPrior(c) + multiObserverPrior(c) * 1.4 - distKm(c, prevNode) / 50 + corridorBonus;
|
||||
const observerCollisionPenalty = isObserverTerminalCollision(c) ? 3.5 : 0;
|
||||
return directionalPrior(c) + multiObserverPrior(c) * 1.4 - distKm(c, prevNode) / 50 + corridorBonus - observerCollisionPenalty;
|
||||
}
|
||||
|
||||
const usedIds = new Set<string>();
|
||||
|
||||
const confirmed = all
|
||||
.filter((c) => {
|
||||
if (!allowTerminalCollisionCandidate(c)) return false;
|
||||
const key = linkKey(c.node_id, prevNode.node_id);
|
||||
if (!context.linkPairs.has(key)) return false;
|
||||
const meta = context.linkMetrics.get(key);
|
||||
@@ -749,6 +782,7 @@ function resolveBetaPath(
|
||||
|
||||
const reachable = all
|
||||
.filter((c) => {
|
||||
if (!allowTerminalCollisionCandidate(c)) return false;
|
||||
if (usedIds.has(c.node_id)) return false;
|
||||
if (!inCorridor(c, prevNode, prefix)) return false;
|
||||
const meta = context.linkMetrics.get(linkKey(c.node_id, prevNode.node_id));
|
||||
@@ -778,6 +812,7 @@ function resolveBetaPath(
|
||||
|
||||
const fallback = all
|
||||
.filter((c) => {
|
||||
if (!allowTerminalCollisionCandidate(c)) return false;
|
||||
if (usedIds.has(c.node_id)) return false;
|
||||
if (!inCorridor(c, prevNode, prefix)) return false;
|
||||
if (distKm(c, prevNode) >= MAX_HOP_KM * 0.5) return false;
|
||||
@@ -1031,7 +1066,7 @@ async function loadContext(network: string): Promise<BetaResolveContext> {
|
||||
|
||||
const [nodeRows, coverageRows, linkRows, learningModel] = await Promise.all([
|
||||
query<MeshNode>(
|
||||
`SELECT node_id, name, lat, lon, iata, role, elevation_m
|
||||
`SELECT node_id, name, lat, lon, iata, role, elevation_m, last_seen::text AS last_seen
|
||||
FROM nodes
|
||||
WHERE ($1 = 'all' OR network = $1)`,
|
||||
[network],
|
||||
@@ -1118,6 +1153,96 @@ export type BetaResolvedPayload = {
|
||||
};
|
||||
};
|
||||
|
||||
const PROHIBITED_NODE_MARKER = '🚫';
|
||||
const HIDDEN_NODE_MASK_RADIUS_MILES = 1;
|
||||
|
||||
function isProhibitedMapNode(node: MeshNode | null | undefined): boolean {
|
||||
return Boolean(node?.name?.includes(PROHIBITED_NODE_MARKER));
|
||||
}
|
||||
|
||||
function roundCoord(value: number): number {
|
||||
return Math.round(value * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
function hiddenCoordKey(lat: number, lon: number): string {
|
||||
return `${roundCoord(lat)},${roundCoord(lon)}`;
|
||||
}
|
||||
|
||||
function hashSeed(input: string): number {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function seededUnitPair(seed: string): [number, number] {
|
||||
const distanceUnit = hashSeed(`${seed}:distance`) / 0xffffffff;
|
||||
const bearingUnit = hashSeed(`${seed}:bearing`) / 0xffffffff;
|
||||
return [distanceUnit, bearingUnit];
|
||||
}
|
||||
|
||||
function stablePointWithinMiles(
|
||||
lat: number,
|
||||
lon: number,
|
||||
seed: string,
|
||||
radiusMiles = HIDDEN_NODE_MASK_RADIUS_MILES,
|
||||
): [number, number] {
|
||||
const radiusKm = radiusMiles * 1.609344;
|
||||
const [distanceUnit, bearingUnit] = seededUnitPair(seed);
|
||||
const distanceKm = Math.sqrt(distanceUnit) * radiusKm;
|
||||
const bearing = bearingUnit * Math.PI * 2;
|
||||
const latRad = lat * (Math.PI / 180);
|
||||
const dLat = (distanceKm / 111) * Math.cos(bearing);
|
||||
const lonScale = Math.max(0.01, Math.cos(latRad));
|
||||
const dLon = (distanceKm / (111 * lonScale)) * Math.sin(bearing);
|
||||
return [lat + dLat, lon + dLon];
|
||||
}
|
||||
|
||||
function buildHiddenCoordMask(nodesById: Map<string, MeshNode>): Map<string, [number, number]> {
|
||||
const mask = new Map<string, [number, number]>();
|
||||
for (const node of nodesById.values()) {
|
||||
if (!hasCoords(node) || !isProhibitedMapNode(node)) continue;
|
||||
const activityKey = node.last_seen ?? 'unknown';
|
||||
const seed = `${node.node_id}|${activityKey}`;
|
||||
mask.set(hiddenCoordKey(node.lat!, node.lon!), stablePointWithinMiles(node.lat!, node.lon!, seed));
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
function maskPoint(point: [number, number], hiddenCoordMask: Map<string, [number, number]>): [number, number] {
|
||||
return hiddenCoordMask.get(hiddenCoordKey(point[0], point[1])) ?? point;
|
||||
}
|
||||
|
||||
function maskPath(path: [number, number][] | null, hiddenCoordMask: Map<string, [number, number]>): [number, number][] | null {
|
||||
if (!path || path.length < 1 || hiddenCoordMask.size < 1) return path;
|
||||
return path.map((point) => maskPoint(point, hiddenCoordMask));
|
||||
}
|
||||
|
||||
function maskSegments(
|
||||
segments: Array<[[number, number], [number, number]]>,
|
||||
hiddenCoordMask: Map<string, [number, number]>,
|
||||
): Array<[[number, number], [number, number]]> {
|
||||
if (segments.length < 1 || hiddenCoordMask.size < 1) return segments;
|
||||
return segments.map(([a, b]) => [maskPoint(a, hiddenCoordMask), maskPoint(b, hiddenCoordMask)]);
|
||||
}
|
||||
|
||||
function maskResolvedPayload(
|
||||
payload: BetaResolvedPayload,
|
||||
hiddenCoordMask: Map<string, [number, number]>,
|
||||
): BetaResolvedPayload {
|
||||
if (hiddenCoordMask.size < 1) return payload;
|
||||
return {
|
||||
...payload,
|
||||
purplePath: maskPath(payload.purplePath, hiddenCoordMask),
|
||||
extraPurplePaths: payload.extraPurplePaths.map((path) => maskPath(path, hiddenCoordMask) ?? path),
|
||||
redPath: maskPath(payload.redPath, hiddenCoordMask),
|
||||
redSegments: maskSegments(payload.redSegments, hiddenCoordMask),
|
||||
completionPaths: payload.completionPaths.map((path) => maskPath(path, hiddenCoordMask) ?? path),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveBetaPathForPacketHash(packetHash: string, network: string, observer?: string): Promise<BetaResolvedPayload | null> {
|
||||
const [packetResult, observerHopResult] = await Promise.all([
|
||||
query<PathPacket>(
|
||||
@@ -1148,12 +1273,14 @@ export async function resolveBetaPathForPacketHash(packetHash: string, network:
|
||||
const packet = packetResult.rows[0];
|
||||
if (!packet) return null;
|
||||
const context = await loadContext(network);
|
||||
const hiddenCoordMask = buildHiddenCoordMask(context.nodesById);
|
||||
const applyHiddenMask = (payload: BetaResolvedPayload) => maskResolvedPayload(payload, hiddenCoordMask);
|
||||
const logPrefix = `[path-beta] hash=${packetHash} network=${network}`;
|
||||
|
||||
const rx = packet.rx_node_id ? context.nodesById.get(packet.rx_node_id) : undefined;
|
||||
if (!hasCoords(rx)) {
|
||||
console.log(`${logPrefix} mode=none reason=missing-rx-coords`);
|
||||
return {
|
||||
return applyHiddenMask({
|
||||
ok: true,
|
||||
packetHash,
|
||||
mode: 'none',
|
||||
@@ -1173,7 +1300,7 @@ export async function resolveBetaPathForPacketHash(packetHash: string, network:
|
||||
srcNodeId: packet.src_node_id,
|
||||
computedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const src = packet.src_node_id ? (context.nodesById.get(packet.src_node_id) ?? null) : null;
|
||||
@@ -1208,7 +1335,7 @@ export async function resolveBetaPathForPacketHash(packetHash: string, network:
|
||||
|
||||
if (hops.length < 1) {
|
||||
console.log(`${logPrefix} mode=none reason=no-hops rx=${packet.rx_node_id ?? 'unknown'} src=${packet.src_node_id ?? 'unknown'}`);
|
||||
return {
|
||||
return applyHiddenMask({
|
||||
ok: true,
|
||||
packetHash,
|
||||
mode: 'none',
|
||||
@@ -1228,7 +1355,7 @@ export async function resolveBetaPathForPacketHash(packetHash: string, network:
|
||||
srcNodeId: packet.src_node_id,
|
||||
computedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if ((packet.path_hash_size_bytes ?? 1) > 1) {
|
||||
@@ -1237,7 +1364,7 @@ export async function resolveBetaPathForPacketHash(packetHash: string, network:
|
||||
console.log(
|
||||
`${logPrefix} mode=resolved color=purple-only reason=exact-multibyte-chain conf=1.0000 threshold=${BETA_PURPLE_THRESHOLD.toFixed(2)} hops=${hops.length} purpleEdges=${Math.max(0, exactMultibyte.path.length - 1)} redEdges=0 remaining=0 rx=${packet.rx_node_id ?? 'unknown'} src=${packet.src_node_id ?? 'unknown'}`,
|
||||
);
|
||||
return {
|
||||
return applyHiddenMask({
|
||||
ok: true,
|
||||
packetHash,
|
||||
mode: 'resolved',
|
||||
@@ -1257,7 +1384,7 @@ export async function resolveBetaPathForPacketHash(packetHash: string, network:
|
||||
srcNodeId: packet.src_node_id,
|
||||
computedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1368,7 +1495,7 @@ export async function resolveBetaPathForPacketHash(packetHash: string, network:
|
||||
`purpleEdges=${purpleEdges} redEdges=${redEdges} remaining=${(split.remainingHops ?? 0) + unresolvedBySolver} ` +
|
||||
`rx=${packet.rx_node_id ?? 'unknown'} src=${packet.src_node_id ?? 'unknown'}`,
|
||||
);
|
||||
return {
|
||||
return applyHiddenMask({
|
||||
ok: true,
|
||||
packetHash,
|
||||
mode: 'resolved',
|
||||
@@ -1388,7 +1515,7 @@ export async function resolveBetaPathForPacketHash(packetHash: string, network:
|
||||
srcNodeId: packet.src_node_id,
|
||||
computedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const fallback = buildFallbackPrefixPath(
|
||||
@@ -1426,7 +1553,7 @@ export async function resolveBetaPathForPacketHash(packetHash: string, network:
|
||||
`${logPrefix} mode=fallback color=full-red reason=beta-solver-no-solution-prefix-fallback conf=null hops=${hops.length} purpleEdges=0 redEdges=${redEdges} ` +
|
||||
`permutations=${permutationCount} remaining=unknown rx=${packet.rx_node_id ?? 'unknown'} src=${packet.src_node_id ?? 'unknown'}`,
|
||||
);
|
||||
return {
|
||||
return applyHiddenMask({
|
||||
ok: true,
|
||||
packetHash,
|
||||
mode: 'fallback',
|
||||
@@ -1446,13 +1573,13 @@ export async function resolveBetaPathForPacketHash(packetHash: string, network:
|
||||
srcNodeId: packet.src_node_id,
|
||||
computedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${logPrefix} mode=none reason=unresolved hops=${hops.length} rx=${packet.rx_node_id ?? 'unknown'} src=${packet.src_node_id ?? 'unknown'}`,
|
||||
);
|
||||
return {
|
||||
return applyHiddenMask({
|
||||
ok: true,
|
||||
packetHash,
|
||||
mode: 'none',
|
||||
@@ -1472,7 +1599,7 @@ export async function resolveBetaPathForPacketHash(packetHash: string, network:
|
||||
srcNodeId: packet.src_node_id,
|
||||
computedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1544,6 +1671,7 @@ export async function resolveMultiObserverBetaPath(
|
||||
}
|
||||
|
||||
const context = await loadContext(network);
|
||||
const hiddenCoordMask = buildHiddenCoordMask(context.nodesById);
|
||||
const logPrefix = `[path-beta-multi] hash=${packetHash} network=${network}`;
|
||||
|
||||
// 3. Build per-observer data
|
||||
@@ -1670,7 +1798,7 @@ export async function resolveMultiObserverBetaPath(
|
||||
forceIncludeSource,
|
||||
observerHopHints,
|
||||
);
|
||||
results.push(result);
|
||||
results.push(maskResolvedPayload(result, hiddenCoordMask));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1709,7 +1837,7 @@ export async function resolveMultiObserverBetaPath(
|
||||
forceIncludeSource,
|
||||
observerHopHints,
|
||||
);
|
||||
results.push(result);
|
||||
results.push(maskResolvedPayload(result, hiddenCoordMask));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1736,7 +1864,7 @@ export async function resolveMultiObserverBetaPath(
|
||||
forceIncludeSource,
|
||||
observerHopHints,
|
||||
);
|
||||
results.push(result);
|
||||
results.push(maskResolvedPayload(result, hiddenCoordMask));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1793,7 +1921,7 @@ export async function resolveMultiObserverBetaPath(
|
||||
permutationCount = permutations.totalCount;
|
||||
}
|
||||
|
||||
results.push({
|
||||
results.push(maskResolvedPayload({
|
||||
ok: true,
|
||||
packetHash,
|
||||
mode: 'fallback',
|
||||
@@ -1813,12 +1941,12 @@ export async function resolveMultiObserverBetaPath(
|
||||
srcNodeId: srcNodeId,
|
||||
computedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}, hiddenCoordMask));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Complete failure for this observer
|
||||
results.push({
|
||||
results.push(maskResolvedPayload({
|
||||
ok: true,
|
||||
packetHash,
|
||||
mode: 'none',
|
||||
@@ -1838,7 +1966,7 @@ export async function resolveMultiObserverBetaPath(
|
||||
srcNodeId: srcNodeId,
|
||||
computedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}, hiddenCoordMask));
|
||||
}
|
||||
|
||||
const resolvedCount = results.filter((r) => r.mode === 'resolved').length;
|
||||
|
||||
@@ -6,6 +6,7 @@ export type MeshNode = {
|
||||
iata: string | null;
|
||||
role: number | null;
|
||||
elevation_m: number | null;
|
||||
last_seen: string | null;
|
||||
};
|
||||
|
||||
export type LinkMetrics = {
|
||||
|
||||
+17
-4
@@ -53,6 +53,9 @@ export const App: React.FC = () => {
|
||||
const [inferredNodes, setInferredNodes] = useState<MeshNode[]>([]);
|
||||
const [inferredActiveNodeIds, setInferredActiveNodeIds] = useState<Set<string>>(new Set());
|
||||
const [packetHistorySegments, setPacketHistorySegments] = useState<PacketHistorySegment[]>([]);
|
||||
const [isPageVisible, setIsPageVisible] = useState(
|
||||
() => (typeof document === 'undefined' ? true : document.visibilityState === 'visible'),
|
||||
);
|
||||
const clashRestoreRef = useRef<{ links: boolean; coverage: boolean; clientNodes: boolean } | null>(null);
|
||||
const prevHexClashesRef = useRef<boolean>(DEFAULT_FILTERS.hexClashes);
|
||||
|
||||
@@ -106,6 +109,13 @@ export const App: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return undefined;
|
||||
const updateVisibility = () => setIsPageVisible(document.visibilityState === 'visible');
|
||||
document.addEventListener('visibilitychange', updateVisibility);
|
||||
return () => document.removeEventListener('visibilitychange', updateVisibility);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(FILTERS_KEY, JSON.stringify(filters));
|
||||
}, [filters]);
|
||||
@@ -140,16 +150,18 @@ export const App: React.FC = () => {
|
||||
};
|
||||
|
||||
void syncRecentPackets();
|
||||
const timer = window.setInterval(() => { void syncRecentPackets(); }, 4000);
|
||||
const pollMs = isPageVisible ? 4000 : 30000;
|
||||
const timer = window.setInterval(() => { void syncRecentPackets(); }, pollMs);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [networkFilter, observerFilter, replaceRecentPackets]);
|
||||
}, [isPageVisible, networkFilter, observerFilter, replaceRecentPackets]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const syncPacketHistory = async () => {
|
||||
if (!isPageVisible) return;
|
||||
try {
|
||||
const response = await fetch(
|
||||
uncachedEndpoint(withScopeParams('/api/path-beta/history', { network: networkFilter })),
|
||||
@@ -172,11 +184,12 @@ export const App: React.FC = () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [networkFilter]);
|
||||
}, [isPageVisible, networkFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const syncInferredNodes = async () => {
|
||||
if (!isPageVisible) return;
|
||||
try {
|
||||
const response = await fetch(
|
||||
uncachedEndpoint(withScopeParams('/api/inferred-nodes', { network: networkFilter, observer: observerFilter })),
|
||||
@@ -201,7 +214,7 @@ export const App: React.FC = () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [networkFilter, observerFilter]);
|
||||
}, [isPageVisible, networkFilter, observerFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
const wasHexClashes = prevHexClashesRef.current;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { MapContainer, TileLayer, useMap, Pane, Polygon, Polyline } from 'react-
|
||||
import type { LatLngExpression, Map as LeafletMap } from 'leaflet';
|
||||
import type { MeshNode, PacketArc } from '../../hooks/useNodes.js';
|
||||
import type { NodeCoverage } from '../../hooks/useCoverage.js';
|
||||
import { hasCoords } from '../../utils/pathing.js';
|
||||
import { buildHiddenCoordMask, hasCoords, maskNodePoint, maskPoint } from '../../utils/pathing.js';
|
||||
import type { LinkMetrics } from '../../utils/pathing.js';
|
||||
import { NodeMarker } from './NodeMarker.js';
|
||||
import { PacketArcLayer } from './PacketArcLayer.js';
|
||||
@@ -67,10 +67,6 @@ function geomToRings(geom: { type: string; coordinates: unknown } | null | undef
|
||||
return [];
|
||||
}
|
||||
|
||||
function isHiddenMapNode(node: MeshNode | null | undefined): boolean {
|
||||
return !hasCoords(node) || Boolean(node?.name?.includes('🚫'));
|
||||
}
|
||||
|
||||
// Raw outer rings from each coverage polygon — used for the green coverage display.
|
||||
// Using raw rings (not a union) with fillRule:'nonzero' means:
|
||||
// - overlapping viewsheds: winding numbers add (+1 per CCW ring) → always filled ✓
|
||||
@@ -186,6 +182,9 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(
|
||||
() => (typeof window !== 'undefined' ? window.matchMedia('(max-width: 640px)').matches : false),
|
||||
);
|
||||
const [isPageVisible, setIsPageVisible] = useState(
|
||||
() => (typeof document === 'undefined' ? true : document.visibilityState === 'visible'),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -196,6 +195,13 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
return () => mq.removeEventListener('change', update);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return undefined;
|
||||
const updateVisibility = () => setIsPageVisible(document.visibilityState === 'visible');
|
||||
document.addEventListener('visibilitychange', updateVisibility);
|
||||
return () => document.removeEventListener('visibilitychange', updateVisibility);
|
||||
}, []);
|
||||
|
||||
const aniFrameRef = useRef<number | null>(null);
|
||||
|
||||
// Animate marching dashes by incrementing stroke-dashoffset directly on the
|
||||
@@ -206,7 +212,7 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
const hasBeta = Boolean(showBetaPaths && (betaLowSegments.length > 0 || betaPaths.length > 0));
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasRegular && !hasBeta) {
|
||||
if (!hasRegular && !hasBeta || !isPageVisible) {
|
||||
if (aniFrameRef.current !== null) {
|
||||
cancelAnimationFrame(aniFrameRef.current);
|
||||
aniFrameRef.current = null;
|
||||
@@ -232,7 +238,7 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
aniFrameRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [hasRegular, hasBeta, map]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [hasRegular, hasBeta, isPageVisible, map]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const allNodesWithPos = useMemo(() => Array.from(nodes.values()).filter(
|
||||
@@ -286,6 +292,7 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
for (const c of coverage) m.set(c.node_id, c);
|
||||
return m;
|
||||
}, [coverage]);
|
||||
const hiddenCoordMask = useMemo(() => buildHiddenCoordMask(nodes.values()), [nodes]);
|
||||
|
||||
const distKm = useCallback((a: MeshNode, b: MeshNode) => {
|
||||
if (!hasCoords(a) || !hasCoords(b)) return Number.POSITIVE_INFINITY;
|
||||
@@ -517,7 +524,7 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
const metrics = linkMetrics.get(key);
|
||||
lines.push({
|
||||
key,
|
||||
positions: [[a.lat, a.lon], [b.lat, b.lon]],
|
||||
positions: [maskNodePoint(a, hiddenCoordMask), maskNodePoint(b, hiddenCoordMask)],
|
||||
observedCount: metrics?.observed_count ?? 0,
|
||||
pathLossDb: metrics?.itm_path_loss_db,
|
||||
countAToB: metrics?.count_a_to_b ?? 0,
|
||||
@@ -532,7 +539,7 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
.sort((a, b) => (b.observedCount - a.observedCount))
|
||||
.slice(0, 400);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showLinks, viablePairsArr, linkedNodesKey, linkMetrics, map, lineInView]);
|
||||
}, [showLinks, viablePairsArr, linkedNodesKey, linkMetrics, map, lineInView, hiddenCoordMask]);
|
||||
|
||||
const visibleClashPathLines = useMemo(
|
||||
() => clashPathLines.filter((line) => lineInView(line.positions)),
|
||||
@@ -556,13 +563,21 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
|
||||
const visiblePacketHistorySegments = useMemo(() => {
|
||||
if (!showPacketHistory) return [];
|
||||
const segments = packetHistorySegments.filter((segment) => lineInView(segment.positions));
|
||||
const segments = packetHistorySegments
|
||||
.map((segment) => ({
|
||||
...segment,
|
||||
positions: [
|
||||
maskPoint(segment.positions[0], hiddenCoordMask),
|
||||
maskPoint(segment.positions[1], hiddenCoordMask),
|
||||
] as [[number, number], [number, number]],
|
||||
}))
|
||||
.filter((segment) => lineInView(segment.positions));
|
||||
const zoom = map?.getZoom() ?? DEFAULT_ZOOM;
|
||||
const limited = (zoom >= 9 || segments.length <= 700) ? segments : [...segments]
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 700);
|
||||
return [...limited].sort((a, b) => a.count - b.count);
|
||||
}, [showPacketHistory, packetHistorySegments, lineInView, map]);
|
||||
}, [showPacketHistory, packetHistorySegments, lineInView, map, hiddenCoordMask]);
|
||||
|
||||
const markerSize = useMemo(() => {
|
||||
const leafletZoom = deckViewState.zoom + 1;
|
||||
@@ -663,15 +678,17 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
|
||||
{/* Repeater markers — Leaflet default marker pane at zIndex 600 */}
|
||||
{visibleRepeaterNodes.map((node) => {
|
||||
if (isHiddenMapNode(node)) return null;
|
||||
if (!hasCoords(node)) return null;
|
||||
if (showHexClashes && !clashVisibleNodeIds.has(node.node_id)) return null;
|
||||
const isFocusVisible = clashVisibleNodeIds.has(node.node_id) || (focusedPrefixNodeIds?.has(node.node_id) ?? false);
|
||||
if (focusedPrefixNodeIds && focusHidePhase === 'hide' && !isFocusVisible) return null;
|
||||
const isStaleNode = (Date.now() - new Date(node.last_seen).getTime()) > STALE_MARKER_MS;
|
||||
const displayPosition = maskNodePoint(node, hiddenCoordMask);
|
||||
return (
|
||||
<NodeMarker
|
||||
key={node.node_id}
|
||||
node={node}
|
||||
displayPosition={displayPosition}
|
||||
isActive={activeNodes.has(node.node_id)}
|
||||
isInferred={isStaleNode && inferredActiveNodeIds.has(node.node_id.toLowerCase()) && !activeNodes.has(node.node_id)}
|
||||
isHighlighted={!!focusedPrefix && node.node_id.slice(0, 2).toUpperCase() === focusedPrefix}
|
||||
@@ -699,13 +716,14 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
|
||||
{/* Companion radio + room server markers (toggled via filter) */}
|
||||
{showClientNodes && !showHexClashes && visibleClientNodes.map((node) => {
|
||||
if (isHiddenMapNode(node)) return null;
|
||||
if (!hasCoords(node)) return null;
|
||||
const isFocusVisible = clashVisibleNodeIds.has(node.node_id);
|
||||
if (focusedPrefixNodeIds && focusHidePhase === 'hide' && !isFocusVisible) return null;
|
||||
return (
|
||||
<NodeMarker
|
||||
key={node.node_id}
|
||||
node={node}
|
||||
displayPosition={maskNodePoint(node, hiddenCoordMask)}
|
||||
isActive={activeNodes.has(node.node_id)}
|
||||
isRestoring={!!focusedPrefixNodeIds && focusHidePhase === 'fade' && !isFocusVisible}
|
||||
nodeCoverage={coverageByNodeId.get(node.node_id)}
|
||||
@@ -743,7 +761,7 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
{showBetaPaths && betaLowSegments.map(([a, b], idx) => (
|
||||
<Polyline
|
||||
key={`beta-low-seg-${idx}`}
|
||||
positions={[a, b]}
|
||||
positions={[maskPoint(a, hiddenCoordMask), maskPoint(b, hiddenCoordMask)]}
|
||||
className="beta-red-path-overlay"
|
||||
pathOptions={{
|
||||
color: '#ef4444',
|
||||
@@ -759,7 +777,7 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
{showBetaPaths && betaPaths.map((path, idx) => (
|
||||
<Polyline
|
||||
key={`beta-purple-${idx}`}
|
||||
positions={path}
|
||||
positions={path.map((point) => maskPoint(point, hiddenCoordMask))}
|
||||
className="beta-purple-path-overlay"
|
||||
pathOptions={{
|
||||
color: '#a855f7',
|
||||
@@ -775,7 +793,7 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
{betaCompletionPaths.map((path, idx) => (
|
||||
<Polyline
|
||||
key={`beta-completion-${idx}`}
|
||||
positions={path}
|
||||
positions={path.map((point) => maskPoint(point, hiddenCoordMask))}
|
||||
pathOptions={{
|
||||
color: '#ef4444',
|
||||
weight: 1.8,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Marker, Popup, Polygon, Pane } from 'react-leaflet';
|
||||
import { Circle, Marker, Popup, Polygon, Pane } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import type { LatLngExpression } from 'leaflet';
|
||||
import type { MeshNode } from '../../hooks/useNodes.js';
|
||||
import type { NodeCoverage } from '../../hooks/useCoverage.js';
|
||||
import { isValidMapCoord } from '../../utils/pathing.js';
|
||||
import { HIDDEN_NODE_MASK_RADIUS_METERS, isProhibitedMapNode, isValidMapCoord } from '../../utils/pathing.js';
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const PREVIEW_TTL_MS = 20_000;
|
||||
@@ -103,6 +103,7 @@ interface NodeLink {
|
||||
|
||||
interface Props {
|
||||
node: MeshNode;
|
||||
displayPosition?: [number, number];
|
||||
isActive: boolean;
|
||||
isInferred?: boolean;
|
||||
nodeCoverage?: NodeCoverage;
|
||||
@@ -117,6 +118,7 @@ interface Props {
|
||||
|
||||
export const NodeMarker: React.FC<Props> = React.memo(({
|
||||
node,
|
||||
displayPosition,
|
||||
isActive,
|
||||
isInferred = false,
|
||||
nodeCoverage,
|
||||
@@ -140,15 +142,19 @@ export const NodeMarker: React.FC<Props> = React.memo(({
|
||||
timerRef.current = setTimeout(() => setShowPreview(false), PREVIEW_TTL_MS);
|
||||
};
|
||||
|
||||
if (!isValidMapCoord(node.lat, node.lon)) return null;
|
||||
const prohibited = isProhibitedMapNode(node);
|
||||
const markerLat = displayPosition?.[0] ?? node.lat;
|
||||
const markerLon = displayPosition?.[1] ?? node.lon;
|
||||
if (!isValidMapCoord(markerLat, markerLon)) return null;
|
||||
|
||||
const lat = node.lat as number;
|
||||
const lon = node.lon as number;
|
||||
const lat = markerLat as number;
|
||||
const lon = markerLon as number;
|
||||
const ageMs = Date.now() - new Date(node.last_seen).getTime();
|
||||
const isStale = ageMs > SEVEN_DAYS_MS;
|
||||
const variant = (isInferred || node.is_inferred) ? 'inferred' : roleVariant(node.role);
|
||||
|
||||
const fallbackName = ROLE_LABELS[node.role ?? 2] ?? 'Unknown Device';
|
||||
const displayName = prohibited ? `Redacted ${fallbackName}` : (node.name ?? `Unknown ${fallbackName}`);
|
||||
|
||||
const statusLabel = isStale
|
||||
? 'STALE'
|
||||
@@ -181,7 +187,7 @@ export const NodeMarker: React.FC<Props> = React.memo(({
|
||||
},
|
||||
}}>
|
||||
<div className="node-popup">
|
||||
<div className="node-popup__name">{node.name ?? `Unknown ${fallbackName}`}</div>
|
||||
<div className="node-popup__name">{displayName}</div>
|
||||
{node.role !== undefined && node.role !== 2 && (
|
||||
<div className="node-popup__row">
|
||||
<span>Type</span>
|
||||
@@ -238,6 +244,12 @@ export const NodeMarker: React.FC<Props> = React.memo(({
|
||||
<span>Position</span>
|
||||
<span>{lat.toFixed(5)}, {lon.toFixed(5)}</span>
|
||||
</div>
|
||||
{prohibited && (
|
||||
<div className="node-popup__row">
|
||||
<span>Location</span>
|
||||
<span>Redacted within 1 mile radius</span>
|
||||
</div>
|
||||
)}
|
||||
{node.elevation_m !== undefined && node.elevation_m !== null && (
|
||||
<div className="node-popup__row">
|
||||
<span>Elevation</span>
|
||||
@@ -290,6 +302,22 @@ export const NodeMarker: React.FC<Props> = React.memo(({
|
||||
</Popup>
|
||||
</Marker>
|
||||
|
||||
{prohibited && (
|
||||
<Circle
|
||||
center={[lat, lon]}
|
||||
radius={HIDDEN_NODE_MASK_RADIUS_METERS}
|
||||
pathOptions={{
|
||||
color: '#f59e0b',
|
||||
weight: 1.4,
|
||||
opacity: 0.55,
|
||||
fillColor: '#f59e0b',
|
||||
fillOpacity: 0.05,
|
||||
dashArray: '4 6',
|
||||
}}
|
||||
interactive={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(previewBands.red.length > 0 || previewBands.amber.length > 0 || previewBands.green.length > 0) && (
|
||||
<Pane name={`cov-preview-${node.node_id}`} style={{ zIndex: 351 }}>
|
||||
{previewBands.red.length > 0 && (
|
||||
|
||||
@@ -52,16 +52,31 @@ function buildArcLayers(arcs: PacketArc[], now: number, show: boolean) {
|
||||
|
||||
export const PacketArcLayer: React.FC<Props> = ({ arcs, showArcs, viewState }) => {
|
||||
const [now, setNow] = useState<number>(() => Date.now());
|
||||
const [isPageVisible, setIsPageVisible] = useState(
|
||||
() => (typeof document === 'undefined' ? true : document.visibilityState === 'visible'),
|
||||
);
|
||||
const rafRef = useRef<ReturnType<typeof requestAnimationFrame>>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return undefined;
|
||||
const updateVisibility = () => setIsPageVisible(document.visibilityState === 'visible');
|
||||
document.addEventListener('visibilitychange', updateVisibility);
|
||||
return () => document.removeEventListener('visibilitychange', updateVisibility);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const hasVisibleArcs = showArcs && arcs.some((arc) => Date.now() - arc.ts < ARC_TTL);
|
||||
if (!isPageVisible || !hasVisibleArcs) {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
return undefined;
|
||||
}
|
||||
const tick = () => {
|
||||
setNow(Date.now());
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, []);
|
||||
}, [arcs, isPageVisible, showArcs]);
|
||||
|
||||
const layers = buildArcLayers(arcs, now, showArcs);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Filters } from '../components/FilterPanel/FilterPanel.js';
|
||||
import type { AggregatedPacket, MeshNode } from './useNodes.js';
|
||||
import { hasCoords, resolvePathWaypoints } from '../utils/pathing.js';
|
||||
import { buildHiddenCoordMask, hasCoords, maskNodePoint, resolvePathWaypoints } from '../utils/pathing.js';
|
||||
|
||||
export type PathSegment = [[number, number], [number, number]];
|
||||
|
||||
@@ -63,14 +63,15 @@ export function buildRegularPacketPaths(
|
||||
nodes: Map<string, MeshNode>,
|
||||
): [number, number][][] {
|
||||
if (!packet || observerIds.length < 1 || (!packet.path?.length && !packet.srcNodeId)) return [];
|
||||
const hiddenCoordMask = buildHiddenCoordMask(nodes.values());
|
||||
const src = packet.srcNodeId ? (nodes.get(packet.srcNodeId) ?? null) : null;
|
||||
const srcWithPos = hasCoords(src) ? src : null;
|
||||
return observerIds.flatMap((observerId) => {
|
||||
const rx = nodes.get(observerId);
|
||||
if (!hasCoords(rx)) return [];
|
||||
const waypoints = packet.path?.length
|
||||
? resolvePathWaypoints(packet.path, srcWithPos, rx, nodes)
|
||||
: (srcWithPos ? [[srcWithPos.lat, srcWithPos.lon], [rx.lat, rx.lon]] as [number, number][] : []);
|
||||
? resolvePathWaypoints(packet.path, srcWithPos, rx, nodes, hiddenCoordMask)
|
||||
: (srcWithPos ? [maskNodePoint(srcWithPos, hiddenCoordMask), maskNodePoint(rx, hiddenCoordMask)] as [number, number][] : []);
|
||||
return waypoints.length >= 2 ? [waypoints] : [];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
const PATH_TTL = 5_000;
|
||||
const PREDICTION_CACHE_TTL_MS = 120_000;
|
||||
const MAX_PREDICTION_CACHE = 1200;
|
||||
const RECENT_PREDICTION_TTL_MS = 45_000;
|
||||
const MAX_RECENT_PREDICTIONS = 48;
|
||||
|
||||
type UsePacketPathOverlayParams = {
|
||||
packets: AggregatedPacket[];
|
||||
@@ -78,6 +80,9 @@ export function usePacketPathOverlay({
|
||||
const [pinnedPacketId, setPinnedPacketId] = useState<string | null>(null);
|
||||
const [pinnedPacketSnapshot, setPinnedPacketSnapshot] = useState<AggregatedPacket | null>(null);
|
||||
const [pathOpacity, setPathOpacity] = useState(0.75);
|
||||
const [isPageVisible, setIsPageVisible] = useState(
|
||||
() => (typeof document === 'undefined' ? true : document.visibilityState === 'visible'),
|
||||
);
|
||||
|
||||
const pinnedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pathTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -116,6 +121,27 @@ export function usePacketPathOverlay({
|
||||
setPathOpacity(0.75);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return undefined;
|
||||
const updateVisibility = () => setIsPageVisible(document.visibilityState === 'visible');
|
||||
document.addEventListener('visibilitychange', updateVisibility);
|
||||
return () => document.removeEventListener('visibilitychange', updateVisibility);
|
||||
}, []);
|
||||
|
||||
const pruneRecentPredictions = useCallback(() => {
|
||||
const now = Date.now();
|
||||
const recent = recentPredictionsRef.current;
|
||||
for (const [key, value] of recent) {
|
||||
if (now - value.ts > RECENT_PREDICTION_TTL_MS) recent.delete(key);
|
||||
}
|
||||
if (recent.size <= MAX_RECENT_PREDICTIONS) return;
|
||||
const sorted = Array.from(recent.entries()).sort((a, b) => a[1].ts - b[1].ts);
|
||||
for (let i = 0; i < Math.max(0, recent.size - MAX_RECENT_PREDICTIONS); i += 1) {
|
||||
const key = sorted[i]?.[0];
|
||||
if (key) recent.delete(key);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyServerPredictions = useCallback((
|
||||
packetHash: string,
|
||||
predictions: Array<ServerBetaResponse | null>,
|
||||
@@ -126,8 +152,9 @@ export function usePacketPathOverlay({
|
||||
) => {
|
||||
const validPredictions = predictions.filter((prediction): prediction is ServerBetaResponse => Boolean(prediction?.ok));
|
||||
if (validPredictions.length < 1) {
|
||||
pruneRecentPredictions();
|
||||
const recent = recentPredictionsRef.current.get(packetHash);
|
||||
if (!recent || Date.now() - recent.ts > 45_000) {
|
||||
if (!recent || Date.now() - recent.ts > RECENT_PREDICTION_TTL_MS) {
|
||||
setBetaPacketPaths([]);
|
||||
setBetaLowConfidencePaths([]);
|
||||
setBetaLowConfidenceSegments([]);
|
||||
@@ -157,7 +184,8 @@ export function usePacketPathOverlay({
|
||||
setBetaRemainingHops(aggregated.remainingHops);
|
||||
|
||||
recentPredictionsRef.current.set(packetHash, { ...aggregated, ts: Date.now() });
|
||||
}, []);
|
||||
pruneRecentPredictions();
|
||||
}, [pruneRecentPredictions]);
|
||||
|
||||
const prunePredictionCache = useCallback(() => {
|
||||
const now = Date.now();
|
||||
@@ -255,11 +283,17 @@ export function usePacketPathOverlay({
|
||||
useEffect(() => {
|
||||
if (pinnedPacketId !== null) return;
|
||||
stopPathTimers();
|
||||
pruneRecentPredictions();
|
||||
|
||||
const latest = packets[0];
|
||||
const observerIds = getPacketObserverIds(latest);
|
||||
setPacketPaths([]);
|
||||
|
||||
if (!isPageVisible) {
|
||||
setPathOpacity(0.75);
|
||||
return;
|
||||
}
|
||||
|
||||
if (filters.betaPaths && latest?.packetHash && latest.path?.length && observerIds.length > 0) {
|
||||
const reqSeq = ++activeReqSeqRef.current;
|
||||
const resolveFn = observerIds.length > 1
|
||||
@@ -316,7 +350,7 @@ export function usePacketPathOverlay({
|
||||
pathFadeRef.current = requestAnimationFrame(animate);
|
||||
}, PATH_TTL - 1_000);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [latestId, filters.betaPaths, pinnedPacketId, network, observer, packets, getPacketObserverIds, resolvePrediction, resolveMultiPrediction, stopPathTimers, clearPathState, applyServerPredictions]);
|
||||
}, [latestId, filters.betaPaths, pinnedPacketId, network, observer, packets, getPacketObserverIds, resolvePrediction, resolveMultiPrediction, stopPathTimers, clearPathState, applyServerPredictions, isPageVisible, pruneRecentPredictions]);
|
||||
|
||||
const handlePacketPin = useCallback((packet: AggregatedPacket) => {
|
||||
if (pinnedPacketId === packet.id) {
|
||||
@@ -368,6 +402,9 @@ export function usePacketPathOverlay({
|
||||
pinnedOverlayKeyRef.current = '';
|
||||
return;
|
||||
}
|
||||
pruneRecentPredictions();
|
||||
|
||||
if (!isPageVisible) return;
|
||||
|
||||
const pinnedPacket = packets.find((packet) => packet.id === pinnedPacketId) ?? pinnedPacketSnapshot;
|
||||
if (!pinnedPacket) return;
|
||||
@@ -430,6 +467,8 @@ export function usePacketPathOverlay({
|
||||
resolvePrediction,
|
||||
resolveMultiPrediction,
|
||||
applyServerPredictions,
|
||||
isPageVisible,
|
||||
pruneRecentPredictions,
|
||||
]);
|
||||
|
||||
useEffect(() => () => {
|
||||
|
||||
@@ -583,7 +583,6 @@ export const OwnerPortalPage: React.FC = () => {
|
||||
<div className="site-stat"><span className="site-stat__value">{live?.ownerNode.advert_count ?? 0}</span><span className="site-stat__label">Adverts</span></div>
|
||||
<div className="site-stat"><span className="site-stat__value">{fmtTs(live?.ownerNode.last_seen ?? null)}</span><span className="site-stat__label">Last Seen</span></div>
|
||||
<div className="site-stat"><span className="site-stat__value">{live?.incomingPeers.length ?? 0}</span><span className="site-stat__label">Direct Senders (24h)</span></div>
|
||||
<div className="site-stat"><span className="site-stat__value">{live?.heardBy.length ?? 0}</span><span className="site-stat__label">Heard By (7d)</span></div>
|
||||
<div className="site-stat"><span className="site-stat__value">{viableLinkCount}</span><span className="site-stat__label">Viable Links</span></div>
|
||||
<div className="site-stat"><span className="site-stat__value">{strongestLink?.peer_name ?? '-'}</span><span className="site-stat__label">Strongest Link</span></div>
|
||||
<div className="site-stat"><span className="site-stat__value">{formatPathLoss(strongestLink?.itm_path_loss_db ?? null)}</span><span className="site-stat__label">Best Path Loss</span></div>
|
||||
@@ -719,28 +718,6 @@ export const OwnerPortalPage: React.FC = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="prose-section owner-panel owner-panel--heard">
|
||||
<div className="owner-panel__head"><h2>Heard By</h2></div>
|
||||
<div className="owner-list">
|
||||
{(live?.heardBy ?? []).slice(0, 8).map((peer) => (
|
||||
<article key={peer.node_id} className="owner-list__row">
|
||||
<div className="owner-list__primary">
|
||||
<strong>{peer.name ?? peer.node_id}</strong>
|
||||
<span>{peer.network ?? '-'} · {peer.iata ?? '-'}</span>
|
||||
</div>
|
||||
<div className="owner-list__metrics">
|
||||
<span>{peer.packets_24h} / 24h</span>
|
||||
<span>{peer.packets_7d} / 7d</span>
|
||||
<span>{peer.best_hops == null ? '-' : `${peer.best_hops} hops`}</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{(live?.heardBy ?? []).length === 0 ? (
|
||||
<p className="prose-note">No nodes have heard this repeater in the last 7 days.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="prose-section owner-panel owner-panel--senders">
|
||||
<div className="owner-panel__head"><h2>Direct Senders</h2></div>
|
||||
<div className="owner-list">
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid,
|
||||
Tooltip, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { CircleMarker, MapContainer, Polyline, TileLayer, Tooltip as LeafletTooltip, useMap } from 'react-leaflet';
|
||||
import { getCurrentSite } from '../config/site.js';
|
||||
import { chartStatsEndpoint, uncachedEndpoint } from '../utils/api.js';
|
||||
|
||||
@@ -56,6 +57,7 @@ interface ChartData {
|
||||
prefixCollisions:{ prefix: string; repeats: number }[];
|
||||
observerRegions: {
|
||||
iata: string;
|
||||
activeObservers: number;
|
||||
observers: number;
|
||||
packets24h: number;
|
||||
packets7d: number;
|
||||
@@ -71,6 +73,29 @@ interface ChartData {
|
||||
multibytePackets24h: number;
|
||||
fullyDecodedMultibyte24h: number;
|
||||
latestMultibyteAt: string | null;
|
||||
latestMultibyteHash: string | null;
|
||||
latestFullyDecodedAt: string | null;
|
||||
latestFullyDecodedHash: string | null;
|
||||
latestFullyDecodedHops: number | null;
|
||||
latestFullyDecodedPath: string | null;
|
||||
latestFullyDecodedNodes: Array<{
|
||||
ord: number;
|
||||
node_id: string;
|
||||
name: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
}>;
|
||||
longestFullyDecodedAt: string | null;
|
||||
longestFullyDecodedHash: string | null;
|
||||
longestFullyDecodedHops: number | null;
|
||||
longestFullyDecodedPath: string | null;
|
||||
longestFullyDecodedNodes: Array<{
|
||||
ord: number;
|
||||
node_id: string;
|
||||
name: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
}>;
|
||||
};
|
||||
summary: {
|
||||
totalPackets24h: number;
|
||||
@@ -154,12 +179,39 @@ const ChartCard: React.FC<{ title: string; sub?: string; children: React.ReactNo
|
||||
</div>
|
||||
);
|
||||
|
||||
const FitDecodedPath: React.FC<{ points: [number, number][] }> = ({ points }) => {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (points.length < 1) return;
|
||||
if (points.length === 1) {
|
||||
map.setView(points[0], 11);
|
||||
return;
|
||||
}
|
||||
map.fitBounds(points, { padding: [24, 24] });
|
||||
}, [map, points]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||
export const StatsPage: React.FC = () => {
|
||||
const [data, setData] = useState<ChartData | null>(null);
|
||||
const [health, setHealth] = useState<HealthPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||
const [selectedDecodedPath, setSelectedDecodedPath] = useState<{
|
||||
title: string;
|
||||
hash: string | null;
|
||||
hops: number | null;
|
||||
nodes: Array<{
|
||||
ord: number;
|
||||
node_id: string;
|
||||
name: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
}>;
|
||||
} | null>(null);
|
||||
const site = getCurrentSite();
|
||||
const statsScope = { network: site.networkFilter, observer: site.observerId };
|
||||
const refreshSeconds = 30 * 60;
|
||||
@@ -205,11 +257,36 @@ export const StatsPage: React.FC = () => {
|
||||
if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
|
||||
return `${Math.floor(sec / 86400)}d ago`;
|
||||
};
|
||||
const decodedPathNodes = (data?.pathHashes.latestFullyDecodedNodes ?? []).filter(
|
||||
(node) => Number.isFinite(node.lat) && Number.isFinite(node.lon),
|
||||
);
|
||||
const decodedPathStart = decodedPathNodes[0]?.name ?? decodedPathNodes[0]?.node_id ?? null;
|
||||
const decodedPathEnd = decodedPathNodes[decodedPathNodes.length - 1]?.name ?? decodedPathNodes[decodedPathNodes.length - 1]?.node_id ?? null;
|
||||
const decodedPathSummary = decodedPathStart && decodedPathEnd
|
||||
? decodedPathStart === decodedPathEnd
|
||||
? decodedPathStart
|
||||
: `${decodedPathStart} -> ${decodedPathEnd}`
|
||||
: data?.pathHashes.latestFullyDecodedPath ?? 'not decoded yet';
|
||||
const longestDecodedPathNodes = (data?.pathHashes.longestFullyDecodedNodes ?? []).filter(
|
||||
(node) => Number.isFinite(node.lat) && Number.isFinite(node.lon),
|
||||
);
|
||||
const longestDecodedPathStart = longestDecodedPathNodes[0]?.name ?? longestDecodedPathNodes[0]?.node_id ?? null;
|
||||
const longestDecodedPathEnd = longestDecodedPathNodes[longestDecodedPathNodes.length - 1]?.name ?? longestDecodedPathNodes[longestDecodedPathNodes.length - 1]?.node_id ?? null;
|
||||
const longestDecodedPathSummary = longestDecodedPathStart && longestDecodedPathEnd
|
||||
? longestDecodedPathStart === longestDecodedPathEnd
|
||||
? longestDecodedPathStart
|
||||
: `${longestDecodedPathStart} -> ${longestDecodedPathEnd}`
|
||||
: data?.pathHashes.longestFullyDecodedPath ?? 'not decoded yet';
|
||||
const selectedDecodedPathNodes = (selectedDecodedPath?.nodes ?? []).filter(
|
||||
(node) => Number.isFinite(node.lat) && Number.isFinite(node.lon),
|
||||
);
|
||||
const selectedDecodedPathPoints = selectedDecodedPathNodes.map((node) => [Number(node.lat), Number(node.lon)] as [number, number]);
|
||||
const isRedactedDecodedNode = (node: { name: string | null }) => node.name === 'Redacted repeater';
|
||||
|
||||
return (
|
||||
<div className="site-layout__inner">
|
||||
{/* ── Page hero ─────────────────────────────────────────────────────── */}
|
||||
<section className="site-page-hero">
|
||||
<section className="site-page-hero site-page-hero--stats">
|
||||
<div className="site-content">
|
||||
<h1 className="site-page-hero__title">Network Stats</h1>
|
||||
<p className="site-page-hero__sub">
|
||||
@@ -286,7 +363,7 @@ export const StatsPage: React.FC = () => {
|
||||
</div>
|
||||
<div className="stats-page__observer-metric">
|
||||
<span>Observers</span>
|
||||
<strong>{fmt(region.observers)}</strong>
|
||||
<strong>{fmt(region.activeObservers)}|{fmt(region.observers)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stats-page__observer-chart">
|
||||
@@ -351,7 +428,61 @@ export const StatsPage: React.FC = () => {
|
||||
<div className="health-meta">
|
||||
<div className="health-kv">
|
||||
<span>Latest Multibyte Packet</span>
|
||||
<strong>{data.pathHashes.latestMultibyteAt ? timeAgo(data.pathHashes.latestMultibyteAt) : 'not seen yet'}</strong>
|
||||
<strong>
|
||||
{data.pathHashes.latestMultibyteHash
|
||||
? `${data.pathHashes.latestMultibyteHash} · ${timeAgo(data.pathHashes.latestMultibyteAt)}`
|
||||
: 'not seen yet'}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="health-kv">
|
||||
<span>Last Fully Decoded Packet</span>
|
||||
<strong>
|
||||
{data.pathHashes.latestFullyDecodedHash
|
||||
? `${data.pathHashes.latestFullyDecodedHash} · ${data.pathHashes.latestFullyDecodedHops ?? 0} hops · ${timeAgo(data.pathHashes.latestFullyDecodedAt)}`
|
||||
: 'not decoded yet'}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="health-kv">
|
||||
<span>Decoded Path</span>
|
||||
{decodedPathNodes.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="stats-page__path-link"
|
||||
onClick={() => setSelectedDecodedPath({
|
||||
title: 'Last Fully Decoded Path',
|
||||
hash: data.pathHashes.latestFullyDecodedHash,
|
||||
hops: data.pathHashes.latestFullyDecodedHops,
|
||||
nodes: data.pathHashes.latestFullyDecodedNodes,
|
||||
})}
|
||||
>
|
||||
{decodedPathSummary}
|
||||
</button>
|
||||
) : (
|
||||
<strong>{decodedPathSummary}</strong>
|
||||
)}
|
||||
</div>
|
||||
<div className="health-kv">
|
||||
<span>Longest Decoded Path</span>
|
||||
{longestDecodedPathNodes.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="stats-page__path-link"
|
||||
onClick={() => setSelectedDecodedPath({
|
||||
title: 'Longest Fully Decoded Path',
|
||||
hash: data.pathHashes.longestFullyDecodedHash,
|
||||
hops: data.pathHashes.longestFullyDecodedHops,
|
||||
nodes: data.pathHashes.longestFullyDecodedNodes,
|
||||
})}
|
||||
>
|
||||
{`${longestDecodedPathSummary} · ${data.pathHashes.longestFullyDecodedHops ?? 0} hops`}
|
||||
</button>
|
||||
) : (
|
||||
<strong>
|
||||
{data.pathHashes.longestFullyDecodedHash
|
||||
? `${longestDecodedPathSummary} · ${data.pathHashes.longestFullyDecodedHops ?? 0} hops`
|
||||
: 'not decoded yet'}
|
||||
</strong>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -569,6 +700,72 @@ export const StatsPage: React.FC = () => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedDecodedPath && selectedDecodedPathPoints.length > 1 && (
|
||||
<div
|
||||
className="disclaimer-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Decoded path map"
|
||||
onClick={() => setSelectedDecodedPath(null)}
|
||||
>
|
||||
<div className="stats-page__path-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="stats-page__path-modal-header">
|
||||
<div>
|
||||
<h2 className="stats-page__path-modal-title">{selectedDecodedPath.title}</h2>
|
||||
<p className="stats-page__path-modal-sub">
|
||||
{selectedDecodedPath.hash} · {selectedDecodedPath.hops ?? 0} hops
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="disclaimer-modal__close stats-page__path-modal-close"
|
||||
onClick={() => setSelectedDecodedPath(null)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="stats-page__path-modal-map">
|
||||
<MapContainer
|
||||
center={selectedDecodedPathPoints[0]}
|
||||
zoom={8}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
scrollWheelZoom
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
attribution="© OpenStreetMap contributors © CARTO"
|
||||
/>
|
||||
<FitDecodedPath points={selectedDecodedPathPoints} />
|
||||
<Polyline positions={selectedDecodedPathPoints} pathOptions={{ color: C_PURPLE, weight: 4, opacity: 0.9 }} />
|
||||
{selectedDecodedPathNodes.map((node) => (
|
||||
<CircleMarker
|
||||
key={`${node.node_id}-${node.ord}`}
|
||||
center={[Number(node.lat), Number(node.lon)]}
|
||||
radius={10}
|
||||
pathOptions={{ color: C_CYAN, fillColor: '#0b1725', fillOpacity: 0.95, weight: 2 }}
|
||||
>
|
||||
<LeafletTooltip permanent direction="center" offset={[0, 0]} className="stats-page__path-node-label">
|
||||
{node.ord}
|
||||
</LeafletTooltip>
|
||||
</CircleMarker>
|
||||
))}
|
||||
</MapContainer>
|
||||
</div>
|
||||
<div className="stats-page__path-modal-list">
|
||||
{selectedDecodedPathNodes.map((node) => (
|
||||
<div key={`${node.node_id}-label-${node.ord}`} className="stats-page__path-modal-node">
|
||||
<span>{node.ord}</span>
|
||||
<strong>
|
||||
{node.name ?? node.node_id}
|
||||
{isRedactedDecodedNode(node) ? ' · approximate within 1 mile' : ''}
|
||||
</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import { getCurrentSite } from '../../config/site.js';
|
||||
import { uncachedEndpoint, withScopeParams } from '../../utils/api.js';
|
||||
|
||||
@@ -12,6 +13,40 @@ type DevNode = {
|
||||
public_key?: string;
|
||||
};
|
||||
|
||||
type DevStatusSample = {
|
||||
time: string;
|
||||
node_id: string;
|
||||
network?: string | null;
|
||||
battery_mv?: number | null;
|
||||
uptime_secs?: number | null;
|
||||
tx_air_secs?: number | null;
|
||||
rx_air_secs?: number | null;
|
||||
channel_utilization?: number | null;
|
||||
air_util_tx?: number | null;
|
||||
stats?: Record<string, unknown> | null;
|
||||
name?: string | null;
|
||||
iata?: string | null;
|
||||
hardware_model?: string | null;
|
||||
firmware_version?: string | null;
|
||||
};
|
||||
|
||||
type DevStatusHistoryPoint = {
|
||||
time: string;
|
||||
battery_mv?: number | null;
|
||||
uptime_secs?: number | null;
|
||||
channel_utilization?: number | null;
|
||||
air_util_tx?: number | null;
|
||||
heap_free?: number | null;
|
||||
heap_min_free?: number | null;
|
||||
tx_queue_depth?: number | null;
|
||||
tx_queue_depth_peak?: number | null;
|
||||
};
|
||||
|
||||
type DevStatusHistoryResponse = {
|
||||
nodeId: string | null;
|
||||
points: DevStatusHistoryPoint[];
|
||||
};
|
||||
|
||||
type DevPacket = {
|
||||
time: string;
|
||||
packet_hash: string;
|
||||
@@ -65,11 +100,175 @@ function packetSummary(packet: DevPacket): string {
|
||||
return String(candidate ?? 'No decoded summary');
|
||||
}
|
||||
|
||||
function labelize(key: string): string {
|
||||
return key
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/^./, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function formatTelemetryValue(value: unknown): string {
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value.toLocaleString() : String(value);
|
||||
if (typeof value === 'string') return value;
|
||||
if (value === null || value === undefined) return '—';
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function formatUptime(value: unknown, fallbackSeconds?: number | null): string {
|
||||
let totalSeconds: number | null = null;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
totalSeconds = value > 86_400 ? Math.floor(value / 1000) : Math.floor(value);
|
||||
} else if (fallbackSeconds != null && Number.isFinite(fallbackSeconds)) {
|
||||
totalSeconds = Math.floor(fallbackSeconds);
|
||||
}
|
||||
if (totalSeconds == null || totalSeconds < 0) return '—';
|
||||
const days = Math.floor(totalSeconds / 86400);
|
||||
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${Math.max(0, minutes)}m`;
|
||||
}
|
||||
|
||||
function formatKilobytes(value: unknown): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return '—';
|
||||
const kb = value / 1024;
|
||||
return `${kb >= 100 ? kb.toFixed(0) : kb.toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
function formatCompactNumber(value: unknown): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return '—';
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
const AXIS_COLOR = '#3a5070';
|
||||
const LABEL_COLOR = '#6b8aaa';
|
||||
|
||||
type TelemetryChartMetric = {
|
||||
key: keyof DevStatusHistoryPoint;
|
||||
title: string;
|
||||
color: string;
|
||||
formatValue: (value: number | null | undefined) => string;
|
||||
yDomain?: [number, number | 'auto'];
|
||||
};
|
||||
|
||||
const TELEMETRY_CHARTS: TelemetryChartMetric[] = [
|
||||
{
|
||||
key: 'channel_utilization',
|
||||
title: 'Channel Utilization',
|
||||
color: '#00c4ff',
|
||||
formatValue: (value) => value == null ? '—' : `${value.toFixed(1)}%`,
|
||||
yDomain: [0, 'auto'],
|
||||
},
|
||||
{
|
||||
key: 'air_util_tx',
|
||||
title: 'Air Util TX',
|
||||
color: '#ff9f43',
|
||||
formatValue: (value) => value == null ? '—' : `${value.toFixed(1)}%`,
|
||||
yDomain: [0, 'auto'],
|
||||
},
|
||||
{
|
||||
key: 'heap_free',
|
||||
title: 'Heap Free',
|
||||
color: '#ce93d8',
|
||||
formatValue: (value) => formatKilobytes(value),
|
||||
},
|
||||
{
|
||||
key: 'heap_min_free',
|
||||
title: 'Heap Min Free',
|
||||
color: '#8bc34a',
|
||||
formatValue: (value) => formatKilobytes(value),
|
||||
},
|
||||
{
|
||||
key: 'tx_queue_depth',
|
||||
title: 'TX Queue Depth',
|
||||
color: '#ff6b6b',
|
||||
formatValue: (value) => value == null ? '—' : formatCompactNumber(value),
|
||||
yDomain: [0, 'auto'],
|
||||
},
|
||||
{
|
||||
key: 'tx_queue_depth_peak',
|
||||
title: 'TX Queue Peak',
|
||||
color: '#ffd166',
|
||||
formatValue: (value) => value == null ? '—' : formatCompactNumber(value),
|
||||
yDomain: [0, 'auto'],
|
||||
},
|
||||
];
|
||||
|
||||
const GraphTooltip: React.FC<{
|
||||
active?: boolean;
|
||||
payload?: Array<{ value: number }>;
|
||||
label?: string;
|
||||
formatter: (value: number | null | undefined) => string;
|
||||
}> = ({ active, payload, label, formatter }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div className="dev-telemetry-chart__tooltip">
|
||||
{label ? <p>{new Date(label).toLocaleString()}</p> : null}
|
||||
<strong>{formatter(payload[0]?.value)}</strong>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TelemetryChartCard: React.FC<{
|
||||
title: string;
|
||||
color: string;
|
||||
points: DevStatusHistoryPoint[];
|
||||
metric: keyof DevStatusHistoryPoint;
|
||||
formatter: (value: number | null | undefined) => string;
|
||||
yDomain?: [number, number | 'auto'];
|
||||
}> = ({ title, color, points, metric, formatter, yDomain }) => {
|
||||
const chartData = points
|
||||
.map((point) => ({ time: point.time, value: point[metric] }))
|
||||
.filter((entry): entry is { time: string; value: number } => typeof entry.value === 'number' && Number.isFinite(entry.value));
|
||||
const latestValue = chartData.length > 0 ? chartData[chartData.length - 1]?.value : null;
|
||||
return (
|
||||
<article className="dev-telemetry-chart">
|
||||
<div className="dev-telemetry-chart__head">
|
||||
<div>
|
||||
<h3>{title}</h3>
|
||||
<p>Last 24h</p>
|
||||
</div>
|
||||
<strong>{formatter(latestValue)}</strong>
|
||||
</div>
|
||||
<div className="dev-telemetry-chart__body">
|
||||
{chartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 6, right: 6, left: 0, bottom: 0 }}>
|
||||
<XAxis dataKey="time" hide />
|
||||
<YAxis hide domain={yDomain ?? ['auto', 'auto']} axisLine={{ stroke: AXIS_COLOR }} tickLine={false} tick={{ fill: LABEL_COLOR, fontSize: 11 }} />
|
||||
<Tooltip content={<GraphTooltip formatter={formatter} />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={color}
|
||||
fill={color}
|
||||
fillOpacity={0.18}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="dev-telemetry-chart__empty">No samples yet</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="dev-telemetry-chart__foot">
|
||||
<span>{chartData.length} samples</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export const DevHomePage: React.FC = () => {
|
||||
const site = getCurrentSite();
|
||||
const scope = useMemo(() => ({ network: site.networkFilter, observer: site.observerId }), [site.networkFilter, site.observerId]);
|
||||
const [nodes, setNodes] = useState<DevNode[]>([]);
|
||||
const [packets, setPackets] = useState<DevPacket[]>([]);
|
||||
const [statusSamples, setStatusSamples] = useState<DevStatusSample[]>([]);
|
||||
const [statusHistory, setStatusHistory] = useState<DevStatusHistoryPoint[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -77,23 +276,39 @@ export const DevHomePage: React.FC = () => {
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [nodesRes, packetsRes] = await Promise.all([
|
||||
const [nodesRes, packetsRes, statusRes] = await Promise.all([
|
||||
fetch(uncachedEndpoint(withScopeParams('/api/nodes', scope)), { cache: 'no-store' }),
|
||||
fetch(uncachedEndpoint(withScopeParams('/api/packets/recent?limit=15&raw=1', scope)), { cache: 'no-store' }),
|
||||
fetch(uncachedEndpoint(withScopeParams('/api/node-status/latest', scope)), { cache: 'no-store' }),
|
||||
]);
|
||||
|
||||
if (!nodesRes.ok || !packetsRes.ok) {
|
||||
throw new Error(`HTTP ${nodesRes.status}/${packetsRes.status}`);
|
||||
if (!nodesRes.ok || !packetsRes.ok || !statusRes.ok) {
|
||||
throw new Error(`HTTP ${nodesRes.status}/${packetsRes.status}/${statusRes.status}`);
|
||||
}
|
||||
|
||||
const [nodesJson, packetsJson] = await Promise.all([
|
||||
const [nodesJson, packetsJson, statusJson] = await Promise.all([
|
||||
nodesRes.json() as Promise<DevNode[]>,
|
||||
packetsRes.json() as Promise<DevPacket[]>,
|
||||
statusRes.json() as Promise<DevStatusSample[]>,
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
setNodes(nodesJson);
|
||||
setPackets(packetsJson);
|
||||
setStatusSamples(statusJson);
|
||||
const latestNodeId = statusJson[0]?.node_id;
|
||||
if (latestNodeId) {
|
||||
const historyRes = await fetch(uncachedEndpoint(withScopeParams(`/api/node-status/history?nodeId=${encodeURIComponent(latestNodeId)}&hours=24`, scope)), { cache: 'no-store' });
|
||||
if (!historyRes.ok) {
|
||||
throw new Error(`HTTP ${historyRes.status}`);
|
||||
}
|
||||
const historyJson = await historyRes.json() as DevStatusHistoryResponse;
|
||||
if (!cancelled) {
|
||||
setStatusHistory(historyJson.points ?? []);
|
||||
}
|
||||
} else {
|
||||
setStatusHistory([]);
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
@@ -110,17 +325,39 @@ export const DevHomePage: React.FC = () => {
|
||||
}, [scope]);
|
||||
|
||||
const nodeMap = useMemo(() => new Map(nodes.map((node) => [node.node_id, node])), [nodes]);
|
||||
const observerIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const packet of packets) {
|
||||
if (packet.rx_node_id) ids.add(packet.rx_node_id);
|
||||
}
|
||||
return [...ids];
|
||||
}, [packets]);
|
||||
|
||||
const latestPacket = packets[0];
|
||||
const latestStatus = statusSamples[0];
|
||||
const latestObserver = latestPacket?.rx_node_id ? nodeMap.get(latestPacket.rx_node_id) : undefined;
|
||||
const recentPackets = useMemo(() => packets.slice(0, 15), [packets]);
|
||||
const latestStats = useMemo(
|
||||
() => (latestStatus?.stats && typeof latestStatus.stats === 'object' ? latestStatus.stats : null),
|
||||
[latestStatus],
|
||||
);
|
||||
const mqttTelemetryEntries = useMemo(() => {
|
||||
const mqttStats = latestStats
|
||||
? (latestStats['mqtt'] as Record<string, unknown> | undefined)
|
||||
: undefined;
|
||||
if (!mqttStats || typeof mqttStats !== 'object') return [] as Array<[string, unknown]>;
|
||||
return Object.entries(mqttStats);
|
||||
}, [latestStats]);
|
||||
const mqttServerCount = 3;
|
||||
const derivedTelemetryBoxes = useMemo(() => {
|
||||
const stats = latestStats ?? {};
|
||||
return [
|
||||
['Uptime', formatUptime(stats['uptime_ms'], latestStatus?.uptime_secs ?? null)],
|
||||
['Loop Iterations', formatCompactNumber(stats['loop_iterations'])],
|
||||
['WiFi Reconnect Attempts', formatCompactNumber(stats['wifi_reconnect_attempts'])],
|
||||
['RX Publish Calls', formatCompactNumber(stats['rx_publish_calls'])],
|
||||
['TX Publish Calls', formatCompactNumber(stats['tx_publish_calls'])],
|
||||
['Publish Skipped (No Connection)', formatCompactNumber(stats['publish_skipped_no_connection'])],
|
||||
['Forward Successes', formatCompactNumber(stats['forward_successes'])],
|
||||
['Forward Successes (Flood)', formatCompactNumber(stats['forward_successes_flood'])],
|
||||
['Forward Successes (Direct)', formatCompactNumber(stats['forward_successes_direct'])],
|
||||
['Forward Failures', formatCompactNumber(stats['forward_failures'])],
|
||||
['WiFi Connected', formatTelemetryValue(stats['wifi_connected'])],
|
||||
['Heap Min Seen Since Boot', formatKilobytes(stats['heap_min_seen_since_boot'])],
|
||||
] as Array<[string, string]>;
|
||||
}, [latestStats, latestStatus?.uptime_secs]);
|
||||
|
||||
return (
|
||||
<section className="dev-status-page">
|
||||
@@ -139,7 +376,7 @@ export const DevHomePage: React.FC = () => {
|
||||
<h2>Status</h2>
|
||||
<div className="dev-status-list">
|
||||
<div><span>Feed</span><strong>{latestPacket ? 'Receiving packets' : 'Waiting for packets'}</strong></div>
|
||||
<div><span>Observers seen</span><strong>{observerIds.length.toLocaleString()}</strong></div>
|
||||
<div><span>MQTT servers connected</span><strong>{mqttServerCount}</strong></div>
|
||||
<div><span>Last packet</span><strong>{latestPacket ? timeAgo(latestPacket.time) : 'never'}</strong></div>
|
||||
<div><span>Isolation</span><strong>network=test</strong></div>
|
||||
</div>
|
||||
@@ -152,11 +389,67 @@ export const DevHomePage: React.FC = () => {
|
||||
<div><span>Type</span><strong>{latestPacket?.packet_type !== undefined ? (TYPE_LABELS[latestPacket.packet_type] ?? `T${latestPacket.packet_type}`) : '—'}</strong></div>
|
||||
<div><span>Signal</span><strong>{latestPacket?.rssi !== undefined || latestPacket?.snr !== undefined ? `${latestPacket.rssi ?? '—'} / ${latestPacket.snr ?? '—'}` : '—'}</strong></div>
|
||||
<div><span>Hash</span><strong className="dev-status-mono">{latestPacket?.packet_hash ?? '—'}</strong></div>
|
||||
<div className="dev-status-list__summary"><span>Decoded summary</span><strong>{latestPacket ? packetSummary(latestPacket) : 'No test packets have arrived yet.'}</strong></div>
|
||||
</div>
|
||||
<p className="dev-status-note">{latestPacket ? packetSummary(latestPacket) : 'No test packets have arrived yet.'}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="dev-status-card dev-status-card--wide">
|
||||
<h2>Latest telemetry</h2>
|
||||
{latestStatus ? (
|
||||
<div className="dev-telemetry">
|
||||
<div className="dev-status-list dev-status-list--compact">
|
||||
<div><span>Node</span><strong>{latestStatus.name ?? shortNode(latestStatus.node_id)}</strong></div>
|
||||
<div><span>Sample</span><strong>{timeAgo(latestStatus.time)}</strong></div>
|
||||
<div><span>Firmware</span><strong>{latestStatus.firmware_version ?? '—'}</strong></div>
|
||||
<div><span>Model</span><strong>{latestStatus.hardware_model ?? '—'}</strong></div>
|
||||
</div>
|
||||
<div className="dev-telemetry__section">
|
||||
<h3>Graphs</h3>
|
||||
<div className="dev-telemetry__charts">
|
||||
{TELEMETRY_CHARTS.map((chart) => (
|
||||
<TelemetryChartCard
|
||||
key={chart.key}
|
||||
title={chart.title}
|
||||
color={chart.color}
|
||||
points={statusHistory}
|
||||
metric={chart.key}
|
||||
formatter={chart.formatValue}
|
||||
yDomain={chart.yDomain}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dev-telemetry__section">
|
||||
<h3>Stats</h3>
|
||||
<div className="dev-telemetry__grid">
|
||||
{derivedTelemetryBoxes.map(([key, value]) => (
|
||||
<div key={key} className="dev-telemetry__item">
|
||||
<span>{key}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dev-telemetry__section">
|
||||
<h3>MQTT</h3>
|
||||
<div className="dev-telemetry__grid">
|
||||
{mqttTelemetryEntries.length > 0 ? mqttTelemetryEntries.map(([key, value]) => (
|
||||
<div key={key} className="dev-telemetry__item">
|
||||
<span>{labelize(key)}</span>
|
||||
<strong>{formatTelemetryValue(value)}</strong>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="dev-status-empty">No nested MQTT stats in the latest status payload.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="dev-status-note">No status telemetry has arrived yet.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="dev-status-card dev-status-card--wide dev-status-card--fixed">
|
||||
<h2>Live packets</h2>
|
||||
<div className="dev-status-table-wrap">
|
||||
|
||||
@@ -944,6 +944,132 @@ html, body, #root {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.stats-page__path-link {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.stats-page__path-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.stats-page__path-modal {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-bright);
|
||||
border-radius: 12px;
|
||||
width: min(960px, 100%);
|
||||
max-height: min(88vh, 900px);
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stats-page__path-modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stats-page__path-modal-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.stats-page__path-modal-sub {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stats-page__path-modal-close {
|
||||
width: auto;
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.stats-page__path-modal-map {
|
||||
height: 420px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stats-page__path-modal-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stats-page__path-modal-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: rgba(11, 23, 37, 0.55);
|
||||
}
|
||||
|
||||
.stats-page__path-modal-node span {
|
||||
display: inline-flex;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 196, 255, 0.12);
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
flex: 0 0 22px;
|
||||
}
|
||||
|
||||
.stats-page__path-modal-node strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.health-kv strong {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.stats-page__path-node-label {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
color: #ffffff;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stats-page__path-node-label::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Layout shell ──────────────────────────────────────────────────────── */
|
||||
.site-layout {
|
||||
display: flex;
|
||||
@@ -1881,11 +2007,154 @@ html, body, #root {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dev-status-list__summary strong {
|
||||
max-width: min(62%, 420px);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dev-status-note {
|
||||
margin: 14px 0 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dev-status-list--compact {
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
|
||||
.dev-telemetry {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.dev-telemetry__section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dev-telemetry__section h3 {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dev-telemetry__charts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dev-telemetry-chart {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dev-telemetry-chart__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dev-telemetry-chart__head h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.dev-telemetry-chart__head p {
|
||||
margin: 2px 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dev-telemetry-chart__head strong {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dev-telemetry-chart__body {
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
.dev-telemetry-chart__empty {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dev-telemetry-chart__foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dev-telemetry-chart__tooltip {
|
||||
background: #0d1520;
|
||||
border: 1px solid rgba(0, 196, 255, 0.25);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dev-telemetry-chart__tooltip p {
|
||||
margin: 0 0 4px;
|
||||
color: #6b8aaa;
|
||||
}
|
||||
|
||||
.dev-telemetry-chart__tooltip strong {
|
||||
color: #e8f0fb;
|
||||
}
|
||||
|
||||
.dev-telemetry__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dev-telemetry__item {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dev-telemetry__item span {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.dev-telemetry__item strong {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dev-status-table-wrap {
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
@@ -1966,23 +2235,35 @@ html, body, #root {
|
||||
|
||||
/* ── Page hero (inner pages) ───────────────────────────────────────────── */
|
||||
.site-page-hero {
|
||||
padding: 32px 0 20px;
|
||||
padding: 14px 0 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #09111a;
|
||||
}
|
||||
.site-page-hero .site-content {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.site-page-hero__title {
|
||||
font-size: clamp(28px, 4vw, 42px);
|
||||
font-size: clamp(20px, 2.2vw, 28px);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 10px;
|
||||
margin: 0;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.site-page-hero__sub {
|
||||
font-size: 15px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
max-width: 780px;
|
||||
line-height: 1.45;
|
||||
max-width: 820px;
|
||||
margin: 0;
|
||||
flex: 1 1 360px;
|
||||
}
|
||||
.site-page-hero--stats {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ── Prose content ─────────────────────────────────────────────────────── */
|
||||
@@ -2626,7 +2907,18 @@ html, body, #root {
|
||||
.site-home__meta-row strong { text-align: left; }
|
||||
.site-home__title { font-size: 34px; }
|
||||
.site-section { padding: 24px 0; }
|
||||
.site-page-hero { padding: 28px 0 18px; }
|
||||
.site-page-hero {
|
||||
padding: 16px 0 12px;
|
||||
}
|
||||
.site-page-hero .site-content {
|
||||
display: block;
|
||||
}
|
||||
.site-page-hero__title {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.site-page-hero--stats {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.site-home__join { align-items: flex-start; }
|
||||
.dev-monitor__grid { grid-template-columns: 1fr; }
|
||||
.dev-monitor__row { flex-direction: column; align-items: flex-start; }
|
||||
@@ -2655,6 +2947,10 @@ html, body, #root {
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.dev-telemetry__charts {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.owner-summary-grid.site-stats-grid--6 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
@@ -2664,6 +2960,10 @@ html, body, #root {
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.dev-telemetry__charts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.owner-summary-grid.site-stats-grid--6 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { MeshNode } from '../hooks/useNodes.js';
|
||||
|
||||
export const MIN_LINK_OBSERVATIONS = 5; // must match backend db/index.ts
|
||||
export const HIDDEN_NODE_MASK_RADIUS_MILES = 1;
|
||||
export const HIDDEN_NODE_MASK_RADIUS_METERS = HIDDEN_NODE_MASK_RADIUS_MILES * 1609.344;
|
||||
const PROHIBITED_NODE_MARKER = '🚫';
|
||||
|
||||
export type LinkMetrics = {
|
||||
observed_count: number;
|
||||
@@ -25,19 +28,91 @@ export function hasCoords(node: MeshNode | null | undefined): node is MeshNode &
|
||||
return isValidMapCoord(node?.lat, node?.lon);
|
||||
}
|
||||
|
||||
export function isProhibitedMapNode(node: MeshNode | null | undefined): boolean {
|
||||
return Boolean(node?.name?.includes(PROHIBITED_NODE_MARKER));
|
||||
}
|
||||
|
||||
function roundCoord(value: number): number {
|
||||
return Math.round(value * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
function hiddenCoordKey(lat: number, lon: number): string {
|
||||
return `${roundCoord(lat)},${roundCoord(lon)}`;
|
||||
}
|
||||
|
||||
function hashSeed(input: string): number {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function seededUnitPair(seed: string): [number, number] {
|
||||
const distanceUnit = hashSeed(`${seed}:distance`) / 0xffffffff;
|
||||
const bearingUnit = hashSeed(`${seed}:bearing`) / 0xffffffff;
|
||||
return [distanceUnit, bearingUnit];
|
||||
}
|
||||
|
||||
function stablePointWithinMiles(
|
||||
lat: number,
|
||||
lon: number,
|
||||
seed: string,
|
||||
radiusMiles = HIDDEN_NODE_MASK_RADIUS_MILES,
|
||||
): [number, number] {
|
||||
const radiusKm = radiusMiles * 1.609344;
|
||||
const [distanceUnit, bearingUnit] = seededUnitPair(seed);
|
||||
const distanceKm = Math.sqrt(distanceUnit) * radiusKm;
|
||||
const bearing = bearingUnit * Math.PI * 2;
|
||||
const latRad = lat * (Math.PI / 180);
|
||||
const dLat = (distanceKm / 111) * Math.cos(bearing);
|
||||
const lonScale = Math.max(0.01, Math.cos(latRad));
|
||||
const dLon = (distanceKm / (111 * lonScale)) * Math.sin(bearing);
|
||||
return [lat + dLat, lon + dLon];
|
||||
}
|
||||
|
||||
export function buildHiddenCoordMask(nodes: Iterable<MeshNode>): Map<string, [number, number]> {
|
||||
const mask = new Map<string, [number, number]>();
|
||||
for (const node of nodes) {
|
||||
if (!hasCoords(node) || !isProhibitedMapNode(node)) continue;
|
||||
const activityKey = node.last_seen ?? 'unknown';
|
||||
const seed = `${node.node_id}|${activityKey}`;
|
||||
mask.set(hiddenCoordKey(node.lat, node.lon), stablePointWithinMiles(node.lat, node.lon, seed));
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
export function maskPoint(
|
||||
point: [number, number],
|
||||
hiddenCoordMask?: Map<string, [number, number]>,
|
||||
): [number, number] {
|
||||
if (!hiddenCoordMask || hiddenCoordMask.size < 1) return point;
|
||||
return hiddenCoordMask.get(hiddenCoordKey(point[0], point[1])) ?? point;
|
||||
}
|
||||
|
||||
export function maskNodePoint(
|
||||
node: MeshNode & { lat: number; lon: number },
|
||||
hiddenCoordMask?: Map<string, [number, number]>,
|
||||
): [number, number] {
|
||||
if (!isProhibitedMapNode(node)) return [node.lat, node.lon];
|
||||
return maskPoint([node.lat, node.lon], hiddenCoordMask);
|
||||
}
|
||||
|
||||
export function resolvePathWaypoints(
|
||||
pathHashes: string[],
|
||||
src: MeshNode | null,
|
||||
rx: MeshNode,
|
||||
src: (MeshNode & { lat: number; lon: number }) | null,
|
||||
rx: MeshNode & { lat: number; lon: number },
|
||||
allNodes: Map<string, MeshNode>,
|
||||
hiddenCoordMask?: Map<string, [number, number]>,
|
||||
): [number, number][] {
|
||||
const waypoints: [number, number][] = src ? [[src.lat!, src.lon!]] : [];
|
||||
const waypoints: [number, number][] = src ? [maskNodePoint(src, hiddenCoordMask)] : [];
|
||||
const N = pathHashes.length;
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
const prefix = pathHashes[i]!.toUpperCase();
|
||||
const candidates = Array.from(allNodes.values()).filter(
|
||||
(n) => hasCoords(n) && n.node_id.toUpperCase().startsWith(prefix),
|
||||
(n): n is MeshNode & { lat: number; lon: number } => hasCoords(n) && n.node_id.toUpperCase().startsWith(prefix),
|
||||
);
|
||||
if (candidates.length === 0) continue;
|
||||
|
||||
@@ -63,9 +138,9 @@ export function resolvePathWaypoints(
|
||||
});
|
||||
}
|
||||
}
|
||||
waypoints.push([best.lat!, best.lon!]);
|
||||
waypoints.push(maskNodePoint(best, hiddenCoordMask));
|
||||
}
|
||||
|
||||
waypoints.push([rx.lat!, rx.lon!]);
|
||||
waypoints.push(maskNodePoint(rx, hiddenCoordMask));
|
||||
return waypoints;
|
||||
}
|
||||
Reference in new issue
Block a user