diff --git a/app/models.py b/app/models.py index 2af8900..5a5048d 100644 --- a/app/models.py +++ b/app/models.py @@ -501,6 +501,16 @@ class ContactActivityCounts(BaseModel): last_week: int +class PathHashWidthStats(BaseModel): + total_packets: int + single_byte: int + double_byte: int + triple_byte: int + single_byte_pct: float + double_byte_pct: float + triple_byte_pct: float + + class StatisticsResponse(BaseModel): busiest_channels_24h: list[BusyChannel] contact_count: int @@ -514,3 +524,4 @@ class StatisticsResponse(BaseModel): total_outgoing: int contacts_heard: ContactActivityCounts repeaters_heard: ContactActivityCounts + path_hash_width_24h: PathHashWidthStats diff --git a/app/repository/settings.py b/app/repository/settings.py index 351b910..5c94f95 100644 --- a/app/repository/settings.py +++ b/app/repository/settings.py @@ -5,6 +5,7 @@ from typing import Any, Literal from app.database import db from app.models import AppSettings, Favorite +from app.path_utils import parse_packet_envelope logger = logging.getLogger(__name__) @@ -269,6 +270,53 @@ class StatisticsRepository: "last_week": row["last_week"] or 0, } + @staticmethod + async def _path_hash_width_24h() -> dict[str, int | float]: + """Count parsed raw packets from the last 24h by hop hash width.""" + now = int(time.time()) + cursor = await db.conn.execute( + "SELECT data FROM raw_packets WHERE timestamp >= ?", + (now - SECONDS_24H,), + ) + rows = await cursor.fetchall() + + single_byte = 0 + double_byte = 0 + triple_byte = 0 + + for row in rows: + envelope = parse_packet_envelope(bytes(row["data"])) + if envelope is None: + continue + if envelope.hash_size == 1: + single_byte += 1 + elif envelope.hash_size == 2: + double_byte += 1 + elif envelope.hash_size == 3: + triple_byte += 1 + + total_packets = single_byte + double_byte + triple_byte + if total_packets == 0: + return { + "total_packets": 0, + "single_byte": 0, + "double_byte": 0, + "triple_byte": 0, + "single_byte_pct": 0.0, + "double_byte_pct": 0.0, + "triple_byte_pct": 0.0, + } + + return { + "total_packets": total_packets, + "single_byte": single_byte, + "double_byte": double_byte, + "triple_byte": triple_byte, + "single_byte_pct": (single_byte / total_packets) * 100, + "double_byte_pct": (double_byte / total_packets) * 100, + "triple_byte_pct": (triple_byte / total_packets) * 100, + } + @staticmethod async def get_all() -> dict: """Aggregate all statistics from existing tables.""" @@ -348,6 +396,7 @@ class StatisticsRepository: # Activity windows contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True) repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2) + path_hash_width_24h = await StatisticsRepository._path_hash_width_24h() return { "busiest_channels_24h": busiest_channels_24h, @@ -362,4 +411,5 @@ class StatisticsRepository: "total_outgoing": total_outgoing, "contacts_heard": contacts_heard, "repeaters_heard": repeaters_heard, + "path_hash_width_24h": path_hash_width_24h, } diff --git a/frontend/src/components/settings/SettingsStatisticsSection.tsx b/frontend/src/components/settings/SettingsStatisticsSection.tsx index 18812e2..42dab34 100644 --- a/frontend/src/components/settings/SettingsStatisticsSection.tsx +++ b/frontend/src/components/settings/SettingsStatisticsSection.tsx @@ -3,6 +3,10 @@ import { Separator } from '../ui/separator'; import { api } from '../../api'; import type { StatisticsResponse } from '../../types'; +function formatPercent(value: number): string { + return `${value.toFixed(1)}%`; +} + export function SettingsStatisticsSection({ className }: { className?: string }) { const [stats, setStats] = useState(null); const [statsLoading, setStatsLoading] = useState(false); @@ -34,7 +38,9 @@ export function SettingsStatisticsSection({ className }: { className?: string }) return (
{statsLoading && !stats ? ( -
Loading statistics...
+
+ Loading statistics... this can take a while if you have a lot of stored packets. +
) : stats ? (
{/* Network */} @@ -100,6 +106,39 @@ export function SettingsStatisticsSection({ className }: { className?: string }) +
+

Path Hash Width (24h)

+
+ Parsed stored raw packets from the last 24 hours:{' '} + {stats.path_hash_width_24h.total_packets} +
+
+
+ 1-byte hops + + {stats.path_hash_width_24h.single_byte} ( + {formatPercent(stats.path_hash_width_24h.single_byte_pct)}) + +
+
+ 2-byte hops + + {stats.path_hash_width_24h.double_byte} ( + {formatPercent(stats.path_hash_width_24h.double_byte_pct)}) + +
+
+ 3-byte hops + + {stats.path_hash_width_24h.triple_byte} ( + {formatPercent(stats.path_hash_width_24h.triple_byte_pct)}) + +
+
+
+ + + {/* Activity */}

Activity

diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 92e8890..18206d0 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -378,6 +378,15 @@ describe('SettingsModal', () => { total_outgoing: 30, contacts_heard: { last_hour: 2, last_24_hours: 7, last_week: 10 }, repeaters_heard: { last_hour: 1, last_24_hours: 3, last_week: 3 }, + path_hash_width_24h: { + total_packets: 120, + single_byte: 60, + double_byte: 36, + triple_byte: 24, + single_byte_pct: 50, + double_byte_pct: 30, + triple_byte_pct: 20, + }, }; vi.spyOn(globalThis, 'fetch').mockResolvedValue( @@ -405,6 +414,14 @@ describe('SettingsModal', () => { expect(screen.getByText('Total stored')).toBeInTheDocument(); expect(screen.getByText('Decrypted')).toBeInTheDocument(); expect(screen.getByText('Undecrypted')).toBeInTheDocument(); + expect(screen.getByText('Path Hash Width (24h)')).toBeInTheDocument(); + expect( + screen.getByText(/Parsed stored raw packets from the last 24 hours: 120/) + ).toBeInTheDocument(); + expect(screen.getByText('1-byte hops')).toBeInTheDocument(); + expect(screen.getByText('60 (50.0%)')).toBeInTheDocument(); + expect(screen.getByText('36 (30.0%)')).toBeInTheDocument(); + expect(screen.getByText('24 (20.0%)')).toBeInTheDocument(); expect(screen.getByText('Contacts heard')).toBeInTheDocument(); expect(screen.getByText('Repeaters heard')).toBeInTheDocument(); @@ -427,6 +444,15 @@ describe('SettingsModal', () => { total_outgoing: 30, contacts_heard: { last_hour: 2, last_24_hours: 7, last_week: 10 }, repeaters_heard: { last_hour: 1, last_24_hours: 3, last_week: 3 }, + path_hash_width_24h: { + total_packets: 120, + single_byte: 60, + double_byte: 36, + triple_byte: 24, + single_byte_pct: 50, + double_byte_pct: 30, + triple_byte_pct: 20, + }, }; const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 33583b9..36a5e95 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -398,4 +398,13 @@ export interface StatisticsResponse { total_outgoing: number; contacts_heard: ContactActivityCounts; repeaters_heard: ContactActivityCounts; + path_hash_width_24h: { + total_packets: number; + single_byte: number; + double_byte: number; + triple_byte: number; + single_byte_pct: number; + double_byte_pct: number; + triple_byte_pct: number; + }; } diff --git a/tests/test_statistics.py b/tests/test_statistics.py index 4773550..a6d5539 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -29,6 +29,15 @@ class TestStatisticsEmpty: assert result["repeaters_heard"]["last_hour"] == 0 assert result["repeaters_heard"]["last_24_hours"] == 0 assert result["repeaters_heard"]["last_week"] == 0 + assert result["path_hash_width_24h"] == { + "total_packets": 0, + "single_byte": 0, + "double_byte": 0, + "triple_byte": 0, + "single_byte_pct": 0.0, + "double_byte_pct": 0.0, + "triple_byte_pct": 0.0, + } class TestStatisticsCounts: @@ -246,3 +255,47 @@ class TestActivityWindows: assert result["repeaters_heard"]["last_hour"] == 1 assert result["repeaters_heard"]["last_24_hours"] == 1 assert result["repeaters_heard"]["last_week"] == 1 + + +class TestPathHashWidthStats: + @pytest.mark.asyncio + async def test_counts_last_24h_packets_by_hash_width(self, test_db): + """Recent raw packets are bucketed by parsed path hash width.""" + now = int(time.time()) + conn = test_db.conn + + packets = [ + (now, bytes.fromhex("0100AA"), b"\x11" * 32), + ( + now, + bytes.fromhex( + "1540cab3b15626481a5ba64247ab25766e410b026e0678a32da9f0c3946fae5b714cab170f" + ), + b"\x22" * 32, + ), + ( + now, + bytes.fromhex("15833fa002860ccae0eed9ca78b9ab0775d477c1f6490a398bf4edc75240"), + b"\x33" * 32, + ), + (now, bytes.fromhex("09C1AABBCC"), b"\x44" * 32), + (now - 90000, bytes.fromhex("0140AA"), b"\x55" * 32), + ] + + for timestamp, data, payload_hash in packets: + await conn.execute( + "INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)", + (timestamp, data, payload_hash), + ) + await conn.commit() + + result = await StatisticsRepository.get_all() + breakdown = result["path_hash_width_24h"] + + assert breakdown["total_packets"] == 3 + assert breakdown["single_byte"] == 1 + assert breakdown["double_byte"] == 1 + assert breakdown["triple_byte"] == 1 + assert breakdown["single_byte_pct"] == pytest.approx(100 / 3, rel=1e-3) + assert breakdown["double_byte_pct"] == pytest.approx(100 / 3, rel=1e-3) + assert breakdown["triple_byte_pct"] == pytest.approx(100 / 3, rel=1e-3)