diff --git a/Dockerfile.app b/Dockerfile.app index 5f329a1..24b8508 100644 --- a/Dockerfile.app +++ b/Dockerfile.app @@ -6,8 +6,20 @@ RUN npm install COPY frontend/ ./ ARG VITE_APP_HOSTNAME ARG VITE_NETWORK=teesside +ARG VITE_SITE=teesside +ARG VITE_OBSERVER_ID= +ARG VITE_SITE_DISPLAY_NAME= +ARG VITE_SITE_FOOTER_NAME= +ARG VITE_SITE_APP_URL= +ARG VITE_SITE_HOME_URL= ENV VITE_APP_HOSTNAME=$VITE_APP_HOSTNAME ENV VITE_NETWORK=$VITE_NETWORK +ENV VITE_SITE=$VITE_SITE +ENV VITE_OBSERVER_ID=$VITE_OBSERVER_ID +ENV VITE_SITE_DISPLAY_NAME=$VITE_SITE_DISPLAY_NAME +ENV VITE_SITE_FOOTER_NAME=$VITE_SITE_FOOTER_NAME +ENV VITE_SITE_APP_URL=$VITE_SITE_APP_URL +ENV VITE_SITE_HOME_URL=$VITE_SITE_HOME_URL RUN npm run build FROM nginx:alpine diff --git a/Dockerfile.website b/Dockerfile.website index 6348678..5769ab7 100644 --- a/Dockerfile.website +++ b/Dockerfile.website @@ -5,8 +5,20 @@ RUN npm install COPY frontend/ ./ ARG VITE_APP_HOSTNAME ARG VITE_SITE=teesside +ARG VITE_OBSERVER_ID= +ARG VITE_NETWORK= +ARG VITE_SITE_DISPLAY_NAME= +ARG VITE_SITE_FOOTER_NAME= +ARG VITE_SITE_APP_URL= +ARG VITE_SITE_HOME_URL= ENV VITE_APP_HOSTNAME=$VITE_APP_HOSTNAME ENV VITE_SITE=$VITE_SITE +ENV VITE_OBSERVER_ID=$VITE_OBSERVER_ID +ENV VITE_NETWORK=$VITE_NETWORK +ENV VITE_SITE_DISPLAY_NAME=$VITE_SITE_DISPLAY_NAME +ENV VITE_SITE_FOOTER_NAME=$VITE_SITE_FOOTER_NAME +ENV VITE_SITE_APP_URL=$VITE_SITE_APP_URL +ENV VITE_SITE_HOME_URL=$VITE_SITE_HOME_URL RUN npm run build FROM nginx:alpine diff --git a/backend/src/api/routes.ts b/backend/src/api/routes.ts index abb829e..2740be0 100644 --- a/backend/src/api/routes.ts +++ b/backend/src/api/routes.ts @@ -5,6 +5,7 @@ import mqtt from 'mqtt'; import { getNodes, getNodeHistory, getRecentPackets, query, MIN_LINK_OBSERVATIONS } from '../db/index.js'; import { getOwnerNodeIdsForUsername } from '../db/ownerAuth.js'; import { getWorkerHealthOverview } from '../health/status.js'; +import { resolveRequestNetwork } from '../http/requestScope.js'; import { resolveBetaPathForPacketHash } from '../path-beta/resolver.js'; const router = Router(); @@ -235,19 +236,71 @@ type NetworkFilters = { nodesAlias: (alias: string) => string; }; -function networkFilters(network?: string): NetworkFilters { +function normalizeObserverQuery(value: unknown): string | undefined { + const observer = String(value ?? '').trim().toLowerCase(); + return observer && /^[0-9a-f]{64}$/.test(observer) ? observer : undefined; +} + +function networkFilters(network?: string, observer?: string): NetworkFilters { + const params: string[] = []; + let networkParam: string | null = null; + let observerParam: string | null = null; + + if (network) { + networkParam = `$${params.length + 1}`; + params.push(network); + } + + if (observer) { + observerParam = `$${params.length + 1}`; + params.push(observer); + } + + const packetConditions: string[] = []; + if (networkParam) packetConditions.push(`network = ${networkParam}`); + else packetConditions.push(`network IS DISTINCT FROM 'test'`); + if (observerParam) packetConditions.push(`LOWER(rx_node_id) = LOWER(${observerParam})`); + + const nodeConditions = (alias?: string) => { + const prefix = alias ? `${alias}.` : ''; + const conditions: string[] = []; + if (networkParam) conditions.push(`${prefix}network = ${networkParam}`); + else conditions.push(`${prefix}network IS DISTINCT FROM 'test'`); + if (observerParam) { + conditions.push( + `( + LOWER(${prefix}node_id) = LOWER(${observerParam}) + OR EXISTS ( + SELECT 1 + FROM packets p + WHERE LOWER(p.rx_node_id) = LOWER(${observerParam}) + ${networkParam ? `AND p.network = ${networkParam}` : ''} + AND LOWER(p.src_node_id) = LOWER(${prefix}node_id) + ) + )`, + ); + } + return conditions; + }; + return { - params: network ? [network] : [], - packets: network ? 'AND network = $1' : '', - nodes: network ? 'AND network = $1' : '', - nodesAlias: (alias: string) => (network ? `AND ${alias}.network = $1` : ''), + params, + packets: packetConditions.length > 0 ? `AND ${packetConditions.join(' AND ')}` : '', + nodes: nodeConditions().length > 0 ? `AND ${nodeConditions().join(' AND ')}` : '', + nodesAlias: (alias: string) => { + const conditions = nodeConditions(alias); + return conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : ''; + }, }; } // GET /api/nodes โ€” all known nodes -router.get('/nodes', async (_req, res) => { +router.get('/nodes', async (req, res) => { try { - const nodes = await getNodes(); + const requestedNetwork = resolveRequestNetwork(req.query['network'], req.headers); + const network = requestedNetwork === 'all' ? undefined : requestedNetwork; + const observer = normalizeObserverQuery(req.query['observer']); + const nodes = await getNodes(network, observer); res.json(nodes); } catch (err) { console.error('[api] GET /nodes', (err as Error).message); @@ -300,7 +353,10 @@ router.get('/nodes/:id/history', async (req, res) => { router.get('/packets/recent', async (req, res) => { try { const limit = Math.min(Number(req.query['limit'] ?? 200), 1000); - const packets = await getRecentPackets(limit); + const requestedNetwork = resolveRequestNetwork(req.query['network'], req.headers); + const network = requestedNetwork === 'all' ? undefined : requestedNetwork; + const observer = normalizeObserverQuery(req.query['observer']); + const packets = await getRecentPackets(limit, network, observer); res.json(packets); } catch (err) { console.error('[api] GET /packets/recent', (err as Error).message); @@ -316,9 +372,9 @@ router.get('/path-beta/resolve', async (req, res) => { res.status(400).json({ error: 'Missing hash query parameter' }); return; } - const networkRaw = String(req.query['network'] ?? 'teesside').trim().toLowerCase(); - const network = networkRaw === 'ukmesh' || networkRaw === 'all' ? networkRaw : 'teesside'; - const resolved = await resolveBetaPathForPacketHash(packetHash, network); + const network = resolveRequestNetwork(req.query['network'], req.headers, 'teesside') ?? 'teesside'; + const observer = normalizeObserverQuery(req.query['observer']); + const resolved = await resolveBetaPathForPacketHash(packetHash, network, observer); if (!resolved) { res.status(404).json({ error: 'Packet not found' }); return; @@ -333,11 +389,13 @@ router.get('/path-beta/resolve', async (req, res) => { // GET /api/stats router.get('/stats', async (req, res) => { try { - const network = req.query['network'] as string | undefined; - const filters = networkFilters(network); + const requestedNetwork = resolveRequestNetwork(req.query['network'], req.headers); + const network = requestedNetwork === 'all' ? undefined : requestedNetwork; + const observer = normalizeObserverQuery(req.query['observer']); + const filters = networkFilters(network, observer); const [mqttCount, packetCount, staleCount, mapNodeCount, totalNodeCount, longestHopCount] = await Promise.all([ query(`SELECT COUNT(DISTINCT rx_node_id) AS count FROM packets WHERE time > NOW() - INTERVAL '10 minutes' AND rx_node_id IS NOT NULL ${filters.packets}`, filters.params), - query(`SELECT COUNT(DISTINCT packet_hash) AS count FROM packets WHERE time > NOW() - INTERVAL '24 hours' ${filters.packets}`, filters.params), + query(`SELECT COUNT(*) AS count FROM packets WHERE time > NOW() - INTERVAL '24 hours' ${filters.packets}`, filters.params), query(`SELECT COUNT(*) AS count FROM nodes WHERE lat IS NOT NULL AND lon IS NOT NULL AND (role IS NULL OR role = 2) @@ -386,8 +444,10 @@ router.get('/stats', async (req, res) => { // GET /api/coverage โ€” all stored viewshed polygons (excludes hidden ๐Ÿšซ nodes) router.get('/coverage', async (req, res) => { try { - const network = req.query['network'] as string | undefined; - const filters = networkFilters(network); + const requestedNetwork = resolveRequestNetwork(req.query['network'], req.headers); + const network = requestedNetwork === 'all' ? undefined : requestedNetwork; + const observer = normalizeObserverQuery(req.query['observer']); + const filters = networkFilters(network, observer); const result = await query( `SELECT nc.node_id, nc.geom, nc.strength_geoms, nc.antenna_height_m, nc.radius_m, nc.calculated_at FROM node_coverage nc @@ -420,7 +480,7 @@ router.get('/planned-nodes', async (_req, res) => { // GET /api/path-learning router.get('/path-learning', async (req, res) => { try { - const network = (req.query['network'] as string | undefined) ?? 'teesside'; + const network = resolveRequestNetwork(req.query['network'], req.headers, 'teesside') ?? 'teesside'; const limit = Math.min(12000, Math.max(1000, Number(req.query['limit'] ?? 6000))); const [prefixRows, transitionRows, edgeRows, motifRows, calibrationRows] = await Promise.all([ query<{ @@ -532,7 +592,7 @@ router.get('/path-learning', async (req, res) => { // GET /api/path-sim/latest โ€” latest path simulation run summary router.get('/path-sim/latest', async (req, res) => { try { - const network = (req.query['network'] as string | undefined)?.trim().toLowerCase(); + const network = resolveRequestNetwork(req.query['network'], req.headers); const hasNetwork = Boolean(network && network !== 'all'); const result = await query<{ id: number; @@ -571,7 +631,7 @@ router.get('/path-sim/latest', async (req, res) => { // GET /api/path-sim/history?limit=20&network=all router.get('/path-sim/history', async (req, res) => { try { - const network = (req.query['network'] as string | undefined)?.trim().toLowerCase(); + const network = resolveRequestNetwork(req.query['network'], req.headers); const hasNetwork = Boolean(network && network !== 'all'); const limit = Math.min(200, Math.max(1, Number(req.query['limit'] ?? 20))); const result = await query<{ @@ -1038,8 +1098,10 @@ router.post('/telemetry/frontend-error', async (req, res) => { // GET /api/stats/charts router.get('/stats/charts', async (req, res) => { try { - const network = req.query['network'] as string | undefined; - const filters = networkFilters(network); + const requestedNetwork = resolveRequestNetwork(req.query['network'], req.headers); + const network = requestedNetwork === 'all' ? undefined : requestedNetwork; + const observer = normalizeObserverQuery(req.query['observer']); + const filters = networkFilters(network, observer); const PAYLOAD_LABELS: Record = { 0: 'Request', 1: 'Response', 2: 'DM', 3: 'Ack', @@ -1051,16 +1113,16 @@ router.get('/stats/charts', async (req, res) => { phResult, pdResult, rhResult, rdResult, ptResult, rpResult, hdResult, pcResult, sumResult, ] = await Promise.all([ - // packets per hour โ€” last 24h (deduplicated by hash) + // packets per hour โ€” last 24h (all observed packet rows) query(` - SELECT time_bucket('1 hour', time) AS hour, COUNT(DISTINCT packet_hash) AS count + SELECT time_bucket('1 hour', time) AS hour, COUNT(*) AS count FROM packets WHERE time > NOW() - INTERVAL '24 hours' ${filters.packets} GROUP BY hour ORDER BY hour `, filters.params), - // packets per day โ€” last 7d (deduplicated by hash) + // packets per day โ€” last 7d (all observed packet rows) query(` - SELECT time_bucket('1 day', time) AS day, COUNT(DISTINCT packet_hash) AS count + SELECT time_bucket('1 day', time) AS day, COUNT(*) AS count FROM packets WHERE time > NOW() - INTERVAL '7 days' ${filters.packets} GROUP BY day ORDER BY day @@ -1079,9 +1141,9 @@ router.get('/stats/charts', async (req, res) => { WHERE time > NOW() - INTERVAL '7 days' AND src_node_id IS NOT NULL ${filters.packets} GROUP BY day ORDER BY day `, filters.params), - // packet types โ€” last 24h (deduplicated by hash) + // packet types โ€” last 24h (all observed packet rows) query(` - SELECT packet_type, COUNT(DISTINCT packet_hash) AS count + SELECT packet_type, COUNT(*) AS count FROM packets WHERE time > NOW() - INTERVAL '24 hours' ${filters.packets} GROUP BY packet_type ORDER BY count DESC @@ -1105,9 +1167,9 @@ router.get('/stats/charts', async (req, res) => { FROM hours h ORDER BY h.hour `, filters.params), - // hop count distribution โ€” last 7d (deduplicated by hash) + // hop count distribution โ€” last 7d (all observed packet rows) query(` - SELECT hop_count AS hops, COUNT(DISTINCT packet_hash) AS count + SELECT hop_count AS hops, COUNT(*) AS count FROM packets WHERE time > NOW() - INTERVAL '7 days' AND hop_count IS NOT NULL @@ -1136,8 +1198,8 @@ router.get('/stats/charts', async (req, res) => { // summary query(` SELECT - (SELECT COUNT(DISTINCT packet_hash) FROM packets WHERE time > NOW() - INTERVAL '24 hours' ${filters.packets}) AS total_24h, - (SELECT COUNT(DISTINCT packet_hash) FROM packets WHERE time > NOW() - INTERVAL '7 days' ${filters.packets}) AS total_7d, + (SELECT COUNT(*) FROM packets WHERE time > NOW() - INTERVAL '24 hours' ${filters.packets}) AS total_24h, + (SELECT COUNT(*) FROM packets WHERE time > NOW() - INTERVAL '7 days' ${filters.packets}) AS total_7d, (SELECT COUNT(DISTINCT src_node_id) FROM packets WHERE time > NOW() - INTERVAL '24 hours' AND src_node_id IS NOT NULL ${filters.packets}) AS unique_radios_24h, (SELECT COUNT(*) FROM nodes n WHERE (n.role IS NULL OR n.role = 2) AND n.last_seen > NOW() - INTERVAL '7 days' ${filters.nodesAlias('n')}) AS active_repeaters, (SELECT COUNT(*) FROM nodes n WHERE (n.role IS NULL OR n.role = 2) AND n.last_seen <= NOW() - INTERVAL '7 days' AND n.last_seen > NOW() - INTERVAL '14 days' ${filters.nodesAlias('n')}) AS stale_repeaters diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index d966dca..014cb0a 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -5,8 +5,14 @@ import { fileURLToPath } from 'node:url'; const { Pool } = pg; +const databaseSchema = String(process.env['DATABASE_SCHEMA'] ?? '').trim(); +if (databaseSchema && !/^[a-z_][a-z0-9_]*$/i.test(databaseSchema)) { + throw new Error(`Invalid DATABASE_SCHEMA: ${databaseSchema}`); +} + const pool = new Pool({ connectionString: process.env.DATABASE_URL, + options: databaseSchema ? `-c search_path=${databaseSchema},public` : undefined, max: 20, idleTimeoutMillis: 30000, connectionTimeoutMillis: 5000, @@ -23,10 +29,76 @@ export async function query( return pool.query(text, params); } +type ScopePlaceholders = { + params: unknown[]; + networkParam: string | null; + observerParam: string | null; +}; + +function buildScopePlaceholders(startIndex: number, network?: string, observer?: string): ScopePlaceholders { + const params: unknown[] = []; + let idx = startIndex; + const networkParam = network ? `$${idx++}` : null; + if (network) params.push(network); + const observerParam = observer ? `$${idx++}` : null; + if (observer) params.push(observer); + return { params, networkParam, observerParam }; +} + +function buildPacketScopeClause( + placeholders: ScopePlaceholders, + alias?: string, +): string { + const prefix = alias ? `${alias}.` : ''; + const conditions: string[] = []; + if (placeholders.networkParam) { + conditions.push(`${prefix}network = ${placeholders.networkParam}`); + } else { + conditions.push(`${prefix}network IS DISTINCT FROM 'test'`); + } + if (placeholders.observerParam) { + conditions.push(`LOWER(${prefix}rx_node_id) = LOWER(${placeholders.observerParam})`); + } + return conditions.length > 0 ? ` AND ${conditions.join(' AND ')}` : ''; +} + +function buildNodeScopeClause( + placeholders: ScopePlaceholders, + alias?: string, +): string { + const prefix = alias ? `${alias}.` : ''; + const conditions: string[] = []; + + if (placeholders.networkParam) { + conditions.push(`${prefix}network = ${placeholders.networkParam}`); + } else { + conditions.push(`${prefix}network IS DISTINCT FROM 'test'`); + } + + if (placeholders.observerParam) { + const observerNodeScope = [ + `LOWER(${prefix}node_id) = LOWER(${placeholders.observerParam})`, + `EXISTS ( + SELECT 1 + FROM packets p + WHERE LOWER(p.rx_node_id) = LOWER(${placeholders.observerParam})`, + placeholders.networkParam ? `AND p.network = ${placeholders.networkParam}` : '', + `AND LOWER(p.src_node_id) = LOWER(${prefix}node_id) + )`, + ].filter(Boolean).join(' '); + conditions.push(`(${observerNodeScope})`); + } + + return conditions.length > 0 ? ` AND ${conditions.join(' AND ')}` : ''; +} + export async function initDb(): Promise { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const schemaPath = path.join(__dirname, 'schema.sql'); const sql = fs.readFileSync(schemaPath, 'utf8'); + if (databaseSchema) { + await pool.query(`CREATE SCHEMA IF NOT EXISTS "${databaseSchema}"`); + } await pool.query(sql); console.log('[db] schema initialised, no retention policy (data kept indefinitely)'); } @@ -63,7 +135,10 @@ export async function upsertNode(nodeId: string, updates: { firmware_version = COALESCE(EXCLUDED.firmware_version, nodes.firmware_version), public_key = COALESCE(EXCLUDED.public_key, nodes.public_key), network = CASE - WHEN nodes.network = 'teesside' THEN 'teesside' + WHEN nodes.network = 'teesside' AND COALESCE(EXCLUDED.network, nodes.network) <> 'test' THEN 'teesside' + WHEN nodes.network IN ('ukmesh', 'teesside') AND EXCLUDED.network = 'test' THEN nodes.network + WHEN nodes.network = 'test' AND EXCLUDED.network IN ('ukmesh', 'teesside') THEN EXCLUDED.network + WHEN EXCLUDED.network = 'test' THEN 'test' ELSE COALESCE(EXCLUDED.network, nodes.network) END, last_seen = NOW(), @@ -101,13 +176,13 @@ export async function insertPacket(p: { ); } -export async function getNodes(network?: string) { - const whereClause = network ? 'WHERE network = $1' : ''; - const params = network ? [network] : []; +export async function getNodes(network?: string, observer?: string) { + const scope = buildScopePlaceholders(1, network, observer); + const whereClause = `WHERE 1=1${buildNodeScopeClause(scope)}`; const res = await pool.query( `SELECT node_id, name, lat, lon, iata, role, last_seen, is_online, hardware_model, public_key, advert_count, elevation_m FROM nodes ${whereClause} ORDER BY last_seen DESC`, - params + scope.params ); return res.rows; } @@ -123,35 +198,45 @@ export async function getNodeHistory(nodeId: string, hours = 24) { return res.rows; } -export async function getRecentPackets(limit = 200) { +export async function getRecentPackets(limit = 200, network?: string, observer?: string) { + const scope = buildScopePlaceholders(2, network, observer); const res = await pool.query( `SELECT time, packet_hash, rx_node_id, src_node_id, topic, packet_type, hop_count, rssi, snr, payload FROM packets WHERE time > NOW() - INTERVAL '5 minutes' + ${buildPacketScopeClause(scope)} ORDER BY time DESC LIMIT $1`, - [limit] + [limit, ...scope.params] ); return res.rows; } -export async function getLastNPackets(n: number, network?: string) { +export async function getLastNPackets(n: number, network?: string, observer?: string) { // DISTINCT ON deduplicates by hash (same packet heard by multiple observers), // preferring the richest observation per hash within the last 24 hours. - const nFilter = network ? 'AND network = $2' : ''; - const params: unknown[] = network ? [n, network] : [n]; + const scope = buildScopePlaceholders(2, network, observer); + const params: unknown[] = [n, ...scope.params]; const res = await pool.query( `SELECT * FROM ( - SELECT DISTINCT ON (packet_hash) time, packet_hash, rx_node_id, src_node_id, - packet_type, hop_count, payload, advert_count, path_hashes - FROM packets - WHERE time > NOW() - INTERVAL '24 hours' ${nFilter} - ORDER BY packet_hash, + SELECT DISTINCT ON (p.packet_hash) p.time, p.packet_hash, p.rx_node_id, p.src_node_id, + p.packet_type, p.hop_count, p.payload, p.advert_count, p.path_hashes, + ( + SELECT ARRAY_AGG(DISTINCT p2.rx_node_id ORDER BY p2.rx_node_id) + FROM packets p2 + WHERE p2.packet_hash = p.packet_hash + AND p2.time > NOW() - INTERVAL '24 hours' + AND p2.rx_node_id IS NOT NULL + ${buildPacketScopeClause(scope, 'p2')} + ) AS observer_node_ids + FROM packets p + WHERE p.time > NOW() - INTERVAL '24 hours' ${buildPacketScopeClause(scope, 'p')} + ORDER BY p.packet_hash, CASE WHEN payload ? 'appData' THEN 1 ELSE 0 END DESC, CASE WHEN src_node_id IS NOT NULL THEN 1 ELSE 0 END DESC, CASE WHEN packet_type = 4 THEN 1 ELSE 0 END DESC, CASE WHEN payload->>'direction' = 'tx' THEN 1 ELSE 0 END DESC, - time DESC + p.time DESC ) deduped ORDER BY time DESC LIMIT $1`, params @@ -163,10 +248,9 @@ export async function getLastNPackets(n: number, network?: string) { export const MIN_LINK_OBSERVATIONS = 5; /** Returns only confirmed viable link pairs โ€” compact for sending in initial WebSocket state. */ -export async function getViableLinkPairs(network?: string): Promise<[string, string][]> { - const params: unknown[] = [MIN_LINK_OBSERVATIONS]; - const networkFilter = network ? 'AND a.network = $2 AND b.network = $2' : ''; - if (network) params.push(network); +export async function getViableLinkPairs(network?: string, observer?: string): Promise<[string, string][]> { + const scope = buildScopePlaceholders(2, network, observer); + const params: unknown[] = [MIN_LINK_OBSERVATIONS, ...scope.params]; const res = await pool.query<{ node_a_id: string; node_b_id: string }>( `SELECT nl.node_a_id, nl.node_b_id @@ -175,7 +259,8 @@ export async function getViableLinkPairs(network?: string): Promise<[string, str JOIN nodes b ON b.node_id = nl.node_b_id WHERE (nl.itm_viable = true OR nl.force_viable = true) AND nl.observed_count >= $1 - ${networkFilter}`, + ${buildNodeScopeClause(scope, 'a')} + ${buildNodeScopeClause(scope, 'b')}`, params, ); return res.rows.map((r) => [r.node_a_id, r.node_b_id]); @@ -192,10 +277,9 @@ export type ViableLinkRow = { }; /** Returns viable links with metrics so UI can render precomputed styles immediately. */ -export async function getViableLinks(network?: string): Promise { - const params: unknown[] = [MIN_LINK_OBSERVATIONS]; - const networkFilter = network ? 'AND a.network = $2 AND b.network = $2' : ''; - if (network) params.push(network); +export async function getViableLinks(network?: string, observer?: string): Promise { + const scope = buildScopePlaceholders(2, network, observer); + const params: unknown[] = [MIN_LINK_OBSERVATIONS, ...scope.params]; const res = await pool.query( `SELECT @@ -211,7 +295,8 @@ export async function getViableLinks(network?: string): Promise JOIN nodes b ON b.node_id = nl.node_b_id WHERE (nl.itm_viable = true OR nl.force_viable = true) AND nl.observed_count >= $1 - ${networkFilter}`, + ${buildNodeScopeClause(scope, 'a')} + ${buildNodeScopeClause(scope, 'b')}`, params, ); return res.rows; diff --git a/backend/src/health/status.ts b/backend/src/health/status.ts index 0df0302..0938d51 100644 --- a/backend/src/health/status.ts +++ b/backend/src/health/status.ts @@ -246,11 +246,12 @@ export async function getWorkerHealthOverview() { stale_threshold_minutes: string; global_last_packet_at: string | null; }>( - `WITH latest_rx AS ( + `WITH latest_rx AS ( SELECT rx_node_id, MAX(time) AS last_packet_at FROM packets WHERE rx_node_id IS NOT NULL AND rx_node_id <> '' + AND network IS DISTINCT FROM 'test' GROUP BY rx_node_id ), active_rx AS ( @@ -269,7 +270,7 @@ export async function getWorkerHealthOverview() { END )::text AS max_stale_minutes, '15'::text AS stale_threshold_minutes, - (SELECT MAX(time)::text FROM packets) AS global_last_packet_at + (SELECT MAX(time)::text FROM packets WHERE network IS DISTINCT FROM 'test') AS global_last_packet_at FROM active_rx`, ), query<{ @@ -280,6 +281,7 @@ export async function getWorkerHealthOverview() { FROM packets p CROSS JOIN LATERAL unnest(p.path_hashes) AS h WHERE p.time > NOW() - INTERVAL '24 hours' + AND p.network IS DISTINCT FROM 'test' GROUP BY 1`, ), query<{ @@ -302,7 +304,8 @@ export async function getWorkerHealthOverview() { ) )::text AS multibyte_packets_24h FROM packets - WHERE time > NOW() - INTERVAL '24 hours'`, + WHERE time > NOW() - INTERVAL '24 hours' + AND network IS DISTINCT FROM 'test'`, ), ]); diff --git a/backend/src/http/requestScope.ts b/backend/src/http/requestScope.ts new file mode 100644 index 0000000..23c049f --- /dev/null +++ b/backend/src/http/requestScope.ts @@ -0,0 +1,49 @@ +import type { IncomingHttpHeaders } from 'node:http'; + +type NetworkScope = 'teesside' | 'ukmesh' | 'test' | 'all'; +type ForcedScope = Exclude; + +function firstHeaderValue(value: string | string[] | undefined): string { + return Array.isArray(value) ? String(value[0] ?? '') : String(value ?? ''); +} + +function extractHostname(raw: string): string { + const trimmed = raw.trim().toLowerCase(); + if (!trimmed) return ''; + try { + return new URL(trimmed).hostname.toLowerCase(); + } catch { + return trimmed.split(',')[0]?.trim().split(':')[0]?.toLowerCase() ?? ''; + } +} + +export function normalizeNetworkValue(value: unknown): NetworkScope | undefined { + const normalized = String(value ?? '').trim().toLowerCase(); + if (normalized === 'ukmesh' || normalized === 'teesside' || normalized === 'test' || normalized === 'all') { + return normalized; + } + return undefined; +} + +export function inferForcedNetwork(headers: IncomingHttpHeaders): ForcedScope | undefined { + const host = extractHostname(firstHeaderValue(headers['x-forwarded-host']) || firstHeaderValue(headers['host'])); + const origin = extractHostname(firstHeaderValue(headers['origin'])); + const referer = extractHostname(firstHeaderValue(headers['referer'])); + const candidates = [host, origin, referer]; + + if (candidates.some((value) => value === 'test.ukmesh.com' || value.endsWith('.test.ukmesh.com'))) return 'test'; + if (candidates.some((value) => value === 'app.ukmesh.com' || value === 'www.ukmesh.com')) return 'all'; + if (candidates.some((value) => value === 'app.teessidemesh.com' || value === 'www.teessidemesh.com')) return 'teesside'; + return undefined; +} + +export function resolveRequestNetwork( + requested: unknown, + headers: IncomingHttpHeaders, + fallback?: Exclude, +): NetworkScope | undefined { + const forced = inferForcedNetwork(headers); + if (forced) return forced; + const normalized = normalizeNetworkValue(requested); + return normalized ?? fallback; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 57467f3..38f1995 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -54,7 +54,7 @@ async function main() { queueLinkJob(packet.rxNodeId, packet.srcNodeId, packet.path, packet.hopCount); } }); - onNodeSeen((nodeId) => broadcastNodeUpdate(nodeId)); + onNodeSeen((nodeId, meta) => broadcastNodeUpdate(nodeId, meta)); onNodeUpsert((node) => { broadcastNodeUpsert(node); // Queue a viewshed job only for visible repeaters (role=2 or unknown) diff --git a/backend/src/mqtt/client.ts b/backend/src/mqtt/client.ts index 4400b2e..5b5afe3 100644 --- a/backend/src/mqtt/client.ts +++ b/backend/src/mqtt/client.ts @@ -9,7 +9,7 @@ import type { LivePacket } from '../types/index.js'; import { decodePacketCompat } from './decodePacket.js'; type PacketCallback = (packet: LivePacket) => void; -type NodeCallback = (nodeId: string) => void; +type NodeCallback = (nodeId: string, meta?: { network?: string; observerId?: string }) => void; type NodeUpsertCallback = (node: Record) => void; const subscribers: PacketCallback[] = []; @@ -23,8 +23,8 @@ export function onNodeUpsert(cb: NodeUpsertCallback) { upsertSubscribers.push(cb function emit(packet: LivePacket) { for (const cb of subscribers) cb(packet); } -function emitNode(nodeId: string) { - for (const cb of nodeSubscribers) cb(nodeId); +function emitNode(nodeId: string, meta?: { network?: string; observerId?: string }) { + for (const cb of nodeSubscribers) cb(nodeId, meta); } function emitNodeUpsert(node: Record) { for (const cb of upsertSubscribers) cb(node); @@ -34,18 +34,29 @@ function emitNodeUpsert(node: Record) { * Topic formats: * meshcore/{IATA}/{OBSERVER_PUBLIC_KEY}/{packets|status} * ukmesh/{IATA}/{OBSERVER_PUBLIC_KEY}/{packets|status} (legacy, accepted during migration) + * meshcore-test/{IATA}/{OBSERVER_PUBLIC_KEY}/{packets|status} (isolated dev/test ingest) * - * Network assignment is now derived from observer IATA: + * Public-network assignment is derived from observer IATA: * - MME => teesside * - all other IATA => ukmesh/global * + * Any non-public topic prefix is treated as isolated test/dev traffic so it can + * live in a separate schema/backend without contaminating the public dataset. + * * mctomqtt JSON structure: * status: { origin, origin_id, model, firmware_version, radio, client_version } * packets: { raw (hex), hash, packet_type, SNR, RSSI, score, route, len, * payload_len, direction, origin, origin_id, timestamp, type } * All numeric values arrive as strings from mctomqtt regex groups. */ -const TOPIC_PREFIXES = new Set(['meshcore', 'ukmesh']); +const DEFAULT_TOPIC_PREFIXES = ['meshcore', 'ukmesh', 'meshcore-test']; +const PUBLIC_TOPIC_PREFIXES = new Set(['meshcore', 'ukmesh']); +const TOPIC_PREFIXES = new Set( + String(process.env['MQTT_TOPIC_PREFIXES'] ?? DEFAULT_TOPIC_PREFIXES.join(',')) + .split(',') + .map((value) => value.trim().toLowerCase()) + .filter(Boolean), +); const TEESSIDE_IATA = (process.env['TEESSIDE_IATA'] ?? 'MME').trim().toUpperCase(); interface TopicParts { @@ -62,7 +73,9 @@ function parseTopic(topic: string): TopicParts | null { if (!prefix || !TOPIC_PREFIXES.has(prefix)) return null; const iata = (parts[1] ?? '').trim().toUpperCase(); if (!iata) return null; - const network = iata === TEESSIDE_IATA ? 'teesside' : 'ukmesh'; + const network = PUBLIC_TOPIC_PREFIXES.has(prefix) + ? (iata === TEESSIDE_IATA ? 'teesside' : 'ukmesh') + : 'test'; return { iata, observerKey: parts[2]!, suffix: parts[3]!, network }; } @@ -140,12 +153,12 @@ function buildSummary(payloadType: number, decoded: unknown, rawHex?: string): s if (!decoded) return undefined; switch (payloadType) { - case 4: { // Advert + case 4: { const p = decoded as AdvertPayload; const name = p.appData?.name; return name ? `${name}` : undefined; } - case 5: { // GroupText + case 5: { const p = decoded as GroupTextPayload; if (p.decrypted) { const sender = p.decrypted.sender ?? '?'; @@ -155,20 +168,20 @@ function buildSummary(payloadType: number, decoded: unknown, rawHex?: string): s } return '[encrypted]'; } - case 2: { // TextMessage (direct/private) + case 2: { const p = decoded as TextMessagePayload; if (p.decrypted?.message) return `${p.decrypted.message}`; return '[encrypted DM]'; } - case 3: { // Ack + case 3: { const p = decoded as AckPayload; return `ACK ${p.checksum.slice(0, 4)}`; } - case 8: { // Path + case 8: { const p = decoded as PathPayload; return `${p.pathLength} hop path`; } - case 9: { // Trace + case 9: { const p = decoded as TracePayload; return `trace ${p.pathHashes.length} hops`; } @@ -199,6 +212,7 @@ export function startMqttClient(): void { const brokerUrl = process.env['MQTT_BROKER_URL'] ?? 'ws://mosquitto:9001'; console.log(`[mqtt] connecting to ${brokerUrl}`); console.log(`[mqtt] channels: ${channelEntries.map((e) => e.name).join(', ')}`); + console.log(`[mqtt] topic prefixes: ${Array.from(TOPIC_PREFIXES).join(', ')}`); const client = mqtt.connect(brokerUrl, { reconnectPeriod: 5000, @@ -240,7 +254,6 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise { return; } - // โ”€โ”€ Status: update observer node metadata โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const origin = json['origin'] as string | undefined; const originId = json['origin_id'] as string | undefined; @@ -257,22 +270,19 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise { firmwareVersion: (firmware && firmware !== 'unknown') ? firmware : undefined, network, }); - emitNode(nodeId); + emitNode(nodeId, { network, observerId: observerKey }); return; } - // โ”€โ”€ Packets only below โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if (suffix !== 'packets') return; - // mctomqtt fields (all arrive as strings) const packetHash = (json['hash'] as string | undefined) ?? crypto.randomUUID(); const packetType = toNum(json['packet_type']); const rssi = toNum(json['RSSI']); const snr = toNum(json['SNR']); const rawHex = (json['raw'] as string | undefined) ?? ''; - const direction = json['direction'] as string | undefined; // 'rx' | 'tx' + const direction = json['direction'] as string | undefined; - // โ”€โ”€ Decode raw hex with keyStore for all packet types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let innerPayload: Record | undefined; let decodedHash: string | undefined; let decodedHops: number | undefined; @@ -297,49 +307,48 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise { summary = buildSummary(decoded.payloadType, decodedInner, rawHex); if (decoded.payloadType === 4) { - // Advert: upsert the advertising node const inner = decodedInner as unknown as Record | undefined; const appData = inner?.['appData'] as Record | undefined; const loc = appData?.['location'] as Record | undefined; const senderKey = inner?.['publicKey'] as string | undefined; const nodeId = senderKey ?? observerKey; - void upsertNode(nodeId, { - name: appData?.['name'] as string | undefined, - lat: loc?.['latitude'], - lon: loc?.['longitude'], - role: appData?.['deviceRole'] as number | undefined, - iata, - publicKey: senderKey, - network, - }); + if (network !== 'test') { + void upsertNode(nodeId, { + name: appData?.['name'] as string | undefined, + lat: loc?.['latitude'], + lon: loc?.['longitude'], + role: appData?.['deviceRole'] as number | undefined, + iata, + publicKey: senderKey, + network, + }); - // Only count each unique advert once โ€” relay copies share the same decoded hash. - if (decodedHash && tryCountAdvert(decodedHash)) { - advertCount = await incrementAdvertCount(nodeId); + if (decodedHash && tryCountAdvert(decodedHash)) { + advertCount = await incrementAdvertCount(nodeId); + } + + emitNodeUpsert({ + node_id: nodeId, + name: appData?.['name'] as string | undefined, + lat: loc?.['latitude'], + lon: loc?.['longitude'], + role: appData?.['deviceRole'] as number | undefined, + iata, + network, + observer_id: observerKey, + public_key: senderKey, + last_seen: new Date().toISOString(), + is_online: true, + advert_count: advertCount, + }); } - // Push full node data to frontend so map updates immediately - emitNodeUpsert({ - node_id: nodeId, - name: appData?.['name'] as string | undefined, - lat: loc?.['latitude'], - lon: loc?.['longitude'], - role: appData?.['deviceRole'] as number | undefined, - iata, - public_key: senderKey, - last_seen: new Date().toISOString(), - is_online: true, - advert_count: advertCount, - }); - innerPayload = inner; srcNodeId = senderKey; } else if (decoded.payloadType === 5) { - // GroupText: store decoded payload so sender name is queryable from DB innerPayload = decodedInner as unknown as Record | undefined; } else if (decoded.payloadType === 7) { - // AnonRequest: sender public key is exposed in clear const inner = decodedInner as unknown as Record | undefined; srcNodeId = inner?.['senderPublicKey'] as string | undefined; } @@ -349,10 +358,6 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise { } } - // MeshCore v1.14.0 multibyte-path adverts can currently reach us with packet_type=4 - // on the MQTT envelope while the community decoder still classifies the raw payload - // as another type. For self-originated TX adverts, use the MQTT origin metadata as a - // safe fallback so the repeater still appears correctly on the site. const hasDecodedAdvertPayload = Boolean( innerPayload && typeof innerPayload === 'object' @@ -370,15 +375,10 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise { innerPayload = buildAdvertFallbackPayload(originId, origin); } - // Use decoder hash as primary key โ€” it's derived from packet content and is the - // same for TX and RX of the same packet, enabling frontend deduplication. - // Fall back to mctomqtt hash (RX only) when decode fails, then UUID. - // The 16-char mctomqtt hash remains accessible via payload->>'hash' in the DB. const finalHash = decodedHash ?? (json['hash'] as string | undefined) ?? crypto.randomUUID(); - // Keep observer last-seen current void upsertNode(observerKey, { iata, network }); - emitNode(observerKey); + emitNode(observerKey, { network, observerId: observerKey }); if (useTxAdvertFallback && originId) { await upsertNode(originId, { @@ -394,6 +394,8 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise { node_id: originId, name: origin, iata, + network, + observer_id: observerKey, public_key: originId, last_seen: new Date().toISOString(), is_online: true, @@ -408,6 +410,7 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise { rxNodeId: observerKey, srcNodeId, topic, + network, packetType, hopCount: decodedHops, direction, @@ -442,10 +445,6 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise { } } -/** - * Decode all historical packets with raw_hex and queue link jobs for those with - * relay path data. Called once at startup when node_links is empty. - */ export async function backfillHistoricalLinks( queueFn: (rxNodeId: string, srcNodeId: string | undefined, path: string[], hopCount: number | undefined) => void, ): Promise { diff --git a/backend/src/path-beta/resolver.ts b/backend/src/path-beta/resolver.ts index 4ed1c24..9e57514 100644 --- a/backend/src/path-beta/resolver.ts +++ b/backend/src/path-beta/resolver.ts @@ -1110,15 +1110,16 @@ export type BetaResolvedPayload = { }; }; -export async function resolveBetaPathForPacketHash(packetHash: string, network: string): Promise { +export async function resolveBetaPathForPacketHash(packetHash: string, network: string, observer?: string): Promise { const packetResult = await query( `SELECT packet_hash, rx_node_id, src_node_id, packet_type, hop_count, path_hashes FROM packets WHERE packet_hash = $1 AND ($2 = 'all' OR network = $2) + ${observer ? 'AND LOWER(rx_node_id) = LOWER($3)' : ''} ORDER BY time DESC LIMIT 1`, - [packetHash, network], + observer ? [packetHash, network, observer] : [packetHash, network], ); const packet = packetResult.rows[0]; diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index d54877c..f28d8c7 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -53,6 +53,7 @@ export interface LivePacket { rxNodeId?: string; srcNodeId?: string; topic: string; + network?: string; packetType?: number; hopCount?: number; direction?: string; // 'rx' | 'tx' from mctomqtt diff --git a/backend/src/ws/server.ts b/backend/src/ws/server.ts index a029cee..5302a6e 100644 --- a/backend/src/ws/server.ts +++ b/backend/src/ws/server.ts @@ -4,6 +4,7 @@ import type { Server } from 'node:http'; import { Redis } from 'ioredis'; import type { WSMessage, LivePacket } from '../types/index.js'; import { getNodes, getLastNPackets, getViableLinks } from '../db/index.js'; +import { resolveRequestNetwork } from '../http/requestScope.js'; const REDIS_CHANNEL = 'meshcore:live'; @@ -12,15 +13,103 @@ let sub: Redis; const VIABLE_LINK_CACHE_TTL_MS = 30_000; const viableLinksCache = new Map> }>(); -async function getCachedViableLinks(network?: string) { - const key = network ?? 'all'; +type ClientScope = { + network?: string; + observer?: string; + nodeIds: Set; +}; + +function normalizeObserver(value: string | null): string | undefined { + const trimmed = String(value ?? '').trim().toLowerCase(); + return trimmed && /^[0-9a-f]{64}$/.test(trimmed) ? trimmed : undefined; +} + +function cacheKey(network?: string, observer?: string): string { + return `${network ?? 'all'}|${observer ?? 'all'}`; +} + +async function getCachedViableLinks(network?: string, observer?: string) { + const key = cacheKey(network, observer); const cached = viableLinksCache.get(key); if (cached && (Date.now() - cached.ts) < VIABLE_LINK_CACHE_TTL_MS) return cached.data; - const data = await getViableLinks(network); + const data = await getViableLinks(network, observer); viableLinksCache.set(key, { ts: Date.now(), data }); return data; } +function packetMatchesScope(packet: Partial, scope: ClientScope): boolean { + if (scope.network && packet.network && packet.network !== scope.network) return false; + if (!scope.network && !scope.observer && packet.network === 'test') return false; + if (scope.observer) { + return String(packet.rxNodeId ?? '').toLowerCase() === scope.observer; + } + return true; +} + +function nodeMatchesScope(nodeId: string | undefined, scope: ClientScope): boolean { + if (!nodeId) return false; + const normalized = nodeId.toLowerCase(); + if (scope.observer && normalized === scope.observer) return true; + return scope.nodeIds.has(normalized); +} + +function shouldSendMessage(msg: WSMessage, scope: ClientScope): boolean { + if (msg.type === 'packet') { + return packetMatchesScope(msg.data as Partial, scope); + } + + if (msg.type === 'node_update') { + const data = msg.data as { nodeId?: string; network?: string; observerId?: string }; + if (scope.network && data.network && data.network !== scope.network) return false; + if (!scope.network && !scope.observer && data.network === 'test') return false; + if (!scope.network && !scope.observer) return true; + if (scope.observer && data.observerId && data.observerId.toLowerCase() !== scope.observer && !nodeMatchesScope(data.nodeId, scope)) { + return false; + } + return nodeMatchesScope(data.nodeId, scope); + } + + if (msg.type === 'node_upsert') { + const data = msg.data as { node_id?: string; network?: string; observer_id?: string }; + if (scope.network && data.network && data.network !== scope.network) return false; + if (!scope.network && !scope.observer && data.network === 'test') return false; + if (!scope.network && !scope.observer) return true; + if (scope.observer) { + if (data.observer_id && data.observer_id.toLowerCase() === scope.observer) return true; + if (data.observer_id && data.observer_id.toLowerCase() !== scope.observer && !nodeMatchesScope(data.node_id, scope)) { + return false; + } + } + return nodeMatchesScope(data.node_id, scope); + } + + if (msg.type === 'coverage_update') { + const data = msg.data as { node_id?: string }; + return nodeMatchesScope(data.node_id, scope); + } + + if (msg.type === 'link_update') { + const data = msg.data as { node_a_id?: string; node_b_id?: string }; + return nodeMatchesScope(data.node_a_id, scope) || nodeMatchesScope(data.node_b_id, scope); + } + + return true; +} + +function trackScopedNodes(msg: WSMessage, scope: ClientScope): void { + if (msg.type === 'packet') { + const data = msg.data as Partial; + if (data.rxNodeId) scope.nodeIds.add(data.rxNodeId.toLowerCase()); + if (data.srcNodeId) scope.nodeIds.add(data.srcNodeId.toLowerCase()); + return; + } + + if (msg.type === 'node_upsert') { + const data = msg.data as { node_id?: string }; + if (data.node_id) scope.nodeIds.add(data.node_id.toLowerCase()); + } +} + export function initWebSocketServer(httpServer: Server): WebSocketServer { const redisUrl = process.env['REDIS_URL'] ?? 'redis://redis:6379'; @@ -55,18 +144,38 @@ export function initWebSocketServer(httpServer: Server): WebSocketServer { }, }); + const clientScopes = new Map(); + wss.on('connection', async (ws: WebSocket, req: IncomingMessage) => { console.log('[ws] client connected, total:', wss.clients.size); - // Derive network filter from query param (?network=teesside) + // Derive scope from query params (?network=teesside&observer=) const reqUrl = new URL(req.url ?? '/', 'http://localhost'); - const network = reqUrl.searchParams.get('network') ?? undefined; + const requestedNetwork = resolveRequestNetwork(reqUrl.searchParams.get('network'), req.headers); + const network = requestedNetwork === 'all' ? undefined : requestedNetwork; + const observer = normalizeObserver(reqUrl.searchParams.get('observer')); + const scope: ClientScope = { + network, + observer, + nodeIds: new Set(), + }; + clientScopes.set(ws, scope); // Send initial state: known nodes + last 5 minutes of packets try { const [nodes, packets, viableLinks] = await Promise.all([ - getNodes(network), getLastNPackets(7, network), getCachedViableLinks(network), + getNodes(network, observer), getLastNPackets(7, network, observer), getCachedViableLinks(network, observer), ]); + for (const node of nodes) { + const nodeId = String((node as { node_id?: string }).node_id ?? '').toLowerCase(); + if (nodeId) scope.nodeIds.add(nodeId); + } + for (const packet of packets) { + const rxNodeId = String((packet as { rx_node_id?: string }).rx_node_id ?? '').toLowerCase(); + const srcNodeId = String((packet as { src_node_id?: string }).src_node_id ?? '').toLowerCase(); + if (rxNodeId) scope.nodeIds.add(rxNodeId); + if (srcNodeId) scope.nodeIds.add(srcNodeId); + } const viablePairs = viableLinks.map((l) => [l.node_a_id, l.node_b_id] as [string, string]); const initMsg: WSMessage = { type: 'initial_state', @@ -79,6 +188,7 @@ export function initWebSocketServer(httpServer: Server): WebSocketServer { } ws.on('close', () => { + clientScopes.delete(ws); console.log('[ws] client disconnected, total:', wss.clients.size); }); @@ -90,7 +200,17 @@ export function initWebSocketServer(httpServer: Server): WebSocketServer { // Fan-out Redis messages to all connected WS clients sub.on('message', (_channel: string, messageStr: string) => { if (wss.clients.size === 0) return; + let parsed: WSMessage | null = null; + try { + parsed = JSON.parse(messageStr) as WSMessage; + } catch { + return; + } for (const client of wss.clients) { + if (client.readyState !== WebSocket.OPEN) continue; + const scope = clientScopes.get(client); + if (parsed && scope && !shouldSendMessage(parsed, scope)) continue; + if (parsed && scope) trackScopedNodes(parsed, scope); if (client.readyState === WebSocket.OPEN) { client.send(messageStr); } @@ -105,8 +225,8 @@ export function broadcastPacket(packet: LivePacket): void { void pub.publish(REDIS_CHANNEL, JSON.stringify(msg)); } -export function broadcastNodeUpdate(nodeId: string): void { - const msg: WSMessage = { type: 'node_update', data: { nodeId, ts: Date.now() }, ts: Date.now() }; +export function broadcastNodeUpdate(nodeId: string, meta?: { network?: string; observerId?: string }): void { + const msg: WSMessage = { type: 'node_update', data: { nodeId, network: meta?.network, observerId: meta?.observerId, ts: Date.now() }, ts: Date.now() }; void pub.publish(REDIS_CHANNEL, JSON.stringify(msg)); } diff --git a/docker-compose.yml b/docker-compose.yml index 1a76c74..6dbc149 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,7 +53,7 @@ services: OWNER_DATABASE_URL: ${OWNER_DATABASE_URL:-postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${OWNER_POSTGRES_DB:-meshcore_owner_auth}} OWNER_MQTT_USERNAME_MAP: ${OWNER_MQTT_USERNAME_MAP:-} OWNER_COOKIE_SECRET: ${OWNER_COOKIE_SECRET:-} - ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:3001,http://localhost:3002} + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:3001,http://localhost:3002},https://test.ukmesh.com COVERAGE_MODEL_VERSION: ${COVERAGE_MODEL_VERSION:-2} NODE_ENV: production depends_on: @@ -507,6 +507,7 @@ services: args: VITE_APP_HOSTNAME: app.ukmesh.com VITE_NETWORK: ukmesh + VITE_SITE: ukmesh restart: unless-stopped depends_on: - backend @@ -526,6 +527,44 @@ services: depends_on: - backend + app-dev: + build: + context: . + dockerfile: Dockerfile.app + args: + VITE_APP_HOSTNAME: ${VITE_DEV_APP_HOSTNAME:-} + VITE_NETWORK: test + VITE_SITE: dev + VITE_OBSERVER_ID: ${VITE_DEV_OBSERVER_ID:-} + VITE_SITE_DISPLAY_NAME: ${VITE_DEV_SITE_DISPLAY_NAME:-UK Mesh Test} + VITE_SITE_FOOTER_NAME: ${VITE_DEV_SITE_FOOTER_NAME:-UK Mesh Test} + VITE_SITE_APP_URL: ${VITE_DEV_SITE_APP_URL:-https://test.ukmesh.com} + VITE_SITE_HOME_URL: ${VITE_DEV_SITE_HOME_URL:-https://test.ukmesh.com} + restart: unless-stopped + depends_on: + - backend + ports: + - "3005:80" + + website-dev: + build: + context: . + dockerfile: Dockerfile.website + args: + VITE_APP_HOSTNAME: ${VITE_DEV_APP_HOSTNAME:-app-dev.ukmesh.com} + VITE_NETWORK: test + VITE_SITE: dev + VITE_OBSERVER_ID: ${VITE_DEV_OBSERVER_ID:-} + VITE_SITE_DISPLAY_NAME: ${VITE_DEV_SITE_DISPLAY_NAME:-UK Mesh Test} + VITE_SITE_FOOTER_NAME: ${VITE_DEV_SITE_FOOTER_NAME:-UK Mesh Test} + VITE_SITE_APP_URL: ${VITE_DEV_SITE_APP_URL:-https://test.ukmesh.com} + VITE_SITE_HOME_URL: ${VITE_DEV_SITE_HOME_URL:-https://test.ukmesh.com} + restart: unless-stopped + ports: + - "3006:80" + depends_on: + - backend + cloudflared: image: cloudflare/cloudflared:latest restart: unless-stopped @@ -539,6 +578,8 @@ services: - website - app-ukmesh - website-ukmesh + - app-dev + - website-dev - mosquitto volumes: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ca42874..245ed91 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { useLinkState } from './hooks/useLinkState.js'; import { usePacketPathOverlay } from './hooks/usePacketPathOverlay.js'; import { useAppMessageHandler } from './hooks/useAppMessageHandler.js'; import { getCurrentSite } from './config/site.js'; +import { uncachedEndpoint, withScopeParams } from './utils/api.js'; const DEFAULT_FILTERS: Filters = { livePackets: true, @@ -53,16 +54,17 @@ export const App: React.FC = () => { arcs, activeNodes, handleInitialState, + replaceRecentPackets, handlePacket, handleNodeUpdate, handleNodeUpsert, } = useNodes(); - // 'ukmesh' build sees all data; teesside/default build filters to its own network - const networkFilter = import.meta.env['VITE_NETWORK'] === 'ukmesh' ? undefined : site.network; + const networkFilter = site.networkFilter; + const observerFilter = site.observerId; - const { coverage, handleCoverageUpdate } = useCoverage(networkFilter); - const stats = useDashboardStats(networkFilter); + const { coverage, handleCoverageUpdate } = useCoverage({ network: networkFilter, observer: observerFilter }); + const stats = useDashboardStats({ network: networkFilter, observer: observerFilter }); const { linkMetrics, viablePairsArr, @@ -72,10 +74,9 @@ export const App: React.FC = () => { } = useLinkState(); const { - packetPath, - betaPacketPath, - betaExtraPurplePaths, - betaLowConfidencePath, + packetPaths, + betaPacketPaths, + betaLowConfidencePaths, betaLowConfidenceSegments, betaCompletionPaths, betaPathConfidence, @@ -89,6 +90,7 @@ export const App: React.FC = () => { nodes, filters, network: networkFilter, + observer: observerFilter, }); useEffect(() => { @@ -101,6 +103,42 @@ export const App: React.FC = () => { localStorage.setItem(FILTERS_KEY, JSON.stringify(filters)); }, [filters]); + useEffect(() => { + let cancelled = false; + const syncRecentPackets = async () => { + try { + const response = await fetch( + uncachedEndpoint(withScopeParams('/api/packets/recent?limit=40', { network: networkFilter, observer: observerFilter })), + { cache: 'no-store' }, + ); + if (!response.ok) return; + const rows = await response.json() as Array<{ + time: string; + packet_hash: string; + rx_node_id?: string; + observer_node_ids?: string[] | null; + src_node_id?: string; + packet_type?: number; + hop_count?: number; + payload?: Record; + advert_count?: number | null; + path_hashes?: string[] | null; + }>; + if (cancelled) return; + replaceRecentPackets(rows); + } catch { + // keep websocket as primary path; polling is only a feed repair/fallback + } + }; + + void syncRecentPackets(); + const timer = window.setInterval(() => { void syncRecentPackets(); }, 4000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [networkFilter, observerFilter, replaceRecentPackets]); + useEffect(() => { const wasHexClashes = prevHexClashesRef.current; const isHexClashes = filters.hexClashes; @@ -176,9 +214,12 @@ export const App: React.FC = () => { applyInitialViablePairs, applyInitialViableLinks, applyLinkUpdate, + onPacketObserved: () => { + window.dispatchEvent(new Event('meshcore:packet-observed')); + }, }); - const wsState = useWebSocket(handleMessage, networkFilter); + const wsState = useWebSocket(handleMessage, { network: networkFilter, observer: observerFilter }); return (
@@ -209,10 +250,9 @@ export const App: React.FC = () => { maxHexClashHops={filters.hexClashMaxHops} viablePairsArr={viablePairsArr} linkMetrics={linkMetrics} - packetPath={packetPath} - betaPath={betaPacketPath} - betaExtraPurplePaths={betaExtraPurplePaths} - betaLowPath={betaLowConfidencePath} + packetPaths={packetPaths} + betaPaths={betaPacketPaths} + betaLowPaths={betaLowConfidencePaths} betaLowSegments={betaLowConfidenceSegments} betaCompletionPaths={betaCompletionPaths} showBetaPaths={filters.betaPaths || pinnedPacketId !== null} diff --git a/frontend/src/components/LiveStatsSection.tsx b/frontend/src/components/LiveStatsSection.tsx index c555f6f..15b02ae 100644 --- a/frontend/src/components/LiveStatsSection.tsx +++ b/frontend/src/components/LiveStatsSection.tsx @@ -11,6 +11,7 @@ type SiteStats = { type LiveStatsSectionProps = { network?: string; + observer?: string; }; const EMPTY_STATS: SiteStats = { @@ -37,13 +38,14 @@ const StatCard: React.FC<{ value: number; label: string; suffix?: string }> = ({ ); }; -export const LiveStatsSection: React.FC = ({ network }) => { +export const LiveStatsSection: React.FC = ({ network, observer }) => { const [stats, setStats] = useState(EMPTY_STATS); const hopFlash = useFlash(stats.longestHop); + const refreshSeconds = 15; useEffect(() => { const loadStats = () => { - fetch(uncachedEndpoint(statsEndpoint(network)), { cache: 'no-store' }) + fetch(uncachedEndpoint(statsEndpoint({ network, observer })), { cache: 'no-store' }) .then((response) => response.json()) .then((data) => setStats({ packetsDay: data.packetsDay, @@ -55,19 +57,25 @@ export const LiveStatsSection: React.FC = ({ network }) = }; loadStats(); - const interval = setInterval(loadStats, 30_000); + const interval = setInterval(loadStats, refreshSeconds * 1000); return () => clearInterval(interval); - }, [network]); + }, [network, observer]); return (

Live network stats

-

Updates every 30 seconds from the shared packet feed.

+

+ {observer + ? `Updates every ${refreshSeconds} seconds from the selected observer feed.` + : network === 'test' + ? `Updates every ${refreshSeconds} seconds from the isolated test feed.` + : `Updates every ${refreshSeconds} seconds from the shared packet feed.`} +

- +
diff --git a/frontend/src/components/Map/MapView.tsx b/frontend/src/components/Map/MapView.tsx index c7dc6c7..fd5b494 100644 --- a/frontend/src/components/Map/MapView.tsx +++ b/frontend/src/components/Map/MapView.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { MapContainer, TileLayer, useMap, Pane, Polygon, Polyline } from 'react-leaflet'; -import type { LatLngExpression, Map as LeafletMap, Polyline as LeafletPolyline } from 'leaflet'; +import type { LatLngExpression, Map as LeafletMap } from 'leaflet'; import type { MeshNode, PacketArc } from '../../hooks/useNodes.js'; import type { NodeCoverage } from '../../hooks/useCoverage.js'; import type { LinkMetrics } from '../../utils/pathing.js'; @@ -100,10 +100,9 @@ interface MapViewProps { maxHexClashHops: number; viablePairsArr: [string, string][]; linkMetrics: Map; - packetPath: [number, number][] | null; - betaPath: [number, number][] | null; - betaExtraPurplePaths: [number, number][][]; - betaLowPath: [number, number][] | null; + packetPaths: [number, number][][]; + betaPaths: [number, number][][]; + betaLowPaths: [number, number][][]; betaLowSegments: [[number, number], [number, number]][]; betaCompletionPaths: [number, number][][]; showBetaPaths: boolean; @@ -117,7 +116,7 @@ const DEFAULT_ZOOM = 11; export const MapView: React.FC = ({ nodes, arcs, activeNodes, coverage, showPackets, showCoverage, showClientNodes, - showLinks, showHexClashes, maxHexClashHops, viablePairsArr, linkMetrics, packetPath, betaPath, betaExtraPurplePaths, betaLowPath, betaCompletionPaths, showBetaPaths, pathOpacity, onMapReady, + showLinks, showHexClashes, maxHexClashHops, viablePairsArr, linkMetrics, packetPaths, betaPaths, betaLowPaths, betaCompletionPaths, showBetaPaths, pathOpacity, onMapReady, }) => { const [map, setMap] = useState(null); const [focusedPrefix, setFocusedPrefix] = useState(null); @@ -168,18 +167,14 @@ export const MapView: React.FC = ({ return () => mq.removeEventListener('change', update); }, []); - // Refs to Leaflet Polyline instances for direct SVG attribute animation - const regularPathRef = useRef(null); - const betaLowPathRef = useRef(null); - const betaPathRef = useRef(null); const aniFrameRef = useRef(null); // Animate marching dashes by incrementing stroke-dashoffset directly on the // Leaflet SVG path element. CSS animation is unreliable here because Leaflet // calls _updateStyle (setAttribute) on every prop change, which can interrupt // CSS keyframe animations. Direct DOM manipulation in an rAF loop is stable. - const hasRegular = !!packetPath; - const hasBeta = Boolean(showBetaPaths && (betaLowPath || betaPath || betaExtraPurplePaths.length > 0)); + const hasRegular = packetPaths.length > 0; + const hasBeta = Boolean(showBetaPaths && (betaLowPaths.length > 0 || betaPaths.length > 0)); useEffect(() => { if (!hasRegular && !hasBeta) { @@ -190,22 +185,16 @@ export const MapView: React.FC = ({ return; } - let offset = 0; - const tick = () => { - offset = (offset + DASH_STEP) % DASH_CYCLE; - // Negative dashoffset = dashes march forward (source โ†’ destination) - const val = String(-offset); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const rp = (regularPathRef.current as any)?._path as SVGPathElement | null; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const bl = (betaLowPathRef.current as any)?._path as SVGPathElement | null; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const bp = (betaPathRef.current as any)?._path as SVGPathElement | null; - if (hasRegular && rp) rp.setAttribute('stroke-dashoffset', val); - if (hasBeta && bl) bl.setAttribute('stroke-dashoffset', val); - if (hasBeta && bp) bp.setAttribute('stroke-dashoffset', val); - aniFrameRef.current = requestAnimationFrame(tick); - }; + let offset = 0; + const tick = () => { + offset = (offset + DASH_STEP) % DASH_CYCLE; + const val = String(-offset); + const paths = map?.getContainer().querySelectorAll( + '.packet-path-overlay, .beta-red-path-overlay, .beta-purple-path-overlay', + ); + paths?.forEach((path) => path.setAttribute('stroke-dashoffset', val)); + aniFrameRef.current = requestAnimationFrame(tick); + }; aniFrameRef.current = requestAnimationFrame(tick); return () => { @@ -214,7 +203,7 @@ export const MapView: React.FC = ({ aniFrameRef.current = null; } }; - }, [hasRegular, hasBeta]); // eslint-disable-line react-hooks/exhaustive-deps + }, [hasRegular, hasBeta, 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( @@ -615,59 +604,48 @@ export const MapView: React.FC = ({ })} {/* Live packet path โ€” marching dashes from source โ†’ observer */} - {packetPath && ( + {packetPaths.map((path, idx) => ( - )} + ))} {/* Beta path โ€” uncertain (red) drawn first so confident (purple) always renders on top */} - {showBetaPaths && betaLowPath && ( + {showBetaPaths && betaLowPaths.map((path, idx) => ( - )} + ))} {/* Purple confident portion rendered last โ€” highest z-order so it is never obscured by the red uncertain portion */} - {showBetaPaths && betaPath && ( + {showBetaPaths && betaPaths.map((path, idx) => ( - )} - - {showBetaPaths && betaExtraPurplePaths.map((path, idx) => ( - ))} diff --git a/frontend/src/components/PacketFeed.tsx b/frontend/src/components/PacketFeed.tsx index e9d287f..e214887 100644 --- a/frontend/src/components/PacketFeed.tsx +++ b/frontend/src/components/PacketFeed.tsx @@ -28,24 +28,25 @@ export const PacketFeed: React.FC = React.memo(({ packets, nodes, onPacke () => packets.slice(0, VISIBLE_ROWS).reverse(), [packets], ); - const [tickClass, setTickClass] = useState(''); + const [newestVisibleId, setNewestVisibleId] = useState(null); const latestIdRef = useRef(null); useEffect(() => { const latestId = packets[0]?.id ?? null; if (!latestId || latestIdRef.current === latestId) return; latestIdRef.current = latestId; - setTickClass(' packet-feed--tick'); - const timer = setTimeout(() => setTickClass(''), 220); + setNewestVisibleId(latestId); + const timer = setTimeout(() => setNewestVisibleId((current) => (current === latestId ? null : current)), 220); return () => clearTimeout(timer); }, [packets]); return ( -
+
{visible.map((p) => { const typeLabel = p.packetType !== undefined ? (TYPE_LABELS[p.packetType] ?? `T${p.packetType}`) : '???'; + const observerIata = p.rxNodeId ? nodes.get(p.rxNodeId)?.iata : undefined; const rawContent = p.summary ?? (p.rxNodeId ? nodes.get(p.rxNodeId)?.name : undefined); @@ -63,12 +64,15 @@ export const PacketFeed: React.FC = React.memo(({ packets, nodes, onPacke return (
onPacketClick?.(p)} role="button" tabIndex={0} onKeyDown={(e) => e.key === 'Enter' && onPacketClick?.(p)} > + {observerIata && ( + {observerIata} + )} {typeLabel} {advertBadge && ( {advertBadge} diff --git a/frontend/src/components/StatsPanel/StatsPanel.tsx b/frontend/src/components/StatsPanel/StatsPanel.tsx index 5315102..0bbd07c 100644 --- a/frontend/src/components/StatsPanel/StatsPanel.tsx +++ b/frontend/src/components/StatsPanel/StatsPanel.tsx @@ -72,6 +72,6 @@ export const StatsPanel: React.FC = ({
- +
); diff --git a/frontend/src/config/site.ts b/frontend/src/config/site.ts index 7e9939f..d8b0e53 100644 --- a/frontend/src/config/site.ts +++ b/frontend/src/config/site.ts @@ -1,21 +1,32 @@ -export type SiteId = 'teesside' | 'ukmesh'; +export type NetworkId = 'teesside' | 'ukmesh' | 'test'; +export type SiteId = 'teesside' | 'ukmesh' | 'dev'; -type SiteConfig = { +export type SiteConfig = { id: SiteId; displayName: string; footerName: string; - network: SiteId; + network: NetworkId; + networkFilter?: NetworkId; + observerId?: string; appUrl: string; appHomeUrl: string; mapHomeUrl: string; }; +function envValue(key: keyof ImportMetaEnv, fallback: string): string { + const value = String(import.meta.env[key] ?? '').trim(); + return value || fallback; +} + +const DEV_OBSERVER_ID = String(import.meta.env['VITE_OBSERVER_ID'] ?? '').trim().toLowerCase() || undefined; + const SITE_CONFIGS: Record = { teesside: { id: 'teesside', displayName: 'Teesside Mesh', footerName: 'Teesside Mesh Network', network: 'teesside', + networkFilter: 'teesside', appUrl: 'https://app.teessidemesh.com', appHomeUrl: 'https://www.teessidemesh.com', mapHomeUrl: 'https://app.teessidemesh.com', @@ -29,19 +40,33 @@ const SITE_CONFIGS: Record = { appHomeUrl: 'https://www.ukmesh.com', mapHomeUrl: 'https://app.ukmesh.com', }, + dev: { + id: 'dev', + displayName: envValue('VITE_SITE_DISPLAY_NAME', 'UK Mesh Test'), + footerName: envValue('VITE_SITE_FOOTER_NAME', 'UK Mesh Test'), + network: 'test', + networkFilter: 'test', + observerId: DEV_OBSERVER_ID, + appUrl: envValue('VITE_SITE_APP_URL', 'https://test.ukmesh.com'), + appHomeUrl: envValue('VITE_SITE_HOME_URL', 'https://test.ukmesh.com'), + mapHomeUrl: envValue('VITE_SITE_APP_URL', 'https://test.ukmesh.com'), + }, }; export function getCurrentSite(): SiteConfig { const siteEnv = import.meta.env['VITE_SITE']; + if (siteEnv === 'dev') return SITE_CONFIGS.dev; if (siteEnv === 'ukmesh') return SITE_CONFIGS.ukmesh; if (siteEnv === 'teesside') return SITE_CONFIGS.teesside; const networkEnv = import.meta.env['VITE_NETWORK']; if (networkEnv === 'ukmesh') return SITE_CONFIGS.ukmesh; if (networkEnv === 'teesside') return SITE_CONFIGS.teesside; + if (networkEnv === 'test') return SITE_CONFIGS.dev; if (typeof window !== 'undefined') { const host = window.location.hostname.toLowerCase(); + if (host === 'test.ukmesh.com' || host.includes('app-dev.ukmesh.com') || host === 'dev.ukmesh.com') return SITE_CONFIGS.dev; if (host.includes('ukmesh.com')) return SITE_CONFIGS.ukmesh; if (host.includes('teessidemesh.com')) return SITE_CONFIGS.teesside; } diff --git a/frontend/src/hooks/useAppMessageHandler.ts b/frontend/src/hooks/useAppMessageHandler.ts index 8366cf8..fb45064 100644 --- a/frontend/src/hooks/useAppMessageHandler.ts +++ b/frontend/src/hooks/useAppMessageHandler.ts @@ -37,6 +37,7 @@ type UseAppMessageHandlerParams = { count_a_to_b?: number; count_b_to_a?: number; }) => void; + onPacketObserved?: () => void; }; export function useAppMessageHandler({ @@ -48,6 +49,7 @@ export function useAppMessageHandler({ applyInitialViablePairs, applyInitialViableLinks, applyLinkUpdate, + onPacketObserved, }: UseAppMessageHandlerParams) { return useCallback((msg: WSMessage) => { if (msg.type === 'initial_state') { @@ -65,6 +67,7 @@ export function useAppMessageHandler({ } if (msg.type === 'packet') { + onPacketObserved?.(); handlePacket(msg.data as LivePacketData); return; } @@ -108,5 +111,6 @@ export function useAppMessageHandler({ applyInitialViablePairs, applyInitialViableLinks, applyLinkUpdate, + onPacketObserved, ]); } diff --git a/frontend/src/hooks/useCoverage.ts b/frontend/src/hooks/useCoverage.ts index 9d8398f..52ff40f 100644 --- a/frontend/src/hooks/useCoverage.ts +++ b/frontend/src/hooks/useCoverage.ts @@ -1,4 +1,5 @@ import { useState, useCallback, useEffect } from 'react'; +import { withScopeParams, type ApiScope } from '../utils/api.js'; export interface NodeCoverage { node_id: string; @@ -9,17 +10,17 @@ export interface NodeCoverage { calculated_at?: string; } -export function useCoverage(network?: string) { +export function useCoverage(scope: ApiScope = {}) { const [coverage, setCoverage] = useState([]); // Fetch all stored polygons on mount useEffect(() => { - const url = network ? `/api/coverage?network=${encodeURIComponent(network)}` : '/api/coverage'; + const url = withScopeParams('/api/coverage', scope); fetch(url) .then((r) => r.json()) .then((data: NodeCoverage[]) => setCoverage(data)) .catch(() => { /* non-fatal */ }); - }, [network]); + }, [scope.network, scope.observer]); // Called when a coverage_update WS message arrives const handleCoverageUpdate = useCallback((update: { node_id: string; geom: NodeCoverage['geom']; strength_geoms?: NodeCoverage['strength_geoms'] }) => { diff --git a/frontend/src/hooks/useDashboardStats.ts b/frontend/src/hooks/useDashboardStats.ts index ee8c7e5..59f2087 100644 --- a/frontend/src/hooks/useDashboardStats.ts +++ b/frontend/src/hooks/useDashboardStats.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { statsEndpoint, uncachedEndpoint } from '../utils/api.js'; +import { statsEndpoint, uncachedEndpoint, type ApiScope } from '../utils/api.js'; export type DashboardStats = { mqttNodes: number; @@ -17,14 +17,14 @@ const EMPTY_STATS: DashboardStats = { totalNodes: 0, }; -export function useDashboardStats(network?: string): DashboardStats { +export function useDashboardStats(scope: ApiScope = {}): DashboardStats { const [stats, setStats] = useState(EMPTY_STATS); useEffect(() => { const fetchStats = async () => { try { const response = await fetch( - uncachedEndpoint(statsEndpoint(network)), + uncachedEndpoint(statsEndpoint(scope)), { cache: 'no-store' } ); if (response.ok) { @@ -36,9 +36,23 @@ export function useDashboardStats(network?: string): DashboardStats { }; fetchStats(); - const interval = setInterval(fetchStats, 30_000); + const interval = setInterval(fetchStats, 10_000); return () => clearInterval(interval); - }, [network]); + }, [scope.network, scope.observer]); + + useEffect(() => { + const handlePacketObserved = () => { + setStats((current) => ({ + ...current, + packetsDay: current.packetsDay + 1, + })); + }; + + window.addEventListener('meshcore:packet-observed', handlePacketObserved as EventListener); + return () => { + window.removeEventListener('meshcore:packet-observed', handlePacketObserved as EventListener); + }; + }, []); return stats; } diff --git a/frontend/src/hooks/useNodes.ts b/frontend/src/hooks/useNodes.ts index 6813356..f90a193 100644 --- a/frontend/src/hooks/useNodes.ts +++ b/frontend/src/hooks/useNodes.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef } from 'react'; +import { useState, useCallback } from 'react'; export interface MeshNode { node_id: string; @@ -37,6 +37,7 @@ export interface AggregatedPacket { packetHash: string; packetType?: number; rxNodeId?: string; // observer โ€” for node-name fallback + observerIds: string[]; srcNodeId?: string; // sender node id (from decoded payload) summary?: string; hopCount?: number; @@ -56,14 +57,8 @@ export interface PacketArc { packetHash: string; } -const ARC_TTL = 5000; const FEED_MAX_PACKETS = 120; -interface HashRecord { - observers: string[]; - ts: number; -} - function packetInfoScore(packet: Pick): number { let score = 0; if (packet.summary) score += 4; @@ -79,18 +74,50 @@ function packetInfoScore(packet: Pick>(new Map()); const [packets, setPackets] = useState([]); - const [arcs, setArcs] = useState([]); - const [activeNodes, setActiveNodes] = useState>(new Set()); + const [arcs] = useState([]); + const [activeNodes] = useState>(new Set()); - const hashRegistry = useRef>(new Map()); - - const pruneArcs = useCallback(() => { - const cutoff = Date.now() - ARC_TTL - 500; - setArcs((prev) => prev.filter((a) => a.ts > cutoff)); - const regCutoff = Date.now() - 10_000; - for (const [k, v] of hashRegistry.current) { - if (v.ts < regCutoff) hashRegistry.current.delete(k); + const mapRecentRows = useCallback((rows: Array<{ + time: string; + packet_hash: string; + rx_node_id?: string; + observer_node_ids?: string[] | null; + src_node_id?: string; + packet_type?: number; + hop_count?: number; + payload?: Record; + advert_count?: number | null; + path_hashes?: string[] | null; + }>): AggregatedPacket[] => { + const seen = new Set(); + const mapped: AggregatedPacket[] = []; + for (const row of rows) { + if (seen.has(row.packet_hash)) continue; + seen.add(row.packet_hash); + const appData = row.payload?.['appData'] as Record | undefined; + const summary = (appData?.['name'] as string | undefined) + ?? (row.payload?.['origin'] as string | undefined); + const observerIds = Array.from(new Set([ + ...(row.observer_node_ids ?? []), + ...(row.rx_node_id ? [row.rx_node_id] : []), + ])); + mapped.push({ + id: row.packet_hash, + packetHash: row.packet_hash, + packetType: row.packet_type, + rxNodeId: row.rx_node_id, + observerIds, + srcNodeId: row.src_node_id, + summary, + hopCount: row.hop_count, + path: row.path_hashes ?? undefined, + rxCount: 1, + txCount: 0, + ts: new Date(row.time).getTime(), + advertCount: row.advert_count ?? undefined, + }); } + return mapped.slice(0, FEED_MAX_PACKETS); }, []); const handleInitialState = useCallback((data: { @@ -99,6 +126,7 @@ export function useNodes() { time: string; packet_hash: string; rx_node_id?: string; + observer_node_ids?: string[] | null; src_node_id?: string; packet_type?: number; hop_count?: number; @@ -110,34 +138,23 @@ export function useNodes() { const nodeMap = new Map(); for (const n of data.nodes) nodeMap.set(n.node_id, n); setNodes(nodeMap); + setPackets(mapRecentRows(data.packets)); + }, [mapRecentRows]); - // Pre-populate the live feed with the last N packets from DB. - // Deduplicate by packet_hash in case the same packet was heard by multiple observers. - const seen = new Set(); - const initialPackets: AggregatedPacket[] = []; - for (const row of data.packets) { - if (seen.has(row.packet_hash)) continue; - seen.add(row.packet_hash); - const appData = row.payload?.['appData'] as Record | undefined; - const summary = (appData?.['name'] as string | undefined) - ?? (row.payload?.['origin'] as string | undefined); - initialPackets.push({ - id: row.packet_hash, - packetHash: row.packet_hash, - packetType: row.packet_type, - rxNodeId: row.rx_node_id, - srcNodeId: row.src_node_id, - summary, - hopCount: row.hop_count, - path: row.path_hashes ?? undefined, - rxCount: 1, - txCount: 0, - ts: new Date(row.time).getTime(), - advertCount: row.advert_count ?? undefined, - }); - } - setPackets(initialPackets.slice(0, FEED_MAX_PACKETS)); - }, []); + const replaceRecentPackets = useCallback((rows: Array<{ + time: string; + packet_hash: string; + rx_node_id?: string; + observer_node_ids?: string[] | null; + src_node_id?: string; + packet_type?: number; + hop_count?: number; + payload?: Record; + advert_count?: number | null; + path_hashes?: string[] | null; + }>) => { + setPackets(mapRecentRows(rows)); + }, [mapRecentRows]); const handlePacket = useCallback((packet: LivePacketData) => { // โ”€โ”€ Aggregate by packetHash โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -147,9 +164,14 @@ export function useNodes() { if (idx >= 0) { // Known packet โ€” increment count, bubble to top const current = prev[idx]!; + const observerIds = packet.rxNodeId + ? [packet.rxNodeId, ...current.observerIds.filter((id) => id !== packet.rxNodeId)] + : current.observerIds; const candidate: AggregatedPacket = { ...current, packetType: packet.packetType ?? current.packetType, + rxNodeId: packet.rxNodeId ?? current.rxNodeId, + observerIds, srcNodeId: packet.srcNodeId ?? current.srcNodeId, summary: packet.summary ?? (packet.payload?.['origin'] as string | undefined) ?? current.summary, hopCount: packet.hopCount ?? current.hopCount, @@ -175,6 +197,7 @@ export function useNodes() { packetHash: packet.packetHash, packetType: packet.packetType, rxNodeId: packet.rxNodeId, + observerIds: packet.rxNodeId ? [packet.rxNodeId] : [], srcNodeId: packet.srcNodeId, summary: packet.summary ?? (packet.payload?.['origin'] as string | undefined), hopCount: packet.hopCount, @@ -187,54 +210,7 @@ export function useNodes() { return [entry, ...prev].slice(0, FEED_MAX_PACKETS); }); - // โ”€โ”€ Pulse the observer node โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if (packet.rxNodeId) { - setActiveNodes((prev) => { - const next = new Set(prev); - next.add(packet.rxNodeId!); - setTimeout(() => setActiveNodes((s) => { - const n = new Set(s); n.delete(packet.rxNodeId!); return n; - }), 1200); - return next; - }); - } - - // โ”€โ”€ Build hop-trail arcs via packetHash correlation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if (packet.rxNodeId) { - const reg = hashRegistry.current; - const existing = reg.get(packet.packetHash); - - if (existing) { - const prevObserverId = existing.observers[existing.observers.length - 1]!; - existing.observers.push(packet.rxNodeId); - existing.ts = packet.ts; - - setNodes((currentNodes) => { - const prev = currentNodes.get(prevObserverId); - const curr = currentNodes.get(packet.rxNodeId!); - if (typeof prev?.lat === 'number' && typeof prev?.lon === 'number' - && typeof curr?.lat === 'number' && typeof curr?.lon === 'number') { - const arc: PacketArc = { - id: packet.id, - from: [prev.lon, prev.lat], - to: [curr.lon, curr.lat], - hopCount: packet.hopCount ?? existing.observers.length, - ts: packet.ts, - packetHash: packet.packetHash, - }; - setArcs((a) => [...a, arc]); - setTimeout(pruneArcs, ARC_TTL + 500); - } - return currentNodes; - }); - } else { - reg.set(packet.packetHash, { - observers: [packet.rxNodeId], - ts: packet.ts, - }); - } - } - }, [pruneArcs]); + }, []); const handleNodeUpdate = useCallback((data: { nodeId: string; ts: number }) => { setNodes((prev) => { @@ -269,6 +245,7 @@ export function useNodes() { arcs, activeNodes, handleInitialState, + replaceRecentPackets, handlePacket, handleNodeUpdate, handleNodeUpsert, diff --git a/frontend/src/hooks/usePacketPathOverlay.ts b/frontend/src/hooks/usePacketPathOverlay.ts index f1b04c4..8ae06d4 100644 --- a/frontend/src/hooks/usePacketPathOverlay.ts +++ b/frontend/src/hooks/usePacketPathOverlay.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { AggregatedPacket, MeshNode } from './useNodes.js'; import { hasCoords, resolvePathWaypoints } from '../utils/pathing.js'; -import { withNetworkParam, uncachedEndpoint } from '../utils/api.js'; +import { withScopeParams, uncachedEndpoint } from '../utils/api.js'; import type { Filters } from '../components/FilterPanel/FilterPanel.js'; const PATH_TTL = 5_000; @@ -14,13 +14,13 @@ type UsePacketPathOverlayParams = { nodes: Map; filters: Filters; network?: string; + observer?: string; }; type UsePacketPathOverlayResult = { - packetPath: [number, number][] | null; - betaPacketPath: [number, number][] | null; - betaExtraPurplePaths: [number, number][][]; - betaLowConfidencePath: [number, number][] | null; + packetPaths: [number, number][][]; + betaPacketPaths: [number, number][][]; + betaLowConfidencePaths: [number, number][][]; betaLowConfidenceSegments: PathSegment[]; betaCompletionPaths: [number, number][][]; betaPathConfidence: number | null; @@ -57,15 +57,15 @@ function segmentizePath(path: [number, number][] | null): PathSegment[] { return segments; } -async function fetchServerBeta(packetHash: string, network?: string, signal?: AbortSignal): Promise { - const endpoint = withNetworkParam(`/api/path-beta/resolve?hash=${encodeURIComponent(packetHash)}`, network); +async function fetchServerBeta(packetHash: string, network?: string, observer?: string, signal?: AbortSignal): Promise { + const endpoint = withScopeParams(`/api/path-beta/resolve?hash=${encodeURIComponent(packetHash)}`, { network, observer }); const response = await fetch(uncachedEndpoint(endpoint), { cache: 'no-store', signal }); if (!response.ok) return null; return response.json() as Promise; } -function cacheKey(packetHash: string, network?: string): string { - return `${network ?? 'teesside'}|${packetHash}`; +function cacheKey(packetHash: string, network?: string, observer?: string): string { + return `${network ?? 'all'}|${observer ?? 'all'}|${packetHash}`; } export function usePacketPathOverlay({ @@ -73,11 +73,11 @@ export function usePacketPathOverlay({ nodes, filters, network, + observer, }: UsePacketPathOverlayParams): UsePacketPathOverlayResult { - const [packetPath, setPacketPath] = useState<[number, number][] | null>(null); - const [betaPacketPath, setBetaPacketPath] = useState<[number, number][] | null>(null); - const [betaExtraPurplePaths, setBetaExtraPurplePaths] = useState<[number, number][][]>([]); - const [betaLowConfidencePath, setBetaLowConfidencePath] = useState<[number, number][] | null>(null); + const [packetPaths, setPacketPaths] = useState<[number, number][][]>([]); + const [betaPacketPaths, setBetaPacketPaths] = useState<[number, number][][]>([]); + const [betaLowConfidencePaths, setBetaLowConfidencePaths] = useState<[number, number][][]>([]); const [betaLowConfidenceSegments, setBetaLowConfidenceSegments] = useState([]); const [betaCompletionPaths, setBetaCompletionPaths] = useState<[number, number][][]>([]); const [betaPathConfidence, setBetaPathConfidence] = useState(null); @@ -94,9 +94,8 @@ export function usePacketPathOverlay({ const inFlightRef = useRef>>(new Map()); const activeReqSeqRef = useRef(0); const recentPredictionsRef = useRef { - setPacketPath(null); - setBetaPacketPath(null); - setBetaExtraPurplePaths([]); - setBetaLowConfidencePath(null); + setPacketPaths([]); + setBetaPacketPaths([]); + setBetaLowConfidencePaths([]); setBetaLowConfidenceSegments([]); setBetaCompletionPaths([]); setBetaPathConfidence(null); @@ -133,13 +131,13 @@ export function usePacketPathOverlay({ setPathOpacity(0.75); }, []); - const applyServerPrediction = useCallback((packetHash: string, prediction: ServerBetaResponse | null) => { - if (!prediction || !prediction.ok) { + const applyServerPredictions = useCallback((packetHash: string, predictions: Array) => { + const validPredictions = predictions.filter((prediction): prediction is ServerBetaResponse => Boolean(prediction?.ok)); + if (validPredictions.length < 1) { const recent = recentPredictionsRef.current.get(packetHash); if (!recent || Date.now() - recent.ts > 45_000) { - setBetaPacketPath(null); - setBetaExtraPurplePaths([]); - setBetaLowConfidencePath(null); + setBetaPacketPaths([]); + setBetaLowConfidencePaths([]); setBetaLowConfidenceSegments([]); setBetaCompletionPaths([]); setBetaPathConfidence(null); @@ -147,9 +145,8 @@ export function usePacketPathOverlay({ setBetaRemainingHops(null); return; } - setBetaPacketPath(recent.purplePath); - setBetaExtraPurplePaths(recent.extraPurplePaths); - setBetaLowConfidencePath(recent.redPath); + setBetaPacketPaths(recent.purplePaths); + setBetaLowConfidencePaths(recent.redPaths); setBetaLowConfidenceSegments(recent.redSegments); setBetaCompletionPaths(recent.completionPaths); setBetaPathConfidence(recent.confidence); @@ -158,33 +155,50 @@ export function usePacketPathOverlay({ return; } - const purplePath = prediction.purplePath && prediction.purplePath.length >= 2 ? prediction.purplePath : null; - const extraPurplePaths = (prediction.extraPurplePaths ?? []).filter((path) => path.length >= 2); - const redPath = prediction.redPath && prediction.redPath.length >= 2 ? prediction.redPath : null; - const redSegments = prediction.redSegments?.length ? prediction.redSegments : segmentizePath(redPath); - const completionPaths = prediction.completionPaths ?? []; - const permutations = Number.isFinite(prediction.permutationCount) - ? prediction.permutationCount - : ((redPath ? 1 : 0) + completionPaths.length); + const purplePaths = validPredictions.flatMap((prediction) => { + const paths: [number, number][][] = []; + if (prediction.purplePath && prediction.purplePath.length >= 2) paths.push(prediction.purplePath); + for (const path of prediction.extraPurplePaths ?? []) { + if (path.length >= 2) paths.push(path); + } + return paths; + }); + const redPaths = validPredictions.flatMap((prediction) => ( + prediction.redPath && prediction.redPath.length >= 2 ? [prediction.redPath] : [] + )); + const redSegments = validPredictions.flatMap((prediction) => ( + prediction.redSegments?.length ? prediction.redSegments : segmentizePath(prediction.redPath && prediction.redPath.length >= 2 ? prediction.redPath : null) + )); + const completionPaths = validPredictions.flatMap((prediction) => prediction.completionPaths ?? []); + const permutations = validPredictions.reduce((sum, prediction) => { + const fallbackCount = (prediction.redPath ? 1 : 0) + (prediction.completionPaths?.length ?? 0); + return sum + (Number.isFinite(prediction.permutationCount) ? prediction.permutationCount : fallbackCount); + }, 0); + const bestConfidence = validPredictions.reduce((best, prediction) => { + if (prediction.confidence == null) return best; + return best == null ? prediction.confidence : Math.max(best, prediction.confidence); + }, null); + const remainingHops = validPredictions.reduce((best, prediction) => { + if (prediction.remainingHops == null) return best; + return best == null ? prediction.remainingHops : Math.max(best, prediction.remainingHops); + }, null); - setBetaPacketPath(purplePath); - setBetaExtraPurplePaths(extraPurplePaths); - setBetaLowConfidencePath(redPath); + setBetaPacketPaths(purplePaths); + setBetaLowConfidencePaths(redPaths); setBetaLowConfidenceSegments(redSegments); setBetaCompletionPaths(completionPaths); - setBetaPathConfidence(prediction.confidence); + setBetaPathConfidence(bestConfidence); setBetaPermutationCount(permutations); - setBetaRemainingHops(prediction.remainingHops); + setBetaRemainingHops(remainingHops); recentPredictionsRef.current.set(packetHash, { - purplePath, - extraPurplePaths, - redPath, + purplePaths, + redPaths, redSegments, completionPaths, - confidence: prediction.confidence, + confidence: bestConfidence, permutations, - remainingHops: prediction.remainingHops, + remainingHops, ts: Date.now(), }); }, []); @@ -204,9 +218,17 @@ export function usePacketPathOverlay({ } }, []); - const resolvePrediction = useCallback((packetHash: string, networkName?: string): Promise => { + const packetObserverIds = useCallback((packet: AggregatedPacket | undefined): string[] => { + if (!packet) return []; + return Array.from(new Set([ + ...(packet.observerIds ?? []), + ...(packet.rxNodeId ? [packet.rxNodeId] : []), + ])); + }, []); + + const resolvePrediction = useCallback((packetHash: string, networkName?: string, observerId?: string): Promise => { prunePredictionCache(); - const key = cacheKey(packetHash, networkName); + const key = cacheKey(packetHash, networkName, observerId); const cached = predictionCacheRef.current.get(key); if (cached && Date.now() - cached.ts <= PREDICTION_CACHE_TTL_MS) { return Promise.resolve(cached.prediction); @@ -215,7 +237,7 @@ export function usePacketPathOverlay({ const inflight = inFlightRef.current.get(key); if (inflight) return inflight; - const p = fetchServerBeta(packetHash, networkName) + const p = fetchServerBeta(packetHash, networkName, observerId) .then((prediction) => { predictionCacheRef.current.set(key, { prediction, ts: Date.now() }); return prediction; @@ -234,34 +256,38 @@ export function usePacketPathOverlay({ stopPathTimers(); const latest = packets[0]; - const rx = latest?.rxNodeId ? nodes.get(latest.rxNodeId) : undefined; + const observerIds = packetObserverIds(latest); - if (filters.packetPaths && latest?.rxNodeId && (latest.path?.length || latest.srcNodeId) && hasCoords(rx)) { + if (filters.packetPaths && observerIds.length > 0 && latest && (latest.path?.length || latest.srcNodeId)) { const src = latest.srcNodeId ? (nodes.get(latest.srcNodeId) ?? null) : null; const srcWithPos = hasCoords(src) ? src : null; - const waypoints = latest.path?.length - ? resolvePathWaypoints(latest.path, srcWithPos, rx, nodes) - : [[srcWithPos!.lat!, srcWithPos!.lon!], [rx.lat, rx.lon]] as [number, number][]; - setPacketPath(waypoints.length >= 2 ? waypoints : null); + const nextPaths = observerIds.flatMap((observerId) => { + const rx = nodes.get(observerId); + if (!hasCoords(rx)) return []; + const waypoints = latest.path?.length + ? resolvePathWaypoints(latest.path, srcWithPos, rx, nodes) + : (srcWithPos ? [[srcWithPos.lat, srcWithPos.lon], [rx.lat, rx.lon]] as [number, number][] : []); + return waypoints.length >= 2 ? [waypoints] : []; + }); + setPacketPaths(nextPaths); } else { - setPacketPath(null); + setPacketPaths([]); } - if (filters.betaPaths && latest?.packetHash && latest.path?.length) { + if (filters.betaPaths && latest?.packetHash && latest.path?.length && observerIds.length > 0) { const reqSeq = ++activeReqSeqRef.current; - void resolvePrediction(latest.packetHash, network) - .then((prediction) => { + void Promise.all(observerIds.map((observerId) => resolvePrediction(latest.packetHash, network, observerId))) + .then((predictions) => { if (reqSeq !== activeReqSeqRef.current) return; - applyServerPrediction(latest.packetHash, prediction); + applyServerPredictions(latest.packetHash, predictions); }) .catch(() => { if (reqSeq !== activeReqSeqRef.current) return; - applyServerPrediction(latest.packetHash, null); + applyServerPredictions(latest.packetHash, []); }); } else { - setBetaPacketPath(null); - setBetaExtraPurplePaths([]); - setBetaLowConfidencePath(null); + setBetaPacketPaths([]); + setBetaLowConfidencePaths([]); setBetaLowConfidenceSegments([]); setBetaCompletionPaths([]); setBetaPathConfidence(null); @@ -295,7 +321,7 @@ export function usePacketPathOverlay({ pathFadeRef.current = requestAnimationFrame(animate); }, PATH_TTL - 1_000); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [latestId, filters.packetPaths, filters.betaPaths, pinnedPacketId, network]); + }, [latestId, filters.packetPaths, filters.betaPaths, pinnedPacketId, network, observer, nodes, packets, packetObserverIds, resolvePrediction, stopPathTimers, clearPathState, applyServerPredictions]); const handlePacketPin = useCallback((packet: AggregatedPacket) => { if (pinnedPacketId === packet.id) { @@ -315,23 +341,23 @@ export function usePacketPathOverlay({ pinnedTimerRef.current = null; } - setPacketPath(null); + setPacketPaths([]); - if (packet.packetHash && packet.path?.length) { + const observerIds = packetObserverIds(packet); + if (packet.packetHash && packet.path?.length && observerIds.length > 0) { const reqSeq = ++activeReqSeqRef.current; - void resolvePrediction(packet.packetHash, network) - .then((prediction) => { + void Promise.all(observerIds.map((observerId) => resolvePrediction(packet.packetHash, network, observerId))) + .then((predictions) => { if (reqSeq !== activeReqSeqRef.current) return; - applyServerPrediction(packet.packetHash, prediction); + applyServerPredictions(packet.packetHash, predictions); }) .catch(() => { if (reqSeq !== activeReqSeqRef.current) return; - applyServerPrediction(packet.packetHash, null); + applyServerPredictions(packet.packetHash, []); }); } else { - setBetaPacketPath(null); - setBetaExtraPurplePaths([]); - setBetaLowConfidencePath(null); + setBetaPacketPaths([]); + setBetaLowConfidencePaths([]); setBetaLowConfidenceSegments([]); setBetaCompletionPaths([]); setBetaPathConfidence(null); @@ -359,7 +385,7 @@ export function usePacketPathOverlay({ }; pathFadeRef.current = requestAnimationFrame(animate); }, 30_000); - }, [pinnedPacketId, network, stopPathTimers, clearPathState, applyServerPrediction, resolvePrediction]); + }, [pinnedPacketId, network, observer, stopPathTimers, clearPathState, applyServerPredictions, resolvePrediction, packetObserverIds]); useEffect(() => () => { stopPathTimers(); @@ -367,10 +393,9 @@ export function usePacketPathOverlay({ }, [stopPathTimers]); return { - packetPath, - betaPacketPath, - betaExtraPurplePaths, - betaLowConfidencePath, + packetPaths, + betaPacketPaths, + betaLowConfidencePaths, betaLowConfidenceSegments, betaCompletionPaths, betaPathConfidence, diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index f52997d..cae7432 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -1,4 +1,5 @@ import { useEffect, useRef, useCallback, useState } from 'react'; +import type { ApiScope } from '../utils/api.js'; export type WSReadyState = 'connecting' | 'connected' | 'disconnected'; @@ -10,7 +11,7 @@ export interface WSMessage { type MessageHandler = (msg: WSMessage) => void; -export function useWebSocket(onMessage: MessageHandler, network?: string) { +export function useWebSocket(onMessage: MessageHandler, scope: ApiScope = {}) { const [readyState, setReadyState] = useState('connecting'); const wsRef = useRef(null); const timerRef = useRef | null>(null); @@ -29,7 +30,11 @@ export function useWebSocket(onMessage: MessageHandler, network?: string) { if (wsRef.current?.readyState === WebSocket.OPEN || wsRef.current?.readyState === WebSocket.CONNECTING) return; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const suffix = network ? `?network=${encodeURIComponent(network)}` : ''; + const params = new URLSearchParams(); + if (scope.network) params.set('network', scope.network); + if (scope.observer) params.set('observer', scope.observer); + const query = params.toString(); + const suffix = query ? `?${query}` : ''; const url = `${protocol}//${window.location.host}/ws${suffix}`; const ws = new WebSocket(url); wsRef.current = ws; @@ -62,7 +67,7 @@ export function useWebSocket(onMessage: MessageHandler, network?: string) { ws.onerror = () => { ws.close(); }; - }, [network]); + }, [scope.network, scope.observer]); useEffect(() => { shouldReconnectRef.current = true; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 3581411..3fa796f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { App } from './App.js'; import { Layout } from './pages/Layout.js'; import { HomePage } from './pages/HomePage.js'; @@ -16,6 +16,8 @@ import { UKLayout } from './pages/ukmesh/UKLayout.js'; import { UKHomePage } from './pages/ukmesh/UKHomePage.js'; import { UKInstallPage } from './pages/ukmesh/UKInstallPage.js'; import { UKMqttPage } from './pages/ukmesh/UKMqttPage.js'; +import { DevLayout } from './pages/dev/DevLayout.js'; +import { DevHomePage } from './pages/dev/DevHomePage.js'; import { getCurrentSite } from './config/site.js'; import './styles/globals.css'; @@ -33,6 +35,15 @@ ReactDOM.createRoot(root).render( {isAppDomain ? ( + ) : site.id === 'dev' ? ( + + + }> + } /> + } /> + + + ) : site.id === 'ukmesh' ? ( diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index f50db3e..7accd6d 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -95,10 +95,11 @@ export const StatsPage: React.FC = () => { const [loading, setLoading] = useState(true); const [lastUpdate, setLastUpdate] = useState(null); const site = getCurrentSite(); - const statsNetwork = site.id === 'ukmesh' ? undefined : site.network; + const statsScope = { network: site.networkFilter, observer: site.observerId }; + const refreshSeconds = 15; const load = () => { - fetch(uncachedEndpoint(chartStatsEndpoint(statsNetwork)), { cache: 'no-store' }) + fetch(uncachedEndpoint(chartStatsEndpoint(statsScope)), { cache: 'no-store' }) .then(r => r.json()) .then((d: ChartData) => { setData(d); setLoading(false); setLastUpdate(new Date()); }) .catch(() => setLoading(false)); @@ -106,9 +107,9 @@ export const StatsPage: React.FC = () => { useEffect(() => { load(); - const t = setInterval(load, 60_000); + const t = setInterval(load, refreshSeconds * 1000); return () => clearInterval(t); - }, []); + }, [site.networkFilter, site.observerId]); const fmt = (n: number) => n.toLocaleString(); @@ -119,9 +120,11 @@ export const StatsPage: React.FC = () => {

Network Stats

- {site.id === 'ukmesh' - ? 'Live analytics across all connected networks. Updates every 60 seconds.' - : `Live analytics from the ${site.displayName} network. Updates every 60 seconds.`} + {site.id === 'dev' + ? `Stats for the isolated test MQTT feed. Updates every ${refreshSeconds} seconds.` + : site.id === 'ukmesh' + ? `Live analytics across all connected networks. Updates every ${refreshSeconds} seconds.` + : `Live analytics from the ${site.displayName} network. Updates every ${refreshSeconds} seconds.`}

{lastUpdate && (

@@ -143,8 +146,8 @@ export const StatsPage: React.FC = () => { <> {/* โ”€โ”€ Summary row โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */}

- - + + { {/* โ”€โ”€ Packets over time โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */}
- + @@ -186,7 +189,7 @@ export const StatsPage: React.FC = () => { - + @@ -252,7 +255,7 @@ export const StatsPage: React.FC = () => { - +
@@ -286,7 +289,7 @@ export const StatsPage: React.FC = () => { {/* โ”€โ”€ Hop distribution + prefix collisions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */}
- + diff --git a/frontend/src/pages/dev/DevHomePage.tsx b/frontend/src/pages/dev/DevHomePage.tsx new file mode 100644 index 0000000..b4a2b19 --- /dev/null +++ b/frontend/src/pages/dev/DevHomePage.tsx @@ -0,0 +1,236 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { getCurrentSite } from '../../config/site.js'; +import { uncachedEndpoint, withScopeParams } from '../../utils/api.js'; + +type DevNode = { + node_id: string; + name?: string; + iata?: string; + last_seen: string; + is_online: boolean; + hardware_model?: string; + public_key?: string; +}; + +type DevPacket = { + time: string; + packet_hash: string; + rx_node_id?: string; + src_node_id?: string; + packet_type?: number; + hop_count?: number; + rssi?: number; + snr?: number; + payload?: Record; +}; + +const TYPE_LABELS: Record = { + 0: 'REQ', + 1: 'RSP', + 2: 'DM', + 3: 'ACK', + 4: 'ADV', + 5: 'GRP', + 6: 'DAT', + 7: 'ANON', + 8: 'PATH', + 9: 'TRC', +}; + +function timeAgo(ts?: string | null): string { + if (!ts) return 'never'; + const ageMs = Math.max(0, Date.now() - Date.parse(ts)); + const sec = Math.floor(ageMs / 1000); + if (sec < 60) return `${sec}s ago`; + if (sec < 3600) return `${Math.floor(sec / 60)}m ago`; + if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`; + return `${Math.floor(sec / 86400)}d ago`; +} + +function shortNode(id?: string): string { + if (!id) return 'unknown'; + return `${id.slice(0, 8)}โ€ฆ${id.slice(-6)}`; +} + +function packetSummary(packet: DevPacket): string { + const payload = packet.payload ?? {}; + const appData = payload['appData'] as Record | undefined; + const candidate = [ + typeof appData?.['name'] === 'string' ? appData['name'] : undefined, + typeof payload['origin'] === 'string' ? payload['origin'] : undefined, + typeof appData?.['text'] === 'string' ? appData['text'] : undefined, + typeof payload['summary'] === 'string' ? payload['summary'] : undefined, + ].find(Boolean); + return String(candidate ?? 'No decoded summary'); +} + +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 [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + const load = async () => { + try { + const [nodesRes, packetsRes] = await Promise.all([ + fetch(uncachedEndpoint(withScopeParams('/api/nodes', scope)), { cache: 'no-store' }), + fetch(uncachedEndpoint(withScopeParams('/api/packets/recent?limit=40', scope)), { cache: 'no-store' }), + ]); + + if (!nodesRes.ok || !packetsRes.ok) { + throw new Error(`HTTP ${nodesRes.status}/${packetsRes.status}`); + } + + const [nodesJson, packetsJson] = await Promise.all([ + nodesRes.json() as Promise, + packetsRes.json() as Promise, + ]); + + if (cancelled) return; + setNodes(nodesJson); + setPackets(packetsJson); + setError(null); + } catch (err) { + if (cancelled) return; + setError((err as Error).message); + } + }; + + void load(); + const timer = setInterval(() => void load(), 3000); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [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 latestObserver = latestPacket?.rx_node_id ? nodeMap.get(latestPacket.rx_node_id) : undefined; + const latestSeenByObserver = useMemo(() => { + return observerIds.map((observerId) => { + const observerNode = nodeMap.get(observerId); + const lastPacket = packets.find((packet) => packet.rx_node_id === observerId); + return { + observerId, + observerNode, + lastPacket, + }; + }).slice(0, 5); + }, [nodeMap, observerIds, packets]); + + const recentPackets = useMemo(() => packets.slice(0, 5), [packets]); + + return ( +
+
+

{site.displayName}

+

Isolated MQTT test feed status.

+ + Last seen: {latestPacket ? timeAgo(latestPacket.time) : 'never'} + +
+ + {error &&

Diagnostics fetch error: {error}

} + +
+
+

Status

+
+
Feed{latestPacket ? 'Receiving packets' : 'Waiting for packets'}
+
Observers seen{observerIds.length.toLocaleString()}
+
Last packet{latestPacket ? timeAgo(latestPacket.time) : 'never'}
+
Isolationnetwork=test
+
+
+ +
+

Latest packet

+
+
Observer{latestObserver?.name ?? shortNode(latestPacket?.rx_node_id)}
+
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 ?? 'โ€”'}
+
+

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

+
+
+ +
+

Observers

+
+ + + + + + + + + + + {latestSeenByObserver.length > 0 ? latestSeenByObserver.map(({ observerId, observerNode, lastPacket }) => ( + + + + + + + )) : ( + + + + )} + +
ObserverNode IDIATALast seen
{observerNode?.name ?? 'Unnamed observer'}{shortNode(observerId)}{observerNode?.iata ?? 'โ€”'}{lastPacket ? timeAgo(lastPacket.time) : timeAgo(observerNode?.last_seen)}
No observer traffic in the test feed yet.
+
+
+ +
+

Live packets

+
+ + + + + + + + + + + + + {recentPackets.length > 0 ? recentPackets.map((packet) => ( + + + + + + + + + )) : ( + + + + )} + +
TimeTypeObserverSignalHashSummary
{new Date(packet.time).toLocaleTimeString()}{packet.packet_type !== undefined ? (TYPE_LABELS[packet.packet_type] ?? `T${packet.packet_type}`) : 'โ€”'}{packet.rx_node_id ? (nodeMap.get(packet.rx_node_id)?.name ?? shortNode(packet.rx_node_id)) : 'unknown'}{packet.rssi !== undefined || packet.snr !== undefined ? `${packet.rssi ?? 'โ€”'} / ${packet.snr ?? 'โ€”'}` : 'โ€”'}{packet.packet_hash}{packetSummary(packet)}
No test packets have arrived yet.
+
+
+
+ ); +}; diff --git a/frontend/src/pages/dev/DevLayout.tsx b/frontend/src/pages/dev/DevLayout.tsx new file mode 100644 index 0000000..b93b5e7 --- /dev/null +++ b/frontend/src/pages/dev/DevLayout.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { Outlet } from 'react-router-dom'; + +export const DevLayout: React.FC = () => ( +
+ +
+); diff --git a/frontend/src/pages/dev/DevMqttPage.tsx b/frontend/src/pages/dev/DevMqttPage.tsx new file mode 100644 index 0000000..ec6eba2 --- /dev/null +++ b/frontend/src/pages/dev/DevMqttPage.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +export const DevMqttPage: React.FC = () => ( + <> +
+
+

Development MQTT

+

+ This site expects the same packet payload contract as production, but on the isolated test MQTT + namespace. +

+
+
+ +
+
+

Use the test topic contract

+

+ Publish status and packets to meshcore-test/{'{'}IATA{'}'}/{'{'}PUBLIC_KEY{'}'}/status and + meshcore-test/{'{'}IATA{'}'}/{'{'}PUBLIC_KEY{'}'}/packets. Keep the same JSON payload + shape as production so firmware changes can be validated directly without a second parser or payload + schema. +

+
+ +
+

Recommended checks

+

Use this site to verify:

+
    +
  • status arrives and updates reliably
  • +
  • packet `raw` hex is complete and decodes cleanly
  • +
  • packet hash, type, RSSI, and SNR are populated
  • +
  • multibyte path hashes are preserved in the raw packet
  • +
  • test traffic stays isolated from the public dashboards
  • +
+
+
+ +); diff --git a/frontend/src/pages/shared/SiteLayout.tsx b/frontend/src/pages/shared/SiteLayout.tsx index 590ca68..1c3f8cd 100644 --- a/frontend/src/pages/shared/SiteLayout.tsx +++ b/frontend/src/pages/shared/SiteLayout.tsx @@ -5,6 +5,12 @@ type SiteLayoutProps = { brandName: string; footerName: string; appUrl: string; + showLiveMap?: boolean; + showAbout?: boolean; + showInstall?: boolean; + showMqtt?: boolean; + showHealth?: boolean; + showOpenSource?: boolean; showPackets: boolean; showStats: boolean; }; @@ -30,6 +36,12 @@ export const SiteLayout: React.FC = ({ brandName, footerName, appUrl, + showLiveMap = true, + showAbout = true, + showInstall = true, + showMqtt = true, + showHealth = true, + showOpenSource = true, showPackets, showStats, }) => { @@ -39,12 +51,12 @@ export const SiteLayout: React.FC = ({ const navItems: NavItem[] = [ { to: '/', label: 'Home', enabled: true }, - { to: '/about', label: 'What is MeshCore', enabled: true }, - { to: '/install', label: 'Install', enabled: true }, - { to: '/mqtt', label: 'MQTT', enabled: true }, - { to: '/health', label: 'Health', enabled: true }, + { to: '/about', label: 'What is MeshCore', enabled: showAbout }, + { to: '/install', label: 'Install', enabled: showInstall }, + { to: '/mqtt', label: 'MQTT', enabled: showMqtt }, + { to: '/health', label: 'Health', enabled: showHealth }, { to: '/packets', label: 'Packets', enabled: showPackets }, - { to: '/open-source', label: 'Open Source', enabled: true }, + { to: '/open-source', label: 'Open Source', enabled: showOpenSource }, { to: '/stats', label: 'Stats', enabled: showStats }, ]; @@ -106,7 +118,7 @@ export const SiteLayout: React.FC = ({ {item.label} ))} - Live Map + {showLiveMap && Live Map} handleNavClick('/login')} @@ -135,8 +147,12 @@ export const SiteLayout: React.FC = ({ Discord ยท Open Source - ยท - Live Map + {showLiveMap && ( + <> + ยท + Live Map + + )}
); diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 6fac6f8..fc4412f 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -627,8 +627,8 @@ html, body, #root { bottom: 16px; left: 16px; z-index: 800; - width: 340px; - height: 220px; + width: 420px; + height: 236px; overflow: hidden; overflow-x: hidden; pointer-events: auto; @@ -641,16 +641,16 @@ html, body, #root { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius); - padding: 6px 10px; + padding: 7px 11px; display: flex; align-items: center; - gap: 8px; + gap: 9px; font-family: var(--font-mono); font-size: 11px; line-height: 1; - animation: slide-in 0.15s ease; - transition: opacity 0.5s ease; + transition: opacity 0.24s ease, background 0.15s ease, border-color 0.15s ease; margin-bottom: 3px; + will-change: transform, opacity; } .packet-item--fading { @@ -698,14 +698,22 @@ html, body, #root { to { transform: translateY(0); } } -.packet-feed--tick .packet-item { - animation: packet-shift-up 180ms ease; +.packet-item--new { + animation: slide-in 0.18s ease; } .packet-item__type { font-size: 11px; color: var(--accent); - min-width: 36px; + min-width: 34px; + flex-shrink: 0; +} + +.packet-item__iata { + font-size: 10px; + color: var(--text-primary); + min-width: 30px; + flex-shrink: 0; } .packet-item__advert-badge { @@ -719,6 +727,7 @@ html, body, #root { color: var(--text-secondary); font-size: 11px; flex: 1; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -1557,6 +1566,247 @@ html, body, #root { flex-wrap: wrap; } +.dev-monitor__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; + margin-top: 8px; +} + +.dev-monitor__card { + padding: 20px 22px; +} + +.dev-monitor__card--wide { + margin-top: 18px; +} + +.dev-monitor__meta { + display: grid; + gap: 10px; +} + +.dev-monitor__row { + display: flex; + justify-content: space-between; + gap: 16px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-soft); +} + +.dev-monitor__row:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.dev-monitor__row span { + color: var(--text-secondary); +} + +.dev-monitor__row strong { + text-align: right; + color: var(--text-primary); +} + +.dev-monitor__summary { + margin-top: 14px; + color: var(--text-secondary); +} + +.dev-monitor__chips { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.dev-monitor__chip { + display: inline-flex; + align-items: center; + padding: 8px 12px; + border-radius: 999px; + border: 1px solid var(--border-soft); + background: var(--bg-panel-alt); + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 12px; +} + +.dev-monitor__section-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} + +.dev-monitor__table-wrap { + overflow-x: auto; +} + +.dev-monitor__table { + width: 100%; + min-width: 760px; + border-collapse: collapse; +} + +.dev-monitor__table th, +.dev-monitor__table td { + text-align: left; + padding: 10px 12px; + border-top: 1px solid var(--border-soft); + vertical-align: top; + font-size: 14px; +} + +.dev-monitor__table th { + color: var(--text-secondary); + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.dev-monitor__mono { + font-family: var(--font-mono); + font-size: 12px; +} + +.dev-monitor__empty, +.dev-monitor__empty-row { + color: var(--text-secondary); +} + +.dev-status-shell { + min-height: 100vh; + padding: 24px; + background: #081019; +} + +.dev-status-page { + max-width: 1200px; + margin: 0 auto; +} + +.dev-status-page__header { + position: relative; + margin-bottom: 20px; +} + +.dev-status-page__last-seen { + position: absolute; + top: 0; + right: 0; + font-size: 13px; + color: var(--text-secondary); +} + +.dev-status-page__header h1 { + margin: 0 0 6px; + font-size: 32px; + color: var(--text-primary); +} + +.dev-status-page__header p { + margin: 0; + color: var(--text-secondary); +} + +.dev-status-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 16px; +} + +.dev-status-card { + margin-bottom: 16px; + padding: 18px 20px; + border: 1px solid var(--border); + border-radius: 10px; + background: #0b121c; +} + +.dev-status-card--fixed { + height: 320px; + display: flex; + flex-direction: column; +} + +.dev-status-card h2 { + margin: 0 0 14px; + font-size: 16px; + color: var(--text-primary); +} + +.dev-status-list { + display: grid; + gap: 10px; +} + +.dev-status-list > div { + display: flex; + justify-content: space-between; + gap: 16px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-soft); +} + +.dev-status-list > div:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.dev-status-list span { + color: var(--text-secondary); +} + +.dev-status-list strong { + color: var(--text-primary); + text-align: right; +} + +.dev-status-note { + margin: 14px 0 0; + color: var(--text-secondary); +} + +.dev-status-table-wrap { + overflow-x: auto; + overflow-y: auto; + flex: 1; +} + +.dev-status-table { + width: 100%; + min-width: 720px; + border-collapse: collapse; +} + +.dev-status-table th, +.dev-status-table td { + padding: 10px 12px; + border-top: 1px solid var(--border-soft); + text-align: left; + vertical-align: top; + font-size: 14px; +} + +.dev-status-table th { + color: var(--text-secondary); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.dev-status-mono { + font-family: var(--font-mono); + font-size: 12px; +} + +.dev-status-empty { + color: var(--text-secondary); +} + /* โ”€โ”€ Page hero (inner pages) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ .site-page-hero { padding: 32px 0 20px; @@ -2102,7 +2352,7 @@ html, body, #root { .map-area .node-search { display: none; } /* โ”€โ”€ Packet feed: full width at bottom, max 4 items โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ - .packet-feed { width: calc(100% - 32px); height: 140px; } + .packet-feed { width: calc(100% - 32px); height: 156px; } /* โ”€โ”€ Website nav: keep bar fixed height, dropdown pops below it โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ .site-nav { @@ -2152,6 +2402,15 @@ html, body, #root { .site-section { padding: 24px 0; } .site-page-hero { padding: 28px 0 18px; } .site-home__join { align-items: flex-start; } + .dev-monitor__grid { grid-template-columns: 1fr; } + .dev-monitor__row { flex-direction: column; align-items: flex-start; } + .dev-monitor__row strong { text-align: left; } + .dev-monitor__section-head { flex-direction: column; align-items: flex-start; } + .dev-status-shell { padding: 16px; } + .dev-status-grid { grid-template-columns: 1fr; } + .dev-status-list > div { flex-direction: column; align-items: flex-start; } + .dev-status-list strong { text-align: left; } + .dev-status-card--fixed { height: auto; } .site-prose { padding: 24px 0 56px; } .site-prose--wide { max-width: 100%; } .prose-section--muted { padding: 20px; } diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts index 62fb1b9..5bec47c 100644 --- a/frontend/src/utils/api.ts +++ b/frontend/src/utils/api.ts @@ -1,15 +1,28 @@ -export function withNetworkParam(path: string, network?: string): string { - if (!network) return path; +export type ApiScope = { + network?: string; + observer?: string; +}; + +export function withScopeParams(path: string, scope: ApiScope = {}): string { + const params = new URLSearchParams(); + if (scope.network) params.set('network', scope.network); + if (scope.observer) params.set('observer', scope.observer); + const query = params.toString(); + if (!query) return path; const sep = path.includes('?') ? '&' : '?'; - return `${path}${sep}network=${encodeURIComponent(network)}`; + return `${path}${sep}${query}`; } -export function statsEndpoint(network?: string): string { - return withNetworkParam('/api/stats', network); +export function withNetworkParam(path: string, network?: string): string { + return withScopeParams(path, { network }); } -export function chartStatsEndpoint(network?: string): string { - return withNetworkParam('/api/stats/charts', network); +export function statsEndpoint(scope: ApiScope = {}): string { + return withScopeParams('/api/stats', scope); +} + +export function chartStatsEndpoint(scope: ApiScope = {}): string { + return withScopeParams('/api/stats/charts', scope); } export function uncachedEndpoint(url: string): string { diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 1331e90..8197c7f 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -2,8 +2,13 @@ interface ImportMetaEnv { readonly VITE_APP_HOSTNAME: string; - readonly VITE_SITE: string; // 'teesside' | 'ukmesh' - readonly VITE_NETWORK: string; // 'teesside' | 'ukmesh' + readonly VITE_SITE: string; // 'teesside' | 'ukmesh' | 'dev' + readonly VITE_NETWORK: string; // 'teesside' | 'ukmesh' | 'test' + readonly VITE_OBSERVER_ID: string; + readonly VITE_SITE_DISPLAY_NAME: string; + readonly VITE_SITE_FOOTER_NAME: string; + readonly VITE_SITE_APP_URL: string; + readonly VITE_SITE_HOME_URL: string; } interface ImportMeta { diff --git a/mosquitto/acl b/mosquitto/acl index c683e57..2bc85f9 100644 --- a/mosquitto/acl +++ b/mosquitto/acl @@ -2,6 +2,7 @@ user backend topic readwrite meshcore/# topic readwrite ukmesh/# +topic readwrite meshcore-test/# # teesside node observers โ€” publish to meshcore/{IATA}/{PUBKEY}/packets user node1 @@ -10,3 +11,9 @@ topic write meshcore/# # ukmesh node observers โ€” publish to ukmesh/{IATA}/{PUBKEY}/packets user node-ukmesh topic write ukmesh/# + +user test +topic write meshcore-test/# + +user mrlm +topic write meshcore/# diff --git a/mosquitto/mosquitto.conf b/mosquitto/mosquitto.conf index f45d9be..525edbd 100644 --- a/mosquitto/mosquitto.conf +++ b/mosquitto/mosquitto.conf @@ -5,14 +5,17 @@ persistence true persistence_location /mosquitto/data/ log_dest stdout log_type all +log_type websockets +connection_messages true +log_timestamp true # WebSocket listener only (no plain TCP MQTT) listener 9001 protocol websockets -# Allow anonymous connections for Phase 1 -# Phase 2 will add auth via ACL + password file -allow_anonymous true +allow_anonymous false +password_file /mosquitto/config/passwd +acl_file /mosquitto/config/acl # Connection limits max_connections 500