Add hop width to channel info. Closes #153.

This commit is contained in:
Jack Kingsman
2026-04-03 13:57:04 -07:00
parent be2b2604df
commit 55081d4a2d
6 changed files with 166 additions and 4 deletions

View File

@@ -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):

View File

@@ -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

View File

@@ -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"],
)

View File

@@ -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({
</div>
)}
{/* Hop Byte Widths (24h) */}
{detail && detail.path_hash_width_24h.total_packets > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Hop Byte Widths (24h)</SectionLabel>
<HopWidthChart stats={detail.path_hash_width_24h} />
</div>
)}
{/* Top Senders 24h */}
{detail && detail.top_senders_24h.length > 0 && (
<div className="px-5 py-3">
@@ -226,3 +235,80 @@ function InfoItem({ label, value }: { label: string; value: string }) {
</div>
);
}
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 (
<div className="flex items-center gap-3">
<div className="flex-shrink-0" style={{ width: 90, height: 90 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
dataKey="value"
cx="50%"
cy="50%"
innerRadius={22}
outerRadius={40}
strokeWidth={1.5}
stroke="hsl(var(--background))"
>
{data.map((d) => (
<Cell key={d.name} fill={d.color} />
))}
</Pie>
<RechartsTooltip
{...TOOLTIP_STYLE}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(value: any, name: any) => {
const v = typeof value === 'number' ? value : Number(value);
return [`${v.toLocaleString()} pkt${v !== 1 ? 's' : ''}`, name];
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex-1 space-y-1">
{data.map((d) => (
<div key={d.name} className="flex items-center gap-1.5">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: d.color }}
/>
<span className="text-[11px] text-muted-foreground flex-1">{d.name}</span>
<span className="text-[11px] font-medium tabular-nums">{d.value.toLocaleString()}</span>
</div>
))}
<p className="text-[10px] text-muted-foreground pt-0.5">
{stats.total_packets.toLocaleString()} total
</p>
</div>
</div>
);
}

View File

@@ -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,
},
};
}

View File

@@ -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 */