Harden map privacy and telemetry displays

This commit is contained in:
Ben committed 2026-03-13 19:21:48 +00:00
1 parent ebb8fe55b4
commit 007d538da9
15 files changed
+1905 -119

No files matched your search

+682 -4
View File
@@ -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),
+26 -3
View File
@@ -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,
+154 -26
View File
@@ -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;
+1
View File
@@ -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
View File
@@ -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;
+34 -16
View File
@@ -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,
+34 -6
View File
@@ -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 && (
+16 -1
View File
@@ -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);
+4 -3
View File
@@ -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] : [];
});
}
+42 -3
View File
@@ -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(() => () => {
-23
View File
@@ -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">
+200 -3
View File
@@ -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="&copy; OpenStreetMap contributors &copy; 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>
);
+307 -14
View File
@@ -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">
+307 -7
View File
@@ -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));
}
+81 -6
View File
@@ -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;
}