mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-10-23 06:12:27 +00:00
Add isolated test feed and fix live public stats
This commit is contained in:
1 parent
cae0580609
commit
41c01bb70b
35 files changed
+1506
-440
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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'`,
|
||||
),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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' : ''}`}>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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'] }) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Vendored
+7
-2
@@ -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 {
|
||||
|
||||
@@ -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/#
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user