From 55081d4a2d826ee64bab7641f2dd06993dc5f661 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Fri, 3 Apr 2026 13:57:04 -0700 Subject: [PATCH] Add hop width to channel info. Closes #153. --- app/models.py | 13 +++ app/repository/messages.py | 46 +++++++++- app/routers/channels.py | 1 + frontend/src/components/ChannelInfoPane.tsx | 90 ++++++++++++++++++- .../test/channelInfoKeyVisibility.test.tsx | 9 ++ frontend/src/types.ts | 11 +++ 6 files changed, 166 insertions(+), 4 deletions(-) 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 && ( +
+ Hop Byte Widths (24h) + +
+ )} + {/* Top Senders 24h */} {detail && detail.top_senders_24h.length > 0 && (
@@ -226,3 +235,80 @@ function InfoItem({ label, value }: { label: string; value: string }) {
); } + +const HOP_WIDTH_SEGMENTS = [ + { key: 'single_byte', label: '1-byte', color: '#22c55e' }, + { key: 'double_byte', label: '2-byte', color: '#0ea5e9' }, + { key: 'triple_byte', label: '3-byte', color: '#8b5cf6' }, +] as const; + +const TOOLTIP_STYLE = { + contentStyle: { + backgroundColor: 'hsl(var(--popover))', + border: '1px solid hsl(var(--border))', + borderRadius: '6px', + fontSize: '11px', + color: 'hsl(var(--popover-foreground))', + }, +} as const; + +function HopWidthChart({ stats }: { stats: PathHashWidthStats }) { + const data = useMemo( + () => + HOP_WIDTH_SEGMENTS.map(({ key, label, color }) => ({ + name: label, + value: stats[key] as number, + color, + })).filter((d) => d.value > 0), + [stats] + ); + + return ( +
+
+ + + + {data.map((d) => ( + + ))} + + { + const v = typeof value === 'number' ? value : Number(value); + return [`${v.toLocaleString()} pkt${v !== 1 ? 's' : ''}`, name]; + }} + /> + + +
+ +
+ {data.map((d) => ( +
+ + {d.name} + {d.value.toLocaleString()} +
+ ))} +

+ {stats.total_packets.toLocaleString()} total +

+
+
+ ); +} diff --git a/frontend/src/test/channelInfoKeyVisibility.test.tsx b/frontend/src/test/channelInfoKeyVisibility.test.tsx index 2141756..a0cd5db 100644 --- a/frontend/src/test/channelInfoKeyVisibility.test.tsx +++ b/frontend/src/test/channelInfoKeyVisibility.test.tsx @@ -25,6 +25,15 @@ function makeDetail(channel: Channel): ChannelDetail { first_message_at: null, unique_sender_count: 0, top_senders_24h: [], + path_hash_width_24h: { + total_packets: 0, + single_byte: 0, + double_byte: 0, + triple_byte: 0, + single_byte_pct: 0, + double_byte_pct: 0, + triple_byte_pct: 0, + }, }; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f57b2de..6a93b1c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -228,12 +228,23 @@ export interface BulkCreateHashtagChannelsResult { message: string; } +export interface PathHashWidthStats { + total_packets: number; + single_byte: number; + double_byte: number; + triple_byte: number; + single_byte_pct: number; + double_byte_pct: number; + triple_byte_pct: number; +} + export interface ChannelDetail { channel: Channel; message_counts: ChannelMessageCounts; first_message_at: number | null; unique_sender_count: number; top_senders_24h: ChannelTopSender[]; + path_hash_width_24h: PathHashWidthStats; } /** A single path that a message took to reach us */