Add isolated test feed and fix live public stats

This commit is contained in:
Ben committed 2026-03-07 21:27:59 +00:00
1 parent cae0580609
commit 41c01bb70b
35 files changed
+1506 -440

No files matched your search

+12
View File
@@ -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
+12
View File
@@ -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
+93 -31
View File
@@ -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<number, string> = {
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
+111 -26
View File
@@ -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<T extends pg.QueryResultRow = pg.QueryResultRow>(
return pool.query<T>(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<void> {
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<ViableLinkRow[]> {
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<ViableLinkRow[]> {
const scope = buildScopePlaceholders(2, network, observer);
const params: unknown[] = [MIN_LINK_OBSERVATIONS, ...scope.params];
const res = await pool.query<ViableLinkRow>(
`SELECT
@@ -211,7 +295,8 @@ export async function getViableLinks(network?: string): Promise<ViableLinkRow[]>
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;
+6 -3
View File
@@ -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'`,
),
]);
+49
View File
@@ -0,0 +1,49 @@
import type { IncomingHttpHeaders } from 'node:http';
type NetworkScope = 'teesside' | 'ukmesh' | 'test' | 'all';
type ForcedScope = Exclude<NetworkScope, 'ukmesh'>;
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, 'all'>,
): NetworkScope | undefined {
const forced = inferForcedNetwork(headers);
if (forced) return forced;
const normalized = normalizeNetworkValue(requested);
return normalized ?? fallback;
}
+1 -1
View File
@@ -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)
+60 -61
View File
@@ -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<string, unknown>) => 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<string, unknown>) {
for (const cb of upsertSubscribers) cb(node);
@@ -34,18 +34,29 @@ function emitNodeUpsert(node: Record<string, unknown>) {
* 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<void> {
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<void> {
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<string, unknown> | undefined;
let decodedHash: string | undefined;
let decodedHops: number | undefined;
@@ -297,49 +307,48 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
summary = buildSummary(decoded.payloadType, decodedInner, rawHex);
if (decoded.payloadType === 4) {
// Advert: upsert the advertising node
const inner = decodedInner as unknown as Record<string, unknown> | undefined;
const appData = inner?.['appData'] as Record<string, unknown> | undefined;
const loc = appData?.['location'] as Record<string, number> | 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<string, unknown> | undefined;
} else if (decoded.payloadType === 7) {
// AnonRequest: sender public key is exposed in clear
const inner = decodedInner as unknown as Record<string, unknown> | undefined;
srcNodeId = inner?.['senderPublicKey'] as string | undefined;
}
@@ -349,10 +358,6 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
}
}
// 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<void> {
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<void> {
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<void> {
rxNodeId: observerKey,
srcNodeId,
topic,
network,
packetType,
hopCount: decodedHops,
direction,
@@ -442,10 +445,6 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
}
}
/**
* 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<void> {
+3 -2
View File
@@ -1110,15 +1110,16 @@ export type BetaResolvedPayload = {
};
};
export async function resolveBetaPathForPacketHash(packetHash: string, network: string): Promise<BetaResolvedPayload | null> {
export async function resolveBetaPathForPacketHash(packetHash: string, network: string, observer?: string): Promise<BetaResolvedPayload | null> {
const packetResult = await query<PathPacket>(
`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];
+1
View File
@@ -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
+128 -8
View File
@@ -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<string, { ts: number; data: Awaited<ReturnType<typeof getViableLinks>> }>();
async function getCachedViableLinks(network?: string) {
const key = network ?? 'all';
type ClientScope = {
network?: string;
observer?: string;
nodeIds: Set<string>;
};
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<LivePacket>, 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<LivePacket>, 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<LivePacket>;
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<WebSocket, ClientScope>();
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=<pubkey>)
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<string>(),
};
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));
}
+42 -1
View File
@@ -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:
+53 -13
View File
@@ -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<string, unknown>;
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 (
<div className="app-shell">
@@ -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}
+14 -6
View File
@@ -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<LiveStatsSectionProps> = ({ network }) => {
export const LiveStatsSection: React.FC<LiveStatsSectionProps> = ({ network, observer }) => {
const [stats, setStats] = useState<SiteStats>(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<LiveStatsSectionProps> = ({ network }) =
};
loadStats();
const interval = setInterval(loadStats, 30_000);
const interval = setInterval(loadStats, refreshSeconds * 1000);
return () => clearInterval(interval);
}, [network]);
}, [network, observer]);
return (
<section className="site-stats-section">
<div className="site-content">
<div className="site-section__head">
<h2>Live network stats</h2>
<p>Updates every 30 seconds from the shared packet feed.</p>
<p>
{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.`}
</p>
</div>
<div className="site-stats-grid">
<StatCard value={stats.packetsDay} label="Packets in the last 24 hours" />
<StatCard value={stats.packetsDay} label="Observed packets in the last 24 hours" />
<StatCard value={stats.totalNodes} label="Nodes ever heard on the network" />
<div className="site-stat">
<span className={`site-stat__value${hopFlash ? ' tick-flash' : ''}`}>
+31 -53
View File
@@ -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<string, LinkMetrics>;
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<MapViewProps> = ({
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<LeafletMap | null>(null);
const [focusedPrefix, setFocusedPrefix] = useState<string | null>(null);
@@ -168,18 +167,14 @@ export const MapView: React.FC<MapViewProps> = ({
return () => mq.removeEventListener('change', update);
}, []);
// Refs to Leaflet Polyline instances for direct SVG attribute animation
const regularPathRef = useRef<LeafletPolyline | null>(null);
const betaLowPathRef = useRef<LeafletPolyline | null>(null);
const betaPathRef = useRef<LeafletPolyline | null>(null);
const aniFrameRef = useRef<number | null>(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<MapViewProps> = ({
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<SVGPathElement>(
'.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<MapViewProps> = ({
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<MapViewProps> = ({
})}
{/* Live packet path — marching dashes from source → observer */}
{packetPath && (
{packetPaths.map((path, idx) => (
<Polyline
ref={regularPathRef}
positions={packetPath}
key={`packet-path-${idx}`}
positions={path}
pathOptions={{
color: '#00c4ff',
weight: 2,
dashArray: '6 9',
opacity: pathOpacity,
className: 'packet-path-overlay',
}}
/>
)}
))}
{/* Beta path — uncertain (red) drawn first so confident (purple) always renders on top */}
{showBetaPaths && betaLowPath && (
{showBetaPaths && betaLowPaths.map((path, idx) => (
<Polyline
ref={betaLowPathRef}
positions={betaLowPath}
key={`beta-low-${idx}`}
positions={path}
pathOptions={{
color: '#ef4444',
weight: 2.6,
dashArray: '6 9',
opacity: Math.min(0.9, pathOpacity),
className: 'beta-red-path-overlay',
}}
interactive={false}
/>
)}
))}
{/* 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) => (
<Polyline
ref={betaPathRef}
positions={betaPath}
pathOptions={{
color: '#a855f7',
weight: 2.8,
dashArray: '6 9',
opacity: pathOpacity,
}}
/>
)}
{showBetaPaths && betaExtraPurplePaths.map((path, idx) => (
<Polyline
key={`beta-extra-purple-${idx}`}
key={`beta-purple-${idx}`}
positions={path}
pathOptions={{
color: '#a855f7',
weight: 2.8,
dashArray: '6 9',
opacity: pathOpacity,
className: 'beta-purple-path-overlay',
}}
interactive={false}
/>
))}
+9 -5
View File
@@ -28,24 +28,25 @@ export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes, onPacke
() => packets.slice(0, VISIBLE_ROWS).reverse(),
[packets],
);
const [tickClass, setTickClass] = useState('');
const [newestVisibleId, setNewestVisibleId] = useState<string | null>(null);
const latestIdRef = useRef<string | null>(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 (
<div className={`packet-feed${tickClass}`}>
<div className="packet-feed">
{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<Props> = React.memo(({ packets, nodes, onPacke
return (
<div
key={p.id}
className={`packet-item packet-item--clickable${isPinned ? ' packet-item--pinned' : ''}`}
className={`packet-item packet-item--clickable${isPinned ? ' packet-item--pinned' : ''}${newestVisibleId === p.id ? ' packet-item--new' : ''}`}
onClick={() => onPacketClick?.(p)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && onPacketClick?.(p)}
>
{observerIata && (
<span className="packet-item__iata">{observerIata}</span>
)}
<span className="packet-item__type">{typeLabel}</span>
{advertBadge && (
<span className="packet-item__advert-badge">{advertBadge}</span>
@@ -72,6 +72,6 @@ export const StatsPanel: React.FC<StatsPanelProps> = ({
<div className="topbar__divider" />
<AnimatedStat label="Stale" value={staleNodes} variant="danger" />
<div className="topbar__divider" />
<AnimatedStat label="Packets / 24h" value={packetsDay} variant="amber" />
<AnimatedStat label="Observed / 24h" value={packetsDay} variant="amber" />
</div>
);
+28 -3
View File
@@ -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<SiteId, SiteConfig> = {
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<SiteId, SiteConfig> = {
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;
}
@@ -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,
]);
}
+4 -3
View File
@@ -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<NodeCoverage[]>([]);
// 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'] }) => {
+19 -5
View File
@@ -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<DashboardStats>(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;
}
+69 -92
View File
@@ -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<AggregatedPacket, 'packetType' | 'srcNodeId' | 'summary' | 'hopCount' | 'path' | 'advertCount'>): number {
let score = 0;
if (packet.summary) score += 4;
@@ -79,18 +74,50 @@ function packetInfoScore(packet: Pick<AggregatedPacket, 'packetType' | 'srcNodeI
export function useNodes() {
const [nodes, setNodes] = useState<Map<string, MeshNode>>(new Map());
const [packets, setPackets] = useState<AggregatedPacket[]>([]);
const [arcs, setArcs] = useState<PacketArc[]>([]);
const [activeNodes, setActiveNodes] = useState<Set<string>>(new Set());
const [arcs] = useState<PacketArc[]>([]);
const [activeNodes] = useState<Set<string>>(new Set());
const hashRegistry = useRef<Map<string, HashRecord>>(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<string, unknown>;
advert_count?: number | null;
path_hashes?: string[] | null;
}>): AggregatedPacket[] => {
const seen = new Set<string>();
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<string, unknown> | 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<string, MeshNode>();
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<string>();
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<string, unknown> | 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<string, unknown>;
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,
+104 -79
View File
@@ -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<string, MeshNode>;
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<ServerBetaResponse | null> {
const endpoint = withNetworkParam(`/api/path-beta/resolve?hash=${encodeURIComponent(packetHash)}`, network);
async function fetchServerBeta(packetHash: string, network?: string, observer?: string, signal?: AbortSignal): Promise<ServerBetaResponse | null> {
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<ServerBetaResponse>;
}
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<PathSegment[]>([]);
const [betaCompletionPaths, setBetaCompletionPaths] = useState<[number, number][][]>([]);
const [betaPathConfidence, setBetaPathConfidence] = useState<number | null>(null);
@@ -94,9 +94,8 @@ export function usePacketPathOverlay({
const inFlightRef = useRef<Map<string, Promise<ServerBetaResponse | null>>>(new Map());
const activeReqSeqRef = useRef(0);
const recentPredictionsRef = useRef<Map<string, {
purplePath: [number, number][] | null;
extraPurplePaths: [number, number][][];
redPath: [number, number][] | null;
purplePaths: [number, number][][];
redPaths: [number, number][][];
redSegments: PathSegment[];
completionPaths: [number, number][][];
confidence: number | null;
@@ -121,10 +120,9 @@ export function usePacketPathOverlay({
}, []);
const clearPathState = useCallback(() => {
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<ServerBetaResponse | null>) => {
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<number | null>((best, prediction) => {
if (prediction.confidence == null) return best;
return best == null ? prediction.confidence : Math.max(best, prediction.confidence);
}, null);
const remainingHops = validPredictions.reduce<number | null>((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<ServerBetaResponse | null> => {
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<ServerBetaResponse | null> => {
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,
+8 -3
View File
@@ -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<WSReadyState>('connecting');
const wsRef = useRef<WebSocket | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | 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;
+12 -1
View File
@@ -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(
<React.StrictMode>
{isAppDomain ? (
<App />
) : site.id === 'dev' ? (
<BrowserRouter>
<Routes>
<Route element={<DevLayout />}>
<Route index element={<DevHomePage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</BrowserRouter>
) : site.id === 'ukmesh' ? (
<BrowserRouter>
<Routes>
+16 -13
View File
@@ -95,10 +95,11 @@ export const StatsPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [lastUpdate, setLastUpdate] = useState<Date | null>(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 = () => {
<div className="site-content">
<h1 className="site-page-hero__title">Network Stats</h1>
<p className="site-page-hero__sub">
{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.`}
</p>
{lastUpdate && (
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>
@@ -143,8 +146,8 @@ export const StatsPage: React.FC = () => {
<>
{/* ── Summary row ─────────────────────────────────────────────── */}
<div className="stats-page__summary">
<StatCard label="Packets (24h)" value={fmt(data.summary.totalPackets24h)} />
<StatCard label="Packets (7 days)" value={fmt(data.summary.totalPackets7d)} />
<StatCard label="Observed packets (24h)" value={fmt(data.summary.totalPackets24h)} />
<StatCard label="Observed packets (7 days)" value={fmt(data.summary.totalPackets7d)} />
<StatCard label="Radios heard (24h)" value={fmt(data.summary.uniqueRadios24h)} color={C_GREEN} />
<StatCard
label="Peak hour"
@@ -168,7 +171,7 @@ export const StatsPage: React.FC = () => {
{/* ── Packets over time ────────────────────────────────────────── */}
<div className="stats-page__row">
<ChartCard title="Packets per hour" sub="last 24 hours">
<ChartCard title="Observed packets per hour" sub="last 24 hours">
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={data.packetsPerHour} margin={{ top: 8, right: 8, left: -20, bottom: 0 }}>
<defs>
@@ -186,7 +189,7 @@ export const StatsPage: React.FC = () => {
</ResponsiveContainer>
</ChartCard>
<ChartCard title="Packets per day" sub="last 7 days">
<ChartCard title="Observed packets per day" sub="last 7 days">
<ResponsiveContainer width="100%" height={220}>
<BarChart data={data.packetsPerDay} margin={{ top: 8, right: 8, left: -20, bottom: 0 }}>
<CartesianGrid {...gridProps} />
@@ -252,7 +255,7 @@ export const StatsPage: React.FC = () => {
</ResponsiveContainer>
</ChartCard>
<ChartCard title="Packet types" sub="last 24 hours">
<ChartCard title="Packet types" sub="last 24 hours · all observer hits">
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<ResponsiveContainer width="50%" height={220}>
<PieChart>
@@ -286,7 +289,7 @@ export const StatsPage: React.FC = () => {
{/* ── Hop distribution + prefix collisions ─────────────────────── */}
<div className="stats-page__row" style={{ marginBottom: 64 }}>
<ChartCard title="Hop count distribution" sub="last 7 days">
<ChartCard title="Hop count distribution" sub="last 7 days · all observer hits">
<ResponsiveContainer width="100%" height={220}>
<BarChart data={data.hopDistribution} margin={{ top: 8, right: 8, left: -20, bottom: 0 }}>
<CartesianGrid {...gridProps} />
+236
View File
@@ -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<string, unknown>;
};
const TYPE_LABELS: Record<number, string> = {
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<string, unknown> | 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<DevNode[]>([]);
const [packets, setPackets] = useState<DevPacket[]>([]);
const [error, setError] = useState<string | null>(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<DevNode[]>,
packetsRes.json() as Promise<DevPacket[]>,
]);
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<string>();
for (const packet of packets) {
if (packet.rx_node_id) ids.add(packet.rx_node_id);
}
return [...ids];
}, [packets]);
const latestPacket = packets[0];
const 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 (
<section className="dev-status-page">
<div className="dev-status-page__header">
<h1>{site.displayName}</h1>
<p>Isolated MQTT test feed status.</p>
<span className="dev-status-page__last-seen">
Last seen: {latestPacket ? timeAgo(latestPacket.time) : 'never'}
</span>
</div>
{error && <p className="prose-note">Diagnostics fetch error: {error}</p>}
<div className="dev-status-grid">
<section className="dev-status-card">
<h2>Status</h2>
<div className="dev-status-list">
<div><span>Feed</span><strong>{latestPacket ? 'Receiving packets' : 'Waiting for packets'}</strong></div>
<div><span>Observers seen</span><strong>{observerIds.length.toLocaleString()}</strong></div>
<div><span>Last packet</span><strong>{latestPacket ? timeAgo(latestPacket.time) : 'never'}</strong></div>
<div><span>Isolation</span><strong>network=test</strong></div>
</div>
</section>
<section className="dev-status-card">
<h2>Latest packet</h2>
<div className="dev-status-list">
<div><span>Observer</span><strong>{latestObserver?.name ?? shortNode(latestPacket?.rx_node_id)}</strong></div>
<div><span>Type</span><strong>{latestPacket?.packet_type !== undefined ? (TYPE_LABELS[latestPacket.packet_type] ?? `T${latestPacket.packet_type}`) : '—'}</strong></div>
<div><span>Signal</span><strong>{latestPacket?.rssi !== undefined || latestPacket?.snr !== undefined ? `${latestPacket.rssi ?? '—'} / ${latestPacket.snr ?? '—'}` : '—'}</strong></div>
<div><span>Hash</span><strong className="dev-status-mono">{latestPacket?.packet_hash ?? '—'}</strong></div>
</div>
<p className="dev-status-note">{latestPacket ? packetSummary(latestPacket) : 'No test packets have arrived yet.'}</p>
</section>
</div>
<section className="dev-status-card dev-status-card--wide dev-status-card--fixed">
<h2>Observers</h2>
<div className="dev-status-table-wrap">
<table className="dev-status-table">
<thead>
<tr>
<th>Observer</th>
<th>Node ID</th>
<th>IATA</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
{latestSeenByObserver.length > 0 ? latestSeenByObserver.map(({ observerId, observerNode, lastPacket }) => (
<tr key={observerId}>
<td>{observerNode?.name ?? 'Unnamed observer'}</td>
<td className="dev-status-mono">{shortNode(observerId)}</td>
<td>{observerNode?.iata ?? '—'}</td>
<td>{lastPacket ? timeAgo(lastPacket.time) : timeAgo(observerNode?.last_seen)}</td>
</tr>
)) : (
<tr>
<td colSpan={4} className="dev-status-empty">No observer traffic in the test feed yet.</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
<section className="dev-status-card dev-status-card--wide dev-status-card--fixed">
<h2>Live packets</h2>
<div className="dev-status-table-wrap">
<table className="dev-status-table">
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>Observer</th>
<th>Signal</th>
<th>Hash</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
{recentPackets.length > 0 ? recentPackets.map((packet) => (
<tr key={`${packet.packet_hash}-${packet.time}`}>
<td>{new Date(packet.time).toLocaleTimeString()}</td>
<td>{packet.packet_type !== undefined ? (TYPE_LABELS[packet.packet_type] ?? `T${packet.packet_type}`) : '—'}</td>
<td>{packet.rx_node_id ? (nodeMap.get(packet.rx_node_id)?.name ?? shortNode(packet.rx_node_id)) : 'unknown'}</td>
<td>{packet.rssi !== undefined || packet.snr !== undefined ? `${packet.rssi ?? '—'} / ${packet.snr ?? '—'}` : '—'}</td>
<td className="dev-status-mono">{packet.packet_hash}</td>
<td>{packetSummary(packet)}</td>
</tr>
)) : (
<tr>
<td colSpan={6} className="dev-status-empty">No test packets have arrived yet.</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
</section>
);
};
+8
View File
@@ -0,0 +1,8 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
export const DevLayout: React.FC = () => (
<main className="dev-status-shell">
<Outlet />
</main>
);
+39
View File
@@ -0,0 +1,39 @@
import React from 'react';
export const DevMqttPage: React.FC = () => (
<>
<section className="site-page-hero">
<div className="site-content">
<h1 className="site-page-hero__title">Development MQTT</h1>
<p className="site-page-hero__sub">
This site expects the same packet payload contract as production, but on the isolated test MQTT
namespace.
</p>
</div>
</section>
<div className="site-content site-prose">
<section className="prose-section">
<h2>Use the test topic contract</h2>
<p>
Publish status and packets to <code>meshcore-test/{'{'}IATA{'}'}/{'{'}PUBLIC_KEY{'}'}/status</code> and
<code>meshcore-test/{'{'}IATA{'}'}/{'{'}PUBLIC_KEY{'}'}/packets</code>. Keep the same JSON payload
shape as production so firmware changes can be validated directly without a second parser or payload
schema.
</p>
</section>
<section className="prose-section">
<h2>Recommended checks</h2>
<p>Use this site to verify:</p>
<ul>
<li>status arrives and updates reliably</li>
<li>packet `raw` hex is complete and decodes cleanly</li>
<li>packet hash, type, RSSI, and SNR are populated</li>
<li>multibyte path hashes are preserved in the raw packet</li>
<li>test traffic stays isolated from the public dashboards</li>
</ul>
</section>
</div>
</>
);
+24 -8
View File
@@ -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<SiteLayoutProps> = ({
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<SiteLayoutProps> = ({
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<SiteLayoutProps> = ({
{item.label}
</NavLink>
))}
<a href={appUrl} className="site-nav__link">Live Map</a>
{showLiveMap && <a href={appUrl} className="site-nav__link">Live Map</a>}
<NavLink
to="/login"
onClick={() => handleNavClick('/login')}
@@ -135,8 +147,12 @@ export const SiteLayout: React.FC<SiteLayoutProps> = ({
<a href="https://discord.gg/bSuST8xvet" target="_blank" rel="noopener noreferrer">Discord</a>
<span className="site-footer__sep">·</span>
<Link to="/open-source">Open Source</Link>
<span className="site-footer__sep">·</span>
<a href={appUrl}>Live Map</a>
{showLiveMap && (
<>
<span className="site-footer__sep">·</span>
<a href={appUrl}>Live Map</a>
</>
)}
</footer>
</div>
);
+269 -10
View File
@@ -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; }
+20 -7
View File
@@ -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 {
+7 -2
View File
@@ -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 {
+7
View File
@@ -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/#
+6 -3
View File
@@ -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