From b98102ccacc145e50eca8189d3bb076dc51648c6 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 7 Apr 2026 16:26:01 -0700 Subject: [PATCH] Add 72hr packet density view --- app/models.py | 6 ++ app/repository/settings.py | 22 +++++ .../settings/SettingsStatisticsSection.tsx | 92 +++++++++++++++++++ frontend/src/test/settingsModal.test.tsx | 5 + frontend/src/types.ts | 6 ++ tests/test_statistics.py | 49 ++++++++++ 6 files changed, 180 insertions(+) diff --git a/app/models.py b/app/models.py index 96615c2..94bc2b3 100644 --- a/app/models.py +++ b/app/models.py @@ -884,6 +884,11 @@ class NoiseFloorHistoryStats(BaseModel): samples: list[NoiseFloorSample] = Field(default_factory=list) +class PacketsPerHourBucket(BaseModel): + timestamp: int = Field(description="Unix timestamp at the start of the hour") + count: int = Field(description="Number of packets received in that hour") + + class StatisticsResponse(BaseModel): busiest_channels_24h: list[BusyChannel] contact_count: int @@ -899,6 +904,7 @@ class StatisticsResponse(BaseModel): repeaters_heard: ContactActivityCounts known_channels_active: ContactActivityCounts path_hash_width_24h: PathHashWidthStats + packets_per_hour_72h: list[PacketsPerHourBucket] noise_floor_24h: NoiseFloorHistoryStats diff --git a/app/repository/settings.py b/app/repository/settings.py index aa4d703..c5dda0b 100644 --- a/app/repository/settings.py +++ b/app/repository/settings.py @@ -11,6 +11,7 @@ logger = logging.getLogger(__name__) SECONDS_1H = 3600 SECONDS_24H = 86400 +SECONDS_72H = 259200 SECONDS_7D = 604800 RAW_PACKET_STATS_BATCH_SIZE = 500 @@ -274,6 +275,25 @@ class StatisticsRepository: "last_week": row["last_week"] or 0, } + @staticmethod + async def _packets_per_hour_72h() -> list[dict[str, int]]: + """Return packet counts bucketed by hour for the last 72 hours.""" + now = int(time.time()) + cutoff = now - SECONDS_72H + # Bucket timestamps to the start of each hour + cursor = await db.conn.execute( + """ + SELECT (timestamp / 3600) * 3600 AS hour_ts, COUNT(*) AS count + FROM raw_packets + WHERE timestamp >= ? + GROUP BY hour_ts + ORDER BY hour_ts + """, + (cutoff,), + ) + rows = await cursor.fetchall() + return [{"timestamp": row["hour_ts"], "count": row["count"]} for row in rows] + @staticmethod async def _path_hash_width_24h() -> dict[str, int | float]: """Count parsed raw packets from the last 24h by hop hash width.""" @@ -350,6 +370,7 @@ class StatisticsRepository: repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2) known_channels_active = await StatisticsRepository._known_channels_active() path_hash_width_24h = await StatisticsRepository._path_hash_width_24h() + packets_per_hour_72h = await StatisticsRepository._packets_per_hour_72h() return { "busiest_channels_24h": busiest_channels_24h, @@ -366,4 +387,5 @@ class StatisticsRepository: "repeaters_heard": repeaters_heard, "known_channels_active": known_channels_active, "path_hash_width_24h": path_hash_width_24h, + "packets_per_hour_72h": packets_per_hour_72h, } diff --git a/frontend/src/components/settings/SettingsStatisticsSection.tsx b/frontend/src/components/settings/SettingsStatisticsSection.tsx index 6605a03..51085d0 100644 --- a/frontend/src/components/settings/SettingsStatisticsSection.tsx +++ b/frontend/src/components/settings/SettingsStatisticsSection.tsx @@ -42,6 +42,87 @@ function formatTime(ts: number): string { }); } +function formatDateTime(ts: number): string { + const d = new Date(ts * 1000); + return ( + d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + + ' ' + + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }) + ); +} + +function PacketsPerHourChart({ buckets }: { buckets: { timestamp: number; count: number }[] }) { + // Fill gaps so hours with zero packets still appear on the chart + const filled: { timestamp: number; count: number }[] = []; + if (buckets.length > 0) { + const first = buckets[0].timestamp; + const last = buckets[buckets.length - 1].timestamp; + const byTs = new Map(buckets.map((b) => [b.timestamp, b.count])); + for (let ts = first; ts <= last; ts += 3600) { + filled.push({ timestamp: ts, count: byTs.get(ts) ?? 0 }); + } + } + + const data = filled.map((b, i) => ({ + idx: i, + label: formatDateTime(b.timestamp), + count: b.count, + })); + + // Show ~6 evenly-spaced tick labels + const tickCount = Math.min(6, data.length); + const tickIndices: number[] = []; + if (data.length > 1) { + for (let i = 0; i < tickCount; i++) { + tickIndices.push(Math.round((i / (tickCount - 1)) * (data.length - 1))); + } + } + + return ( + + + + data[idx]?.label ?? ''} + /> + + data[Number(idx)]?.label ?? ''} + formatter={(value) => [`${Number(value).toLocaleString()} packets`, 'Count']} + /> + + + + ); +} + function NoiseFloorChart({ samples, }: { @@ -241,6 +322,17 @@ export function SettingsStatisticsSection({ className }: { className?: string }) + {/* Packets per Hour (72h) */} + {stats.packets_per_hour_72h?.length > 0 && ( + <> + +
+

Packets per Hour (72h)

+ +
+ + )} + {/* Path Hash Width */} diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 4e7133d..056bc24 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -652,6 +652,10 @@ describe('SettingsModal', () => { double_byte_pct: 30, triple_byte_pct: 20, }, + packets_per_hour_72h: [ + { timestamp: 1711792800, count: 12 }, + { timestamp: 1711796400, count: 8 }, + ], noise_floor_24h: { sample_interval_seconds: 300, coverage_seconds: 3600, @@ -722,6 +726,7 @@ describe('SettingsModal', () => { double_byte_pct: 30, triple_byte_pct: 20, }, + packets_per_hour_72h: [], noise_floor_24h: { sample_interval_seconds: 300, coverage_seconds: 0, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a8efdf9..b43a1dc 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -544,6 +544,11 @@ export interface NoiseFloorHistoryStats { samples: NoiseFloorSample[]; } +interface PacketsPerHourBucket { + timestamp: number; + count: number; +} + export interface StatisticsResponse { busiest_channels_24h: BusyChannel[]; contact_count: number; @@ -567,5 +572,6 @@ export interface StatisticsResponse { double_byte_pct: number; triple_byte_pct: number; }; + packets_per_hour_72h: PacketsPerHourBucket[]; noise_floor_24h: NoiseFloorHistoryStats; } diff --git a/tests/test_statistics.py b/tests/test_statistics.py index 89c2d66..7a6ca8f 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -43,6 +43,7 @@ class TestStatisticsEmpty: "double_byte_pct": 0.0, "triple_byte_pct": 0.0, } + assert result["packets_per_hour_72h"] == [] class TestStatisticsCounts: @@ -397,6 +398,54 @@ class TestPathHashWidthStats: assert breakdown["triple_byte"] == 1 +class TestPacketsPerHour: + @pytest.mark.asyncio + async def test_buckets_packets_by_hour(self, test_db): + """Packets within 72h are bucketed by hour.""" + now = int(time.time()) + hour_start = (now // 3600) * 3600 + conn = test_db.conn + + # 3 packets in the current hour, 1 in the previous hour + for i in range(3): + await conn.execute( + "INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)", + (hour_start + i, b"\x01", bytes([i]) * 32), + ) + await conn.execute( + "INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)", + (hour_start - 1800, b"\x02", b"\xaa" * 32), + ) + # 1 packet outside the 72h window — should be excluded + await conn.execute( + "INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)", + (now - 260000, b"\x03", b"\xbb" * 32), + ) + await conn.commit() + + result = await StatisticsRepository.get_all() + buckets = result["packets_per_hour_72h"] + + assert len(buckets) == 2 + by_ts = {b["timestamp"]: b["count"] for b in buckets} + assert by_ts[hour_start] == 3 + assert by_ts[hour_start - 3600] == 1 + + @pytest.mark.asyncio + async def test_empty_when_no_recent_packets(self, test_db): + """Returns empty list when all packets are older than 72h.""" + now = int(time.time()) + conn = test_db.conn + await conn.execute( + "INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)", + (now - 300000, b"\x01", b"\x01" * 32), + ) + await conn.commit() + + result = await StatisticsRepository.get_all() + assert result["packets_per_hour_72h"] == [] + + class TestStatisticsEndpoint: @pytest.mark.asyncio async def test_statistics_endpoint_includes_noise_floor_history(self, test_db, client):