Add observer region stats cards

This commit is contained in:
Ben committed 2026-03-08 21:25:28 +00:00
1 parent f294b96592
commit 10136d5582
3 files changed
+238 -1

No files matched your search

+74 -1
View File
@@ -262,6 +262,7 @@ async function requireOwnerSession(req: Request, res: Response): Promise<string[
type NetworkFilters = {
params: string[];
packets: string;
packetsAlias: (alias: string) => 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<string, {
iata: string;
observers: number;
packets24h: number;
packets7d: number;
lastPacketAt: string | null;
series: { day: string; count: number }[];
}>();
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),
+75
View File
@@ -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 (
<div className="site-layout__inner">
@@ -169,6 +186,64 @@ export const StatsPage: React.FC = () => {
/>
</div>
{data.observerRegions.length > 0 && (
<div className="stats-page__observer-section">
<div className="stats-page__chart-header">
<span className="stats-page__chart-title">Observer regions</span>
<span className="stats-page__chart-sub">sorted by observed packets over the last 7 days</span>
</div>
<div className="stats-page__observer-grid">
{data.observerRegions.map((region) => (
<div key={region.iata} className="stats-page__observer-card">
<div className="stats-page__observer-card-head">
<span className="stats-page__observer-iata">{region.iata}</span>
<span className="stats-page__observer-last">last packet {timeAgo(region.lastPacketAt)}</span>
</div>
<div className="stats-page__observer-metrics">
<div className="stats-page__observer-metric">
<span>Packets (7D)</span>
<strong>{fmt(region.packets7d)}</strong>
</div>
<div className="stats-page__observer-metric">
<span>Packets (24h)</span>
<strong>{fmt(region.packets24h)}</strong>
</div>
<div className="stats-page__observer-metric">
<span>Observers</span>
<strong>{fmt(region.observers)}</strong>
</div>
</div>
<div className="stats-page__observer-chart">
<ResponsiveContainer width="100%" height={90}>
<AreaChart data={region.series} margin={{ top: 4, right: 0, left: 0, bottom: 0 }}>
<defs>
<linearGradient id={`gObserver-${region.iata}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={C_CYAN} stopOpacity={0.28} />
<stop offset="95%" stopColor={C_CYAN} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} {...gridProps} />
<XAxis dataKey="day" hide />
<YAxis hide />
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="count"
name="Packets"
stroke={C_CYAN}
fill={`url(#gObserver-${region.iata})`}
strokeWidth={2}
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
))}
</div>
</div>
)}
{/* ── Packets over time ────────────────────────────────────────── */}
<div className="stats-page__row">
<ChartCard title="Observed packets per hour" sub="last 24 hours">
+89
View File
@@ -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;
}
}