Improve stats charts and path hash reporting

This commit is contained in:
Ben committed 2026-03-09 02:20:00 +00:00
1 parent 10136d5582
commit 1f0e4addde
4 files changed
+201 -60

No files matched your search

+140 -14
View File
@@ -320,8 +320,13 @@ function networkFilters(network?: string, observer?: string): NetworkFilters {
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 (networkParam) {
conditions.push(`${prefix}network = ${networkParam}`);
conditions.push(`split_part(${prefix}topic, '/', 1) <> 'meshcore-test'`);
} else {
conditions.push(`${prefix}network IS DISTINCT FROM 'test'`);
conditions.push(`split_part(${prefix}topic, '/', 1) <> 'meshcore-test'`);
}
if (observerParam) conditions.push(`LOWER(${prefix}rx_node_id) = LOWER(${observerParam})`);
return conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '';
},
@@ -1144,13 +1149,27 @@ router.get('/stats/charts', async (req, res) => {
const [
phResult, pdResult, rhResult, rdResult,
ptResult, rpResult, hdResult, pcResult, sumResult, orSummaryResult, orSeriesResult,
pathHashWidthsResult, multibyteSummaryResult,
] = await Promise.all([
// packets per hour — last 24h (all observed packet rows)
// packets per rolling hour — last 24h (sampled every 5 minutes)
query(`
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
WITH buckets AS (
SELECT generate_series(
date_trunc('minute', NOW() - INTERVAL '24 hours'),
date_trunc('minute', NOW()),
INTERVAL '5 minutes'
) AS bucket
)
SELECT b.bucket AS hour, COALESCE(c.count, 0) AS count
FROM buckets b
LEFT JOIN LATERAL (
SELECT COUNT(*)::int AS count
FROM packets p
WHERE p.time > b.bucket - INTERVAL '1 hour'
AND p.time <= b.bucket
${filters.packetsAlias('p')}
) c ON TRUE
ORDER BY b.bucket
`, filters.params),
// packets per day — last 7d (all observed packet rows)
query(`
@@ -1159,12 +1178,26 @@ router.get('/stats/charts', async (req, res) => {
WHERE time > NOW() - INTERVAL '7 days' ${filters.packets}
GROUP BY day ORDER BY day
`, filters.params),
// unique radios heard per hour — last 24h (distinct transmitting nodes)
// unique radios heard per rolling hour — last 24h (sampled every 5 minutes)
query(`
SELECT time_bucket('1 hour', time) AS hour, COUNT(DISTINCT src_node_id) AS count
FROM packets
WHERE time > NOW() - INTERVAL '24 hours' AND src_node_id IS NOT NULL ${filters.packets}
GROUP BY hour ORDER BY hour
WITH buckets AS (
SELECT generate_series(
date_trunc('minute', NOW() - INTERVAL '24 hours'),
date_trunc('minute', NOW()),
INTERVAL '5 minutes'
) AS bucket
)
SELECT b.bucket AS hour, COALESCE(c.count, 0) AS count
FROM buckets b
LEFT JOIN LATERAL (
SELECT COUNT(DISTINCT p.src_node_id)::int AS count
FROM packets p
WHERE p.time > b.bucket - INTERVAL '1 hour'
AND p.time <= b.bucket
AND p.src_node_id IS NOT NULL
${filters.packetsAlias('p')}
) c ON TRUE
ORDER BY b.bucket
`, filters.params),
// unique radios heard per day — last 7d
query(`
@@ -1268,6 +1301,69 @@ router.get('/stats/charts', async (req, res) => {
GROUP BY 1, 2
ORDER BY iata ASC, day ASC
`, filters.params),
query<{
hash_hex_len: string;
hop_count: string;
}>(
`SELECT length(h)::text AS hash_hex_len, COUNT(*)::text AS hop_count
FROM packets p
CROSS JOIN LATERAL unnest(p.path_hashes) AS h
WHERE p.time > NOW() - INTERVAL '24 hours'
${filters.packetsAlias('p')}
GROUP BY 1`,
filters.params,
),
query<{
latest_multibyte_at: string | null;
multibyte_packets_24h: string;
fully_decoded_multibyte_24h: string;
}>(
`WITH multibyte AS (
SELECT DISTINCT ON (p.packet_hash)
p.packet_hash,
p.time,
p.path_hashes
FROM packets p
WHERE p.time > NOW() - INTERVAL '24 hours'
AND p.path_hash_size_bytes > 1
AND COALESCE(array_length(p.path_hashes, 1), 0) > 0
${filters.packetsAlias('p')}
ORDER BY p.packet_hash,
COALESCE(array_length(p.path_hashes, 1), 0) DESC,
COALESCE(p.hop_count, 0) DESC,
p.time DESC
),
hop_matches AS (
SELECT
m.packet_hash,
h.ord,
COUNT(DISTINCT n.node_id)::int AS match_count,
MIN(n.node_id) FILTER (WHERE n.node_id IS NOT NULL) AS matched_node_id
FROM multibyte m
CROSS JOIN LATERAL unnest(m.path_hashes) WITH ORDINALITY AS h(hash, ord)
LEFT JOIN nodes n
ON UPPER(LEFT(n.node_id, LENGTH(h.hash))) = UPPER(h.hash)
AND (n.role IS NULL OR n.role = 2)
AND n.lat IS NOT NULL
AND n.lon IS NOT NULL
GROUP BY 1, 2
),
packet_eval AS (
SELECT
m.packet_hash,
BOOL_AND(hm.match_count = 1) AS all_unique,
COUNT(hm.ord)::int AS hop_count,
COUNT(DISTINCT hm.matched_node_id) FILTER (WHERE hm.matched_node_id IS NOT NULL)::int AS unique_nodes
FROM multibyte m
JOIN hop_matches hm ON hm.packet_hash = m.packet_hash
GROUP BY m.packet_hash
)
SELECT
(SELECT MAX(time)::text FROM multibyte) AS latest_multibyte_at,
(SELECT COUNT(*)::text FROM multibyte) AS multibyte_packets_24h,
(SELECT COUNT(*)::text FROM packet_eval WHERE all_unique AND unique_nodes = hop_count) AS fully_decoded_multibyte_24h`,
filters.params,
),
]);
const peakRow = phResult.rows.reduce(
@@ -1279,6 +1375,10 @@ router.get('/stats/charts', async (req, res) => {
const d = new Date(ts);
return `${d.getHours().toString().padStart(2, '0')}:00`;
};
const fmtHourMinute = (ts: Date | string) => {
const d = new Date(ts);
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
};
const fmtDay = (ts: Date | string) => {
const d = new Date(ts);
return d.toLocaleDateString('en-GB', { weekday: 'short', month: 'short', day: 'numeric' });
@@ -1315,10 +1415,30 @@ router.get('/stats/charts', async (req, res) => {
});
}
const widthToBucket: Record<number, 'one_byte' | 'two_byte' | 'three_byte'> = {
2: 'one_byte',
4: 'two_byte',
6: 'three_byte',
};
const pathHashStats = {
one_byte: 0,
two_byte: 0,
three_byte: 0,
};
for (const row of pathHashWidthsResult.rows) {
const width = Number(row.hash_hex_len ?? 0);
const bucket = widthToBucket[width];
if (!bucket) continue;
pathHashStats[bucket] += Number(row.hop_count ?? 0);
}
const multibyteRow = multibyteSummaryResult.rows[0];
res.json({
packetsPerHour: phResult.rows.map(r => ({ hour: fmtHour(r.hour), count: Number(r.count) })),
packetsPerHour: phResult.rows.map(r => ({ hour: fmtHourMinute(r.hour), count: Number(r.count) })),
packetsPerDay: pdResult.rows.map(r => ({ day: fmtDay(r.day), count: Number(r.count) })),
radiosPerHour: rhResult.rows.map(r => ({ hour: fmtHour(r.hour), count: Number(r.count) })),
radiosPerHour: rhResult.rows.map(r => ({ hour: fmtHourMinute(r.hour), count: Number(r.count) })),
radiosPerDay: rdResult.rows.map(r => ({ day: fmtDay(r.day), count: Number(r.count) })),
packetTypes: ptResult.rows.map(r => ({ label: PAYLOAD_LABELS[Number(r.packet_type)] ?? `Type${r.packet_type}`, count: Number(r.count) })),
repeatersPerDay: rpResult.rows.map(r => ({ hour: fmtDay(r.hour), count: Number(r.count ?? 0) })),
@@ -1330,6 +1450,12 @@ router.get('/stats/charts', async (req, res) => {
repeats: Number(r.repeats),
})),
observerRegions: Array.from(observerRegionsByIata.values()),
pathHashes: {
last24hHops: pathHashStats,
multibytePackets24h: Number(multibyteRow?.multibyte_packets_24h ?? 0),
fullyDecodedMultibyte24h: Number(multibyteRow?.fully_decoded_multibyte_24h ?? 0),
latestMultibyteAt: multibyteRow?.latest_multibyte_at ?? null,
},
summary: {
totalPackets24h: Number(sumResult.rows[0].total_24h),
totalPackets7d: Number(sumResult.rows[0].total_7d),
+14 -9
View File
@@ -48,13 +48,18 @@ function buildScopePlaceholders(startIndex: number, network?: string, observer?:
function buildPacketScopeClause(
placeholders: ScopePlaceholders,
alias?: string,
network?: string,
): string {
const prefix = alias ? `${alias}.` : '';
const conditions: string[] = [];
if (placeholders.networkParam) {
conditions.push(`${prefix}network = ${placeholders.networkParam}`);
if (network !== 'test') {
conditions.push(`split_part(${prefix}topic, '/', 1) <> 'meshcore-test'`);
}
} else {
conditions.push(`${prefix}network IS DISTINCT FROM 'test'`);
conditions.push(`split_part(${prefix}topic, '/', 1) <> 'meshcore-test'`);
}
if (placeholders.observerParam) {
conditions.push(`LOWER(${prefix}rx_node_id) = LOWER(${placeholders.observerParam})`);
@@ -227,7 +232,7 @@ export async function getRecentPackets(limit = 200, network?: string, observer?:
WHERE p2.packet_hash = p.packet_hash
AND p2.time > NOW() - INTERVAL '5 minutes'
AND p2.rx_node_id IS NOT NULL
${buildPacketScopeClause(scope, 'p2')}
${buildPacketScopeClause(scope, 'p2', network)}
) AS observer_node_ids,
(
SELECT COUNT(*)::int
@@ -235,7 +240,7 @@ export async function getRecentPackets(limit = 200, network?: string, observer?:
WHERE p2.packet_hash = p.packet_hash
AND p2.time > NOW() - INTERVAL '5 minutes'
AND COALESCE(p2.payload->>'direction', 'rx') <> 'tx'
${buildPacketScopeClause(scope, 'p2')}
${buildPacketScopeClause(scope, 'p2', network)}
) AS rx_count,
(
SELECT COUNT(*)::int
@@ -243,11 +248,11 @@ export async function getRecentPackets(limit = 200, network?: string, observer?:
WHERE p2.packet_hash = p.packet_hash
AND p2.time > NOW() - INTERVAL '5 minutes'
AND COALESCE(p2.payload->>'direction', 'rx') = 'tx'
${buildPacketScopeClause(scope, 'p2')}
${buildPacketScopeClause(scope, 'p2', network)}
) AS tx_count
FROM packets p
WHERE p.time > NOW() - INTERVAL '5 minutes'
${buildPacketScopeClause(scope, 'p')}
${buildPacketScopeClause(scope, 'p', network)}
ORDER BY p.packet_hash,
CASE WHEN p.payload ? 'appData' THEN 1 ELSE 0 END DESC,
CASE WHEN p.src_node_id IS NOT NULL THEN 1 ELSE 0 END DESC,
@@ -273,7 +278,7 @@ export async function getRecentPacketEvents(limit = 200, network?: string, obser
p.advert_count, p.path_hashes, p.path_hash_size_bytes
FROM packets p
WHERE p.time > NOW() - INTERVAL '24 hours'
${buildPacketScopeClause(scope, 'p')}
${buildPacketScopeClause(scope, 'p', network)}
ORDER BY p.time DESC
LIMIT $1`,
params,
@@ -296,7 +301,7 @@ export async function getLastNPackets(n: number, network?: string, observer?: st
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')}
${buildPacketScopeClause(scope, 'p2', network)}
) AS observer_node_ids,
(
SELECT COUNT(*)::int
@@ -304,7 +309,7 @@ export async function getLastNPackets(n: number, network?: string, observer?: st
WHERE p2.packet_hash = p.packet_hash
AND p2.time > NOW() - INTERVAL '24 hours'
AND COALESCE(p2.payload->>'direction', 'rx') <> 'tx'
${buildPacketScopeClause(scope, 'p2')}
${buildPacketScopeClause(scope, 'p2', network)}
) AS rx_count,
(
SELECT COUNT(*)::int
@@ -312,10 +317,10 @@ export async function getLastNPackets(n: number, network?: string, observer?: st
WHERE p2.packet_hash = p.packet_hash
AND p2.time > NOW() - INTERVAL '24 hours'
AND COALESCE(p2.payload->>'direction', 'rx') = 'tx'
${buildPacketScopeClause(scope, 'p2')}
${buildPacketScopeClause(scope, 'p2', network)}
) AS tx_count
FROM packets p
WHERE p.time > NOW() - INTERVAL '24 hours' ${buildPacketScopeClause(scope, 'p')}
WHERE p.time > NOW() - INTERVAL '24 hours' ${buildPacketScopeClause(scope, 'p', network)}
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,
-37
View File
@@ -33,15 +33,6 @@ type HealthPayload = {
stale_threshold_minutes: number;
global_last_packet_at: string | null;
};
path_hashes: {
last_24h_hops: {
one_byte: number;
two_byte: number;
three_byte: number;
};
multibyte_packets_24h: number;
latest_multibyte_at: string | null;
};
};
function timeAgo(ts: string | null): string {
@@ -200,34 +191,6 @@ export const HealthPage: React.FC = () => {
<div className="health-kv"><span>Disk</span><strong>{fmtGb(data?.system.disk.used_gb)} / {fmtGb(data?.system.disk.total_gb)}</strong></div>
</div>
</section>
<section className="prose-section">
<h2>Path Hashes</h2>
<div className="site-stats-grid site-stats-grid--4 health-system-grid">
<div className="site-stat">
<span className="site-stat__value">{fmtInt(data?.path_hashes.last_24h_hops.one_byte)}</span>
<span className="site-stat__label">1-byte Hops (24h)</span>
</div>
<div className="site-stat">
<span className="site-stat__value">{fmtInt(data?.path_hashes.last_24h_hops.two_byte)}</span>
<span className="site-stat__label">2-byte Hops (24h)</span>
</div>
<div className="site-stat">
<span className="site-stat__value">{fmtInt(data?.path_hashes.last_24h_hops.three_byte)}</span>
<span className="site-stat__label">3-byte Hops (24h)</span>
</div>
<div className="site-stat">
<span className="site-stat__value">{fmtInt(data?.path_hashes.multibyte_packets_24h)}</span>
<span className="site-stat__label">Multibyte Packets (24h)</span>
</div>
</div>
<div className="health-meta">
<div className="health-kv">
<span>Latest Multibyte Packet</span>
<strong>{data?.path_hashes.latest_multibyte_at ? timeAgo(data.path_hashes.latest_multibyte_at) : 'not seen yet'}</strong>
</div>
</div>
</section>
</div>
</>
);
+47
View File
@@ -62,6 +62,16 @@ interface ChartData {
lastPacketAt: string | null;
series: { day: string; count: number }[];
}[];
pathHashes: {
last24hHops: {
one_byte: number;
two_byte: number;
three_byte: number;
};
multibytePackets24h: number;
fullyDecodedMultibyte24h: number;
latestMultibyteAt: string | null;
};
summary: {
totalPackets24h: number;
totalPackets7d: number;
@@ -120,6 +130,7 @@ export const StatsPage: React.FC = () => {
}, [site.networkFilter, site.observerId]);
const fmt = (n: number) => n.toLocaleString();
const pct = (num: number, den: number) => den > 0 ? `${Math.round((num / den) * 100)}%` : '0%';
const timeAgo = (ts: string | null) => {
if (!ts) return 'never';
const diff = Math.max(0, Date.now() - Date.parse(ts));
@@ -244,6 +255,42 @@ export const StatsPage: React.FC = () => {
</div>
)}
<div className="stats-page__observer-section">
<div className="stats-page__chart-header">
<span className="stats-page__chart-title">Path hashes</span>
<span className="stats-page__chart-sub">observed hop widths across the last 24 hours</span>
</div>
<div className="site-stats-grid site-stats-grid--4 health-system-grid">
<div className="site-stat">
<span className="site-stat__value">{fmt(data.pathHashes.last24hHops.one_byte)}</span>
<span className="site-stat__label">1-byte Hops (24h)</span>
</div>
<div className="site-stat">
<span className="site-stat__value">{fmt(data.pathHashes.last24hHops.two_byte)}</span>
<span className="site-stat__label">2-byte Hops (24h)</span>
</div>
<div className="site-stat">
<span className="site-stat__value">{fmt(data.pathHashes.last24hHops.three_byte)}</span>
<span className="site-stat__label">3-byte Hops (24h)</span>
</div>
<div className="site-stat">
<span className="site-stat__value">{fmt(data.pathHashes.multibytePackets24h)}</span>
<span className="site-stat__label">Multibyte Packets (24h)</span>
</div>
<div className="site-stat">
<span className="site-stat__value">{fmt(data.pathHashes.fullyDecodedMultibyte24h)}</span>
<span className="site-stat__label">Fully Decoded (24h)</span>
<span className="site-stat__sub">{pct(data.pathHashes.fullyDecodedMultibyte24h, data.pathHashes.multibytePackets24h)} of multibyte packets</span>
</div>
</div>
<div className="health-meta">
<div className="health-kv">
<span>Latest Multibyte Packet</span>
<strong>{data.pathHashes.latestMultibyteAt ? timeAgo(data.pathHashes.latestMultibyteAt) : 'not seen yet'}</strong>
</div>
</div>
</div>
{/* ── Packets over time ────────────────────────────────────────── */}
<div className="stats-page__row">
<ChartCard title="Observed packets per hour" sub="last 24 hours">