From 10136d55823dcf9ac44f3ef5f1fa6668bb46223e Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 8 Mar 2026 21:25:28 +0000 Subject: [PATCH] Add observer region stats cards --- backend/src/api/routes.ts | 75 ++++++++++++++++++++++++++- frontend/src/pages/StatsPage.tsx | 75 +++++++++++++++++++++++++++ frontend/src/styles/globals.css | 89 ++++++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 1 deletion(-) diff --git a/backend/src/api/routes.ts b/backend/src/api/routes.ts index 2534c0d..682a9a0 100644 --- a/backend/src/api/routes.ts +++ b/backend/src/api/routes.ts @@ -262,6 +262,7 @@ async function requireOwnerSession(req: Request, res: Response): Promise string; nodes: string; nodesAlias: (alias: string) => string; }; @@ -316,6 +317,14 @@ function networkFilters(network?: string, observer?: string): NetworkFilters { return { params, packets: packetConditions.length > 0 ? `AND ${packetConditions.join(' AND ')}` : '', + packetsAlias: (alias: string) => { + const prefix = `${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}rx_node_id) = LOWER(${observerParam})`); + return conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : ''; + }, nodes: nodeConditions().length > 0 ? `AND ${nodeConditions().join(' AND ')}` : '', nodesAlias: (alias: string) => { const conditions = nodeConditions(alias); @@ -1134,7 +1143,7 @@ router.get('/stats/charts', async (req, res) => { const [ phResult, pdResult, rhResult, rdResult, - ptResult, rpResult, hdResult, pcResult, sumResult, + ptResult, rpResult, hdResult, pcResult, sumResult, orSummaryResult, orSeriesResult, ] = await Promise.all([ // packets per hour — last 24h (all observed packet rows) query(` @@ -1227,6 +1236,38 @@ router.get('/stats/charts', async (req, res) => { (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 `, filters.params), + // observer regions summary — last 7d + query(` + SELECT + COALESCE(NULLIF(TRIM(UPPER(n.iata)), ''), 'UNK') AS iata, + COUNT(*) FILTER (WHERE p.time > NOW() - INTERVAL '24 hours') AS packets_24h, + COUNT(*) AS packets_7d, + COUNT(DISTINCT LOWER(p.rx_node_id)) AS observers, + MAX(p.time)::text AS last_packet_at + FROM packets p + LEFT JOIN nodes n ON LOWER(n.node_id) = LOWER(p.rx_node_id) + WHERE p.time > NOW() - INTERVAL '7 days' + AND p.rx_node_id IS NOT NULL + AND p.rx_node_id <> '' + ${filters.packetsAlias('p')} + GROUP BY 1 + ORDER BY packets_7d DESC, iata ASC + `, filters.params), + // observer regions sparkline series — last 7d + query(` + SELECT + COALESCE(NULLIF(TRIM(UPPER(n.iata)), ''), 'UNK') AS iata, + time_bucket('1 day', p.time) AS day, + COUNT(*) AS count + FROM packets p + LEFT JOIN nodes n ON LOWER(n.node_id) = LOWER(p.rx_node_id) + WHERE p.time > NOW() - INTERVAL '7 days' + AND p.rx_node_id IS NOT NULL + AND p.rx_node_id <> '' + ${filters.packetsAlias('p')} + GROUP BY 1, 2 + ORDER BY iata ASC, day ASC + `, filters.params), ]); const peakRow = phResult.rows.reduce( @@ -1243,6 +1284,37 @@ router.get('/stats/charts', async (req, res) => { return d.toLocaleDateString('en-GB', { weekday: 'short', month: 'short', day: 'numeric' }); }; + const observerRegionsByIata = new Map(); + + for (const row of orSummaryResult.rows) { + const iata = String(row.iata ?? 'UNK'); + observerRegionsByIata.set(iata, { + iata, + observers: Number(row.observers ?? 0), + packets24h: Number(row.packets_24h ?? 0), + packets7d: Number(row.packets_7d ?? 0), + lastPacketAt: row.last_packet_at ?? null, + series: [], + }); + } + + for (const row of orSeriesResult.rows) { + const iata = String(row.iata ?? 'UNK'); + const region = observerRegionsByIata.get(iata); + if (!region) continue; + region.series.push({ + day: fmtDay(row.day), + count: Number(row.count ?? 0), + }); + } + res.json({ packetsPerHour: phResult.rows.map(r => ({ hour: fmtHour(r.hour), count: Number(r.count) })), packetsPerDay: pdResult.rows.map(r => ({ day: fmtDay(r.day), count: Number(r.count) })), @@ -1257,6 +1329,7 @@ router.get('/stats/charts', async (req, res) => { prefix: String(r.prefix ?? '').toUpperCase(), repeats: Number(r.repeats), })), + observerRegions: Array.from(observerRegionsByIata.values()), summary: { totalPackets24h: Number(sumResult.rows[0].total_24h), totalPackets7d: Number(sumResult.rows[0].total_7d), diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index 437fc11..41a31fd 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -54,6 +54,14 @@ interface ChartData { repeatersPerDay: { hour: string; count: number }[]; hopDistribution: { hops: number; count: number }[]; prefixCollisions:{ prefix: string; repeats: number }[]; + observerRegions: { + iata: string; + observers: number; + packets24h: number; + packets7d: number; + lastPacketAt: string | null; + series: { day: string; count: number }[]; + }[]; summary: { totalPackets24h: number; totalPackets7d: number; @@ -112,6 +120,15 @@ export const StatsPage: React.FC = () => { }, [site.networkFilter, site.observerId]); const fmt = (n: number) => n.toLocaleString(); + const timeAgo = (ts: string | null) => { + if (!ts) return 'never'; + const diff = Math.max(0, Date.now() - Date.parse(ts)); + const sec = Math.floor(diff / 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`; + }; return (
@@ -169,6 +186,64 @@ export const StatsPage: React.FC = () => { />
+ {data.observerRegions.length > 0 && ( +
+
+ Observer regions + sorted by observed packets over the last 7 days +
+
+ {data.observerRegions.map((region) => ( +
+
+ {region.iata} + last packet {timeAgo(region.lastPacketAt)} +
+
+
+ Packets (7D) + {fmt(region.packets7d)} +
+
+ Packets (24h) + {fmt(region.packets24h)} +
+
+ Observers + {fmt(region.observers)} +
+
+
+ + + + + + + + + + + + } /> + + + +
+
+ ))} +
+
+ )} + {/* ── Packets over time ────────────────────────────────────────── */}
diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 157852d..7373fec 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -2618,6 +2618,82 @@ html, body, #root { margin-bottom: 16px; } +.stats-page__observer-section { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + margin: 0 0 16px; +} + +.stats-page__observer-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 12px; +} + +.stats-page__observer-card { + border: 1px solid rgba(0, 196, 255, 0.12); + background: rgba(10, 20, 32, 0.55); + border-radius: var(--radius-md); + padding: 14px; + display: flex; + flex-direction: column; + gap: 12px; + min-height: 190px; +} + +.stats-page__observer-card-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.stats-page__observer-iata { + font-size: 22px; + line-height: 1; + font-weight: 700; + font-family: var(--font-mono); + color: var(--text-primary); +} + +.stats-page__observer-last { + font-size: 11px; + color: var(--text-muted); + text-align: right; +} + +.stats-page__observer-metrics { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +.stats-page__observer-metric { + display: flex; + flex-direction: column; + gap: 4px; +} + +.stats-page__observer-metric span { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.stats-page__observer-metric strong { + font-size: 16px; + color: var(--text-primary); + font-family: var(--font-mono); +} + +.stats-page__observer-chart { + margin-top: auto; +} + .stats-page__chart { background: var(--bg-panel); border: 1px solid var(--border); @@ -2685,10 +2761,23 @@ html, body, #root { .stats-page__row { grid-template-columns: 1fr; } + .stats-page__observer-metrics { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } } @media (max-width: 480px) { .stats-page__summary { grid-template-columns: repeat(2, 1fr); } + .stats-page__observer-card-head { + flex-direction: column; + align-items: flex-start; + } + .stats-page__observer-last { + text-align: left; + } + .stats-page__observer-metrics { + grid-template-columns: 1fr; + } }