From 007d538da95d54c5f9e4986e85bbf0761e34603d Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 13 Mar 2026 19:21:48 +0000 Subject: [PATCH] Harden map privacy and telemetry displays --- backend/src/api/routes.ts | 686 +++++++++++++++++- backend/src/mqtt/client.ts | 29 +- backend/src/path-beta/resolver.ts | 180 ++++- backend/src/path-beta/types.ts | 1 + frontend/src/App.tsx | 21 +- frontend/src/components/Map/MapView.tsx | 50 +- frontend/src/components/Map/NodeMarker.tsx | 40 +- .../src/components/Map/PacketArcLayer.tsx | 17 +- frontend/src/hooks/packetPathOverlayUtils.ts | 7 +- frontend/src/hooks/usePacketPathOverlay.ts | 45 +- frontend/src/pages/OwnerPortalPage.tsx | 23 - frontend/src/pages/StatsPage.tsx | 203 +++++- frontend/src/pages/dev/DevHomePage.tsx | 321 +++++++- frontend/src/styles/globals.css | 314 +++++++- frontend/src/utils/pathing.ts | 87 ++- 15 files changed, 1905 insertions(+), 119 deletions(-) diff --git a/backend/src/api/routes.ts b/backend/src/api/routes.ts index 0836ac9..4fd38b5 100644 --- a/backend/src/api/routes.ts +++ b/backend/src/api/routes.ts @@ -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 { const raw = String(process.env['OWNER_MQTT_USERNAME_MAP'] ?? '').trim(); const map = new Map(); @@ -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 | 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 | 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 | 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 | 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(); @@ -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 { 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), diff --git a/backend/src/mqtt/client.ts b/backend/src/mqtt/client.ts index 828e72a..22731f8 100644 --- a/backend/src/mqtt/client.ts +++ b/backend/src/mqtt/client.ts @@ -127,10 +127,20 @@ type StatusTelemetrySample = { stats?: Record; }; -function extractStatusTelemetry(json: Record): StatusTelemetrySample | null { +function extractStatusTelemetry( + json: Record, + 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): 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 { network, allowTestOverride: network === 'test' && nodeId === observerKey, }); - const telemetry = extractStatusTelemetry(json); + const telemetry = extractStatusTelemetry(json, { + allowRawStatsOnly: network === 'test', + }); if (telemetry) { void insertNodeStatusSample({ nodeId, diff --git a/backend/src/path-beta/resolver.ts b/backend/src/path-beta/resolver.ts index 7b01c15..1a6a967 100644 --- a/backend/src/path-beta/resolver.ts +++ b/backend/src/path-beta/resolver.ts @@ -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(); 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 { const [nodeRows, coverageRows, linkRows, learningModel] = await Promise.all([ query( - `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): Map { + const mask = new Map(); + 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): [number, number] { + return hiddenCoordMask.get(hiddenCoordKey(point[0], point[1])) ?? point; +} + +function maskPath(path: [number, number][] | null, hiddenCoordMask: Map): [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, +): 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, +): 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 { const [packetResult, observerHopResult] = await Promise.all([ query( @@ -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; diff --git a/backend/src/path-beta/types.ts b/backend/src/path-beta/types.ts index 942d8c5..bbb716f 100644 --- a/backend/src/path-beta/types.ts +++ b/backend/src/path-beta/types.ts @@ -6,6 +6,7 @@ export type MeshNode = { iata: string | null; role: number | null; elevation_m: number | null; + last_seen: string | null; }; export type LinkMetrics = { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ab037f7..0d64648 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -53,6 +53,9 @@ export const App: React.FC = () => { const [inferredNodes, setInferredNodes] = useState([]); const [inferredActiveNodeIds, setInferredActiveNodeIds] = useState>(new Set()); const [packetHistorySegments, setPacketHistorySegments] = useState([]); + 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(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; diff --git a/frontend/src/components/Map/MapView.tsx b/frontend/src/components/Map/MapView.tsx index 74b84eb..131eed0 100644 --- a/frontend/src/components/Map/MapView.tsx +++ b/frontend/src/components/Map/MapView.tsx @@ -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 = ({ 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 = ({ 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(null); // Animate marching dashes by incrementing stroke-dashoffset directly on the @@ -206,7 +212,7 @@ export const MapView: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ .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 = ({ 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 = ({ {/* 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 ( = ({ {/* 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 ( = ({ {showBetaPaths && betaLowSegments.map(([a, b], idx) => ( = ({ {showBetaPaths && betaPaths.map((path, idx) => ( maskPoint(point, hiddenCoordMask))} className="beta-purple-path-overlay" pathOptions={{ color: '#a855f7', @@ -775,7 +793,7 @@ export const MapView: React.FC = ({ {betaCompletionPaths.map((path, idx) => ( maskPoint(point, hiddenCoordMask))} pathOptions={{ color: '#ef4444', weight: 1.8, diff --git a/frontend/src/components/Map/NodeMarker.tsx b/frontend/src/components/Map/NodeMarker.tsx index cea3bd1..01646f6 100644 --- a/frontend/src/components/Map/NodeMarker.tsx +++ b/frontend/src/components/Map/NodeMarker.tsx @@ -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 = React.memo(({ node, + displayPosition, isActive, isInferred = false, nodeCoverage, @@ -140,15 +142,19 @@ export const NodeMarker: React.FC = 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 = React.memo(({ }, }}>
-
{node.name ?? `Unknown ${fallbackName}`}
+
{displayName}
{node.role !== undefined && node.role !== 2 && (
Type @@ -238,6 +244,12 @@ export const NodeMarker: React.FC = React.memo(({ Position {lat.toFixed(5)}, {lon.toFixed(5)}
+ {prohibited && ( +
+ Location + Redacted within 1 mile radius +
+ )} {node.elevation_m !== undefined && node.elevation_m !== null && (
Elevation @@ -290,6 +302,22 @@ export const NodeMarker: React.FC = React.memo(({ + {prohibited && ( + + )} + {(previewBands.red.length > 0 || previewBands.amber.length > 0 || previewBands.green.length > 0) && ( {previewBands.red.length > 0 && ( diff --git a/frontend/src/components/Map/PacketArcLayer.tsx b/frontend/src/components/Map/PacketArcLayer.tsx index abe19f6..cb2a11d 100644 --- a/frontend/src/components/Map/PacketArcLayer.tsx +++ b/frontend/src/components/Map/PacketArcLayer.tsx @@ -52,16 +52,31 @@ function buildArcLayers(arcs: PacketArc[], now: number, show: boolean) { export const PacketArcLayer: React.FC = ({ arcs, showArcs, viewState }) => { const [now, setNow] = useState(() => Date.now()); + const [isPageVisible, setIsPageVisible] = useState( + () => (typeof document === 'undefined' ? true : document.visibilityState === 'visible'), + ); const rafRef = useRef>(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); diff --git a/frontend/src/hooks/packetPathOverlayUtils.ts b/frontend/src/hooks/packetPathOverlayUtils.ts index 2e2a348..0c72bb4 100644 --- a/frontend/src/hooks/packetPathOverlayUtils.ts +++ b/frontend/src/hooks/packetPathOverlayUtils.ts @@ -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, ): [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] : []; }); } diff --git a/frontend/src/hooks/usePacketPathOverlay.ts b/frontend/src/hooks/usePacketPathOverlay.ts index 8d62df2..9f28b85 100644 --- a/frontend/src/hooks/usePacketPathOverlay.ts +++ b/frontend/src/hooks/usePacketPathOverlay.ts @@ -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(null); const [pinnedPacketSnapshot, setPinnedPacketSnapshot] = useState(null); const [pathOpacity, setPathOpacity] = useState(0.75); + const [isPageVisible, setIsPageVisible] = useState( + () => (typeof document === 'undefined' ? true : document.visibilityState === 'visible'), + ); const pinnedTimerRef = useRef | null>(null); const pathTimerRef = useRef | 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, @@ -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(() => () => { diff --git a/frontend/src/pages/OwnerPortalPage.tsx b/frontend/src/pages/OwnerPortalPage.tsx index 45594bb..f60c799 100644 --- a/frontend/src/pages/OwnerPortalPage.tsx +++ b/frontend/src/pages/OwnerPortalPage.tsx @@ -583,7 +583,6 @@ export const OwnerPortalPage: React.FC = () => {
{live?.ownerNode.advert_count ?? 0}Adverts
{fmtTs(live?.ownerNode.last_seen ?? null)}Last Seen
{live?.incomingPeers.length ?? 0}Direct Senders (24h)
-
{live?.heardBy.length ?? 0}Heard By (7d)
{viableLinkCount}Viable Links
{strongestLink?.peer_name ?? '-'}Strongest Link
{formatPathLoss(strongestLink?.itm_path_loss_db ?? null)}Best Path Loss
@@ -719,28 +718,6 @@ export const OwnerPortalPage: React.FC = () => {
-
-

Heard By

-
- {(live?.heardBy ?? []).slice(0, 8).map((peer) => ( -
-
- {peer.name ?? peer.node_id} - {peer.network ?? '-'} ยท {peer.iata ?? '-'} -
-
- {peer.packets_24h} / 24h - {peer.packets_7d} / 7d - {peer.best_hops == null ? '-' : `${peer.best_hops} hops`} -
-
- ))} - {(live?.heardBy ?? []).length === 0 ? ( -

No nodes have heard this repeater in the last 7 days.

- ) : null} -
-
-

Direct Senders

diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index b744057..02e6be0 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -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
); +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(null); const [health, setHealth] = useState(null); const [loading, setLoading] = useState(true); const [lastUpdate, setLastUpdate] = useState(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 (
{/* โ”€โ”€ Page hero โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} -
+

Network Stats

@@ -286,7 +363,7 @@ export const StatsPage: React.FC = () => {

Observers - {fmt(region.observers)} + {fmt(region.activeObservers)}|{fmt(region.observers)}
@@ -351,7 +428,61 @@ export const StatsPage: React.FC = () => {
Latest Multibyte Packet - {data.pathHashes.latestMultibyteAt ? timeAgo(data.pathHashes.latestMultibyteAt) : 'not seen yet'} + + {data.pathHashes.latestMultibyteHash + ? `${data.pathHashes.latestMultibyteHash} ยท ${timeAgo(data.pathHashes.latestMultibyteAt)}` + : 'not seen yet'} + +
+
+ Last Fully Decoded Packet + + {data.pathHashes.latestFullyDecodedHash + ? `${data.pathHashes.latestFullyDecodedHash} ยท ${data.pathHashes.latestFullyDecodedHops ?? 0} hops ยท ${timeAgo(data.pathHashes.latestFullyDecodedAt)}` + : 'not decoded yet'} + +
+
+ Decoded Path + {decodedPathNodes.length > 1 ? ( + + ) : ( + {decodedPathSummary} + )} +
+
+ Longest Decoded Path + {longestDecodedPathNodes.length > 1 ? ( + + ) : ( + + {data.pathHashes.longestFullyDecodedHash + ? `${longestDecodedPathSummary} ยท ${data.pathHashes.longestFullyDecodedHops ?? 0} hops` + : 'not decoded yet'} + + )}
@@ -569,6 +700,72 @@ export const StatsPage: React.FC = () => { )} )} + + {selectedDecodedPath && selectedDecodedPathPoints.length > 1 && ( +
setSelectedDecodedPath(null)} + > +
e.stopPropagation()}> +
+
+

{selectedDecodedPath.title}

+

+ {selectedDecodedPath.hash} ยท {selectedDecodedPath.hops ?? 0} hops +

+
+ +
+
+ + + + + {selectedDecodedPathNodes.map((node) => ( + + + {node.ord} + + + ))} + +
+
+ {selectedDecodedPathNodes.map((node) => ( +
+ {node.ord} + + {node.name ?? node.node_id} + {isRedactedDecodedNode(node) ? ' ยท approximate within 1 mile' : ''} + +
+ ))} +
+
+
+ )}
); diff --git a/frontend/src/pages/dev/DevHomePage.tsx b/frontend/src/pages/dev/DevHomePage.tsx index d0ccac9..cba5779 100644 --- a/frontend/src/pages/dev/DevHomePage.tsx +++ b/frontend/src/pages/dev/DevHomePage.tsx @@ -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 | 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 ( +
+ {label ?

{new Date(label).toLocaleString()}

: null} + {formatter(payload[0]?.value)} +
+ ); +}; + +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 ( +
+
+
+

{title}

+

Last 24h

+
+ {formatter(latestValue)} +
+
+ {chartData.length > 0 ? ( + + + + + } /> + + + + ) : ( +
No samples yet
+ )} +
+
+ {chartData.length} samples +
+
+ ); +}; + 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([]); const [packets, setPackets] = useState([]); + const [statusSamples, setStatusSamples] = useState([]); + const [statusHistory, setStatusHistory] = useState([]); const [error, setError] = useState(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, packetsRes.json() as Promise, + statusRes.json() as Promise, ]); 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(); - 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 | 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 (
@@ -139,7 +376,7 @@ export const DevHomePage: React.FC = () => {

Status

Feed{latestPacket ? 'Receiving packets' : 'Waiting for packets'}
-
Observers seen{observerIds.length.toLocaleString()}
+
MQTT servers connected{mqttServerCount}
Last packet{latestPacket ? timeAgo(latestPacket.time) : 'never'}
Isolationnetwork=test
@@ -152,11 +389,67 @@ export const DevHomePage: React.FC = () => {
Type{latestPacket?.packet_type !== undefined ? (TYPE_LABELS[latestPacket.packet_type] ?? `T${latestPacket.packet_type}`) : 'โ€”'}
Signal{latestPacket?.rssi !== undefined || latestPacket?.snr !== undefined ? `${latestPacket.rssi ?? 'โ€”'} / ${latestPacket.snr ?? 'โ€”'}` : 'โ€”'}
Hash{latestPacket?.packet_hash ?? 'โ€”'}
+
Decoded summary{latestPacket ? packetSummary(latestPacket) : 'No test packets have arrived yet.'}
-

{latestPacket ? packetSummary(latestPacket) : 'No test packets have arrived yet.'}

+
+

Latest telemetry

+ {latestStatus ? ( +
+
+
Node{latestStatus.name ?? shortNode(latestStatus.node_id)}
+
Sample{timeAgo(latestStatus.time)}
+
Firmware{latestStatus.firmware_version ?? 'โ€”'}
+
Model{latestStatus.hardware_model ?? 'โ€”'}
+
+
+

Graphs

+
+ {TELEMETRY_CHARTS.map((chart) => ( + + ))} +
+
+
+

Stats

+
+ {derivedTelemetryBoxes.map(([key, value]) => ( +
+ {key} + {value} +
+ ))} +
+
+
+

MQTT

+
+ {mqttTelemetryEntries.length > 0 ? mqttTelemetryEntries.map(([key, value]) => ( +
+ {labelize(key)} + {formatTelemetryValue(value)} +
+ )) : ( +
No nested MQTT stats in the latest status payload.
+ )} +
+
+
+ ) : ( +

No status telemetry has arrived yet.

+ )} +
+

Live packets

diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index b9262fb..810a7df 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -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)); } diff --git a/frontend/src/utils/pathing.ts b/frontend/src/utils/pathing.ts index 24be2ed..64bfec6 100644 --- a/frontend/src/utils/pathing.ts +++ b/frontend/src/utils/pathing.ts @@ -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): Map { + const mask = new Map(); + 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, +): [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, +): [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, + hiddenCoordMask?: Map, ): [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; }