mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-10-22 22:11:31 +00:00
Add observer region stats cards
This commit is contained in:
1 parent
f294b96592
commit
10136d5582
3 files changed
+238
-1
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user