Add statistics

This commit is contained in:
Jack Kingsman
2026-03-07 23:17:10 -08:00
parent 20af50585b
commit b3625b4937
6 changed files with 189 additions and 1 deletions
+11
View File
@@ -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
+50
View File
@@ -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,
}
@@ -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<StatisticsResponse | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
@@ -34,7 +38,9 @@ export function SettingsStatisticsSection({ className }: { className?: string })
return (
<div className={className}>
{statsLoading && !stats ? (
<div className="py-8 text-center text-muted-foreground">Loading statistics...</div>
<div className="py-8 text-center text-muted-foreground">
Loading statistics... this can take a while if you have a lot of stored packets.
</div>
) : stats ? (
<div className="space-y-6">
{/* Network */}
@@ -100,6 +106,39 @@ export function SettingsStatisticsSection({ className }: { className?: string })
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Path Hash Width (24h)</h4>
<div className="mb-2 text-xs text-muted-foreground">
Parsed stored raw packets from the last 24 hours:{' '}
{stats.path_hash_width_24h.total_packets}
</div>
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span>1-byte hops</span>
<span className="text-muted-foreground">
{stats.path_hash_width_24h.single_byte} (
{formatPercent(stats.path_hash_width_24h.single_byte_pct)})
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span>2-byte hops</span>
<span className="text-muted-foreground">
{stats.path_hash_width_24h.double_byte} (
{formatPercent(stats.path_hash_width_24h.double_byte_pct)})
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span>3-byte hops</span>
<span className="text-muted-foreground">
{stats.path_hash_width_24h.triple_byte} (
{formatPercent(stats.path_hash_width_24h.triple_byte_pct)})
</span>
</div>
</div>
</div>
<Separator />
{/* Activity */}
<div>
<h4 className="text-sm font-medium mb-2">Activity</h4>
+26
View File
@@ -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(
+9
View File
@@ -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;
};
}
+53
View File
@@ -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)