mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-31 05:53:04 +02:00
Add statistics
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user