Draft 1 of the homebaked visualizer

This commit is contained in:
Jack Kingsman
2026-01-19 16:10:07 -08:00
parent d6f6913ef2
commit 52319a8863
16 changed files with 2556 additions and 584 deletions
+11
View File
@@ -15,6 +15,7 @@ import { NewMessageModal } from './components/NewMessageModal';
import { SettingsModal } from './components/SettingsModal';
import { RawPacketList } from './components/RawPacketList';
import { MapView } from './components/MapView';
import { VisualizerView } from './components/VisualizerView';
import { CrackerPanel } from './components/CrackerPanel';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './components/ui/sheet';
import { Toaster, toast } from './components/ui/sonner';
@@ -370,6 +371,9 @@ export function App() {
mapFocusKey: hashConv.mapFocusKey,
};
}
if (hashConv.type === 'visualizer') {
return { type: 'visualizer', id: 'visualizer', name: 'Mesh Visualizer' };
}
if (hashConv.type === 'channel') {
const channel = channels.find(
(c) => c.name === hashConv.name || c.name === `#${hashConv.name}`
@@ -716,6 +720,13 @@ export function App() {
<MapView contacts={contacts} focusedKey={activeConversation.mapFocusKey} />
</div>
</>
) : activeConversation.type === 'visualizer' ? (
<VisualizerView
packets={rawPackets}
contacts={contacts}
channels={channels}
config={config}
/>
) : activeConversation.type === 'raw' ? (
<>
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg">
File diff suppressed because it is too large Load Diff
+191 -33
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useMemo } from 'react';
import { MeshCoreDecoder, PayloadType, Utils } from '@michaelhart/meshcore-decoder';
import type { RawPacket } from '../types';
interface RawPacketListProps {
@@ -10,30 +11,6 @@ function formatTime(timestamp: number): string {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function formatPayloadType(type: string): string {
// Convert SNAKE_CASE to Title Case
return type
.split('_')
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
.join(' ');
}
function getDecryptedLabel(packet: RawPacket): string {
if (!packet.decrypted || !packet.decrypted_info) {
return formatPayloadType(packet.payload_type);
}
const info = packet.decrypted_info;
if (packet.payload_type === 'GROUP_TEXT' && info.channel_name) {
return `GroupText to ${info.channel_name}`;
}
if (packet.payload_type === 'TEXT_MESSAGE' && info.sender) {
return `TextMessage from ${info.sender}`;
}
return formatPayloadType(packet.payload_type);
}
function formatSignalInfo(packet: RawPacket): string {
const parts: string[] = [];
if (packet.snr !== null && packet.snr !== undefined) {
@@ -45,9 +22,170 @@ function formatSignalInfo(packet: RawPacket): string {
return parts.join(' | ');
}
// Decrypted info from the packet (validated by backend)
interface DecryptedInfo {
channel_name: string | null;
sender: string | null;
}
// Decode a packet and generate a human-readable summary
// Uses backend's decrypted_info when available (validated), falls back to decoder
function decodePacketSummary(
hexData: string,
decryptedInfo: DecryptedInfo | null
): {
summary: string;
routeType: string;
details?: string;
} {
try {
const decoded = MeshCoreDecoder.decode(hexData);
if (!decoded.isValid) {
return { summary: 'Invalid packet', routeType: 'Unknown' };
}
const routeType = Utils.getRouteTypeName(decoded.routeType);
const payloadTypeName = Utils.getPayloadTypeName(decoded.payloadType);
// Build path string if available
const pathStr = decoded.path && decoded.path.length > 0 ? ` via ${decoded.path.join('-')}` : '';
// Generate summary based on payload type
let summary = payloadTypeName;
let details: string | undefined;
switch (decoded.payloadType) {
case PayloadType.TextMessage: {
const payload = decoded.payload.decoded as {
destinationHash?: string;
sourceHash?: string;
} | null;
if (payload?.sourceHash && payload?.destinationHash) {
summary = `DM from ${payload.sourceHash} to ${payload.destinationHash}${pathStr}`;
} else {
summary = `DM${pathStr}`;
}
break;
}
case PayloadType.GroupText: {
const payload = decoded.payload.decoded as {
channelHash?: string;
} | null;
// Use backend's validated decrypted_info when available
if (decryptedInfo?.channel_name) {
if (decryptedInfo.sender) {
summary = `GT from ${decryptedInfo.sender} in ${decryptedInfo.channel_name}${pathStr}`;
} else {
summary = `GT in ${decryptedInfo.channel_name}${pathStr}`;
}
} else if (payload?.channelHash) {
// Fallback to showing channel hash when not decrypted
summary = `GT ch:${payload.channelHash}${pathStr}`;
} else {
summary = `GroupText${pathStr}`;
}
break;
}
case PayloadType.Advert: {
const payload = decoded.payload.decoded as {
publicKey?: string;
appData?: { name?: string; deviceRole?: number };
} | null;
if (payload?.appData?.name) {
const role =
payload.appData.deviceRole !== undefined
? Utils.getDeviceRoleName(payload.appData.deviceRole)
: '';
summary = `Advert: ${payload.appData.name}${role ? ` (${role})` : ''}${pathStr}`;
} else if (payload?.publicKey) {
summary = `Advert: ${payload.publicKey.slice(0, 8)}...${pathStr}`;
} else {
summary = `Advert${pathStr}`;
}
break;
}
case PayloadType.Ack: {
summary = `ACK${pathStr}`;
break;
}
case PayloadType.Request: {
summary = `Request${pathStr}`;
break;
}
case PayloadType.Response: {
summary = `Response${pathStr}`;
break;
}
case PayloadType.Trace: {
summary = `Trace${pathStr}`;
break;
}
case PayloadType.Path: {
summary = `Path${pathStr}`;
break;
}
default:
summary = `${payloadTypeName}${pathStr}`;
}
return { summary, routeType, details };
} catch {
return { summary: 'Decode error', routeType: 'Unknown' };
}
}
// Get route type badge color
function getRouteTypeColor(routeType: string): string {
switch (routeType) {
case 'Flood':
return 'bg-blue-500/20 text-blue-400';
case 'Direct':
return 'bg-green-500/20 text-green-400';
case 'Transport Flood':
return 'bg-purple-500/20 text-purple-400';
case 'Transport Direct':
return 'bg-orange-500/20 text-orange-400';
default:
return 'bg-gray-500/20 text-gray-400';
}
}
// Get short route type label
function getRouteTypeLabel(routeType: string): string {
switch (routeType) {
case 'Flood':
return 'F';
case 'Direct':
return 'D';
case 'Transport Flood':
return 'TF';
case 'Transport Direct':
return 'TD';
default:
return '?';
}
}
export function RawPacketList({ packets }: RawPacketListProps) {
const listRef = useRef<HTMLDivElement>(null);
// Decode all packets (memoized to avoid re-decoding on every render)
const decodedPackets = useMemo(() => {
return packets.map((packet) => ({
packet,
decoded: decodePacketSummary(packet.data, packet.decrypted_info),
}));
}, [packets]);
useEffect(() => {
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
@@ -63,24 +201,44 @@ export function RawPacketList({ packets }: RawPacketListProps) {
}
// Sort packets by timestamp ascending (oldest first)
const sortedPackets = [...packets].sort((a, b) => a.timestamp - b.timestamp);
const sortedPackets = [...decodedPackets].sort((a, b) => a.packet.timestamp - b.packet.timestamp);
return (
<div className="h-full overflow-y-auto p-4 flex flex-col gap-3" ref={listRef}>
{sortedPackets.map((packet) => (
{sortedPackets.map(({ packet, decoded }) => (
<div key={packet.id} className="py-2 px-3 bg-muted rounded">
<div className={packet.decrypted ? 'text-primary' : 'text-destructive'}>
{!packet.decrypted && <span className="mr-1">🔒</span>}
{getDecryptedLabel(packet)}
{' • '}
{formatTime(packet.timestamp)}
<div className="flex items-center gap-2">
{/* Route type badge */}
<span
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
title={decoded.routeType}
>
{getRouteTypeLabel(decoded.routeType)}
</span>
{/* Encryption status */}
{!packet.decrypted && <span title="Encrypted">🔒</span>}
{/* Summary */}
<span className={packet.decrypted ? 'text-primary' : 'text-foreground'}>
{decoded.summary}
</span>
{/* Time */}
<span className="text-muted-foreground ml-auto text-sm">
{formatTime(packet.timestamp)}
</span>
</div>
{/* Signal info */}
{(packet.snr !== null || packet.rssi !== null) && (
<div className="text-[11px] text-muted-foreground mt-0.5">
{formatSignalInfo(packet)}
</div>
)}
<div className="font-mono text-[11px] break-all text-muted-foreground/70 mt-1">
{/* Raw hex data (always visible) */}
<div className="font-mono text-[10px] break-all text-muted-foreground/70 mt-1 p-1 bg-background/50 rounded">
{packet.data.toUpperCase()}
</div>
</div>
+21 -1
View File
@@ -68,7 +68,7 @@ export function Sidebar({
onSelectConversation(conversation);
};
const isActive = (type: 'contact' | 'channel' | 'raw' | 'map', id: string) =>
const isActive = (type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer', id: string) =>
activeConversation?.type === type && activeConversation?.id === id;
// Get unread count for a conversation
@@ -288,6 +288,26 @@ export function Sidebar({
</div>
)}
{/* Mesh Visualizer */}
{!query && (
<div
className={cn(
'px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent',
isActive('visualizer', 'visualizer') && 'bg-accent border-l-primary'
)}
onClick={() =>
handleSelectConversation({
type: 'visualizer',
id: 'visualizer',
name: 'Mesh Visualizer',
})
}
>
<span className="text-muted-foreground text-xs"></span>
<span className="flex-1 truncate">Mesh Visualizer</span>
</div>
)}
{/* Cracker Toggle */}
{!query && (
<div
@@ -0,0 +1,91 @@
import { useState } from 'react';
import type { Contact, RawPacket, Channel, RadioConfig } from '../types';
import { PacketVisualizer } from './PacketVisualizer';
import { RawPacketList } from './RawPacketList';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Checkbox } from './ui/checkbox';
import { cn } from '@/lib/utils';
interface VisualizerViewProps {
packets: RawPacket[];
contacts: Contact[];
channels: Channel[];
config: RadioConfig | null;
}
export function VisualizerView({ packets, contacts, channels, config }: VisualizerViewProps) {
const [fullScreen, setFullScreen] = useState(false);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg">
<span>Mesh Visualizer</span>
{/* Full screen toggle - only show on larger screens */}
<label className="hidden md:flex items-center gap-2 text-sm font-normal cursor-pointer">
<Checkbox
checked={fullScreen}
onCheckedChange={(checked) => setFullScreen(checked === true)}
/>
<span>Full screen</span>
</label>
</div>
{/* Mobile: Tabbed interface */}
<div className="flex-1 overflow-hidden md:hidden">
<Tabs defaultValue="visualizer" className="h-full flex flex-col">
<TabsList className="mx-4 mt-2 grid grid-cols-2">
<TabsTrigger value="visualizer">Visualizer</TabsTrigger>
<TabsTrigger value="packets">Packet Feed</TabsTrigger>
</TabsList>
<TabsContent value="visualizer" className="flex-1 m-0 overflow-hidden">
<PacketVisualizer
packets={packets}
contacts={contacts}
channels={channels}
config={config}
/>
</TabsContent>
<TabsContent value="packets" className="flex-1 m-0 overflow-hidden">
<RawPacketList packets={packets} />
</TabsContent>
</Tabs>
</div>
{/* Desktop: Split screen (or full screen if toggled) */}
<div className="hidden md:flex flex-1 overflow-hidden">
{/* Visualizer panel */}
<div
className={cn(
'overflow-hidden transition-all duration-200',
fullScreen ? 'flex-1' : 'flex-1 border-r border-border'
)}
>
<PacketVisualizer
packets={packets}
contacts={contacts}
channels={channels}
config={config}
/>
</div>
{/* Packet feed panel - hidden when full screen */}
<div
className={cn(
'overflow-hidden transition-all duration-200',
fullScreen ? 'w-0' : 'w-[45rem] lg:w-[54rem]'
)}
>
<div className="h-full flex flex-col">
<div className="px-3 py-2 border-b border-border text-sm font-medium text-muted-foreground">
Packet Feed
</div>
<div className="flex-1 overflow-hidden">
<RawPacketList packets={packets} />
</div>
</div>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -99,7 +99,7 @@ export interface Message {
acked: number;
}
export type ConversationType = 'contact' | 'channel' | 'raw' | 'map';
export type ConversationType = 'contact' | 'channel' | 'raw' | 'map' | 'visualizer';
export interface Conversation {
type: ConversationType;
+6 -1
View File
@@ -1,7 +1,7 @@
import type { Conversation } from '../types';
export interface ParsedHashConversation {
type: 'channel' | 'contact' | 'raw' | 'map';
type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer';
name: string;
/** For map view: public key prefix to focus on */
mapFocusKey?: string;
@@ -20,6 +20,10 @@ export function parseHashConversation(): ParsedHashConversation | null {
return { type: 'map', name: 'map' };
}
if (hash === 'visualizer') {
return { type: 'visualizer', name: 'visualizer' };
}
// Check for map with focus: #map/focus/{pubkey_prefix}
if (hash.startsWith('map/focus/')) {
const focusKey = hash.slice('map/focus/'.length);
@@ -54,6 +58,7 @@ export function getConversationHash(conv: Conversation | null): string {
if (!conv) return '';
if (conv.type === 'raw') return '#raw';
if (conv.type === 'map') return '#map';
if (conv.type === 'visualizer') return '#visualizer';
// Strip leading # from channel names for cleaner URLs
const name =
conv.type === 'channel' && conv.name.startsWith('#') ? conv.name.slice(1) : conv.name;