diff --git a/app/models.py b/app/models.py index f727008..1c7994f 100644 --- a/app/models.py +++ b/app/models.py @@ -355,6 +355,18 @@ class ChannelTopSender(BaseModel): message_count: int +class PathHashWidthStats(BaseModel): + """Hop byte width distribution for parsed raw packets.""" + + total_packets: int = 0 + single_byte: int = 0 + double_byte: int = 0 + triple_byte: int = 0 + single_byte_pct: float = 0.0 + double_byte_pct: float = 0.0 + triple_byte_pct: float = 0.0 + + class ChannelDetail(BaseModel): """Comprehensive channel profile data.""" @@ -363,6 +375,7 @@ class ChannelDetail(BaseModel): first_message_at: int | None = None unique_sender_count: int = 0 top_senders_24h: list[ChannelTopSender] = Field(default_factory=list) + path_hash_width_24h: PathHashWidthStats = Field(default_factory=PathHashWidthStats) class MessagePath(BaseModel): diff --git a/app/repository/messages.py b/app/repository/messages.py index 12adc96..e0aafb8 100644 --- a/app/repository/messages.py +++ b/app/repository/messages.py @@ -786,12 +786,14 @@ class MessageRepository: @staticmethod async def get_channel_stats(conversation_key: str) -> dict: - """Get channel message statistics: time-windowed counts, first message, unique senders, top senders. + """Get channel message statistics: time-windowed counts, first message, unique senders, top senders, path hash widths. - Returns a dict with message_counts, first_message_at, unique_sender_count, top_senders_24h. + Returns a dict with message_counts, first_message_at, unique_sender_count, top_senders_24h, path_hash_width_24h. """ import time as _time + from app.path_utils import parse_packet_envelope + now = int(_time.time()) t_1h = now - 3600 t_24h = now - 86400 @@ -843,11 +845,51 @@ class MessageRepository: for r in top_rows ] + # Path hash width distribution for last 24h (in-Python parse of raw packet envelopes) + single_byte = 0 + double_byte = 0 + triple_byte = 0 + cursor3 = await db.conn.execute( + """ + SELECT rp.data FROM raw_packets rp + JOIN messages m ON rp.message_id = m.id + WHERE m.type = 'CHAN' AND m.conversation_key = ? + AND rp.timestamp >= ? + """, + (conversation_key, t_24h), + ) + while True: + batch = await cursor3.fetchmany(500) + if not batch: + break + for pkt_row in batch: + envelope = parse_packet_envelope(bytes(pkt_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 + + hash_total = single_byte + double_byte + triple_byte + path_hash_width_24h = { + "total_packets": hash_total, + "single_byte": single_byte, + "double_byte": double_byte, + "triple_byte": triple_byte, + "single_byte_pct": (single_byte / hash_total * 100) if hash_total else 0.0, + "double_byte_pct": (double_byte / hash_total * 100) if hash_total else 0.0, + "triple_byte_pct": (triple_byte / hash_total * 100) if hash_total else 0.0, + } + return { "message_counts": message_counts, "first_message_at": row["first_message_at"], "unique_sender_count": row["unique_sender_count"] or 0, "top_senders_24h": top_senders, + "path_hash_width_24h": path_hash_width_24h, } @staticmethod diff --git a/app/routers/channels.py b/app/routers/channels.py index 2f02bb2..3091620 100644 --- a/app/routers/channels.py +++ b/app/routers/channels.py @@ -215,6 +215,7 @@ async def get_channel_detail(key: str) -> ChannelDetail: first_message_at=stats["first_message_at"], unique_sender_count=stats["unique_sender_count"], top_senders_24h=[ChannelTopSender(**s) for s in stats["top_senders_24h"]], + path_hash_width_24h=stats["path_hash_width_24h"], ) diff --git a/frontend/src/components/ChannelInfoPane.tsx b/frontend/src/components/ChannelInfoPane.tsx index 7c284bd..d41ac31 100644 --- a/frontend/src/components/ChannelInfoPane.tsx +++ b/frontend/src/components/ChannelInfoPane.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip } from 'recharts'; import { Star } from 'lucide-react'; import { api } from '../api'; import { formatTime } from '../utils/messageParser'; @@ -6,7 +7,7 @@ import { isFavorite } from '../utils/favorites'; import { handleKeyboardActivate } from '../utils/a11y'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; import { toast } from './ui/sonner'; -import type { Channel, ChannelDetail, Favorite } from '../types'; +import type { Channel, ChannelDetail, Favorite, PathHashWidthStats } from '../types'; interface ChannelInfoPaneProps { channelKey: string | null; @@ -179,6 +180,14 @@ export function ChannelInfoPane({ )} + {/* Hop Byte Widths (24h) */} + {detail && detail.path_hash_width_24h.total_packets > 0 && ( +
+ {stats.total_packets.toLocaleString()} total +
+