Add visualizer

Merge in Visualizer
This commit is contained in:
Jack Kingsman
2026-01-19 22:39:51 -08:00
committed by GitHub
16 changed files with 2249 additions and 584 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -13,8 +13,8 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script type="module" crossorigin src="/assets/index-D6IU3K0A.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CcK6B7WY.css">
<script type="module" crossorigin src="/assets/index-CQVFPi-8.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bg6UtK9Z.css">
</head>
<body>
<div id="root"></div>
+55 -2
View File
@@ -1,13 +1,14 @@
{
"name": "remoteterm-meshcore-frontend",
"version": "1.2.0",
"version": "1.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "remoteterm-meshcore-frontend",
"version": "1.2.0",
"version": "1.4.1",
"dependencies": {
"@michaelhart/meshcore-decoder": "^0.2.7",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
@@ -16,6 +17,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.562.0",
"meshcore-hashtag-cracker": "^1.6.0",
@@ -31,6 +33,7 @@
"@eslint/js": "^9.17.0",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0",
"@types/d3-force": "^3.0.10",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.0.3",
"@types/react": "^18.3.12",
@@ -687,6 +690,8 @@
},
"node_modules/@michaelhart/meshcore-decoder": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@michaelhart/meshcore-decoder/-/meshcore-decoder-0.2.7.tgz",
"integrity": "sha512-a3zNbqeACibYy7XlMx6F2fMfg8FT+mP7lcYCmr8EvQk2MvBw7Qs7+bV2DAnwTf+51IRZqFEPCq55GB0scsw1WA==",
"license": "MIT",
"dependencies": {
"@noble/ed25519": "^2.3.0",
@@ -1472,6 +1477,13 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"dev": true,
@@ -2387,6 +2399,47 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/data-urls": {
"version": "5.0.0",
"dev": true,
+3
View File
@@ -15,6 +15,7 @@
"format:check": "prettier --check src/"
},
"dependencies": {
"@michaelhart/meshcore-decoder": "^0.2.7",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
@@ -23,6 +24,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.562.0",
"meshcore-hashtag-cracker": "^1.6.0",
@@ -38,6 +40,7 @@
"@eslint/js": "^9.17.0",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0",
"@types/d3-force": "^3.0.10",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.0.3",
"@types/react": "^18.3.12",
+6
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,8 @@ export function App() {
<MapView contacts={contacts} focusedKey={activeConversation.mapFocusKey} />
</div>
</>
) : activeConversation.type === 'visualizer' ? (
<VisualizerView packets={rawPackets} contacts={contacts} 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,77 @@
import { useState } from 'react';
import type { Contact, RawPacket, RadioConfig } from '../types';
import { PacketVisualizer } from './PacketVisualizer';
import { RawPacketList } from './RawPacketList';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { cn } from '@/lib/utils';
interface VisualizerViewProps {
packets: RawPacket[];
contacts: Contact[];
config: RadioConfig | null;
}
export function VisualizerView({ packets, contacts, 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>
</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} 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}
config={config}
fullScreen={fullScreen}
onFullScreenChange={setFullScreen}
/>
</div>
{/* Packet feed panel - hidden when full screen */}
<div
className={cn(
'overflow-hidden transition-all duration-200',
fullScreen ? 'w-0' : 'w-[31rem] lg:w-[38rem]'
)}
>
<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;