mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-30 21:43:14 +02:00
5e5ab7db4d
Typing in a conversation got progressively slower as its history grew.
The cause was not the input: every packet the node overhears was stored
in App state, so each one re-rendered the whole tree, including the
un-memoized MessageList. That cost scales with history length, and on a
busy mesh it saturated the main thread so keystrokes queued behind it.
Measured in Chromium with the real components, cost per overheard packet:
history before after
50 7.3ms ~0ms
500 48.5ms ~0ms
1000 97.7ms ~0ms
2000 228.4ms ~0ms
The packet stream now lives in a small external store that views subscribe
to individually, so only the map, visualizer, raw feed, and cracker re-render
when a packet arrives, so the chat view is no longer along for the ride.
This also retires useRawPacketStatsSession, whose session state was App
state for the same reason.
974 lines
33 KiB
TypeScript
974 lines
33 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
|
|
import {
|
|
BarChart,
|
|
Bar,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip as RechartsTooltip,
|
|
ResponsiveContainer,
|
|
Cell,
|
|
} from 'recharts';
|
|
|
|
import { MeshCoreDecoder, Utils } from '@michaelhart/meshcore-decoder';
|
|
|
|
import { RawPacketList } from './RawPacketList';
|
|
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
|
|
import { Button } from './ui/button';
|
|
import type { Channel, Contact, RawPacket } from '../types';
|
|
import {
|
|
KNOWN_PAYLOAD_TYPES,
|
|
RAW_PACKET_STATS_WINDOWS,
|
|
buildRawPacketStatsSnapshot,
|
|
type NeighborStat,
|
|
type PacketTimelineBin,
|
|
type RankedPacketStat,
|
|
type RawPacketStatsSessionState,
|
|
type RawPacketStatsWindow,
|
|
} from '../utils/rawPacketStats';
|
|
import { createDecoderOptions } from '../utils/rawPacketInspector';
|
|
import { useRawPacketStatsSession, useRawPackets } from '../stores/rawPacketStore';
|
|
import { getContactDisplayName } from '../utils/pubkey';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
const TIMELINE_FILL_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
|
|
|
|
/**
|
|
* Build a stable name→color mapping so the same type always gets the same
|
|
* color regardless of sort order or appearance order.
|
|
*/
|
|
function buildColorMap(names: readonly string[]): Map<string, string> {
|
|
const map = new Map<string, string>();
|
|
for (let i = 0; i < names.length; i++) {
|
|
map.set(names[i], TIMELINE_FILL_COLORS[i % TIMELINE_FILL_COLORS.length]);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function colorForIndex(index: number, colorMap?: Map<string, string>, name?: string): string {
|
|
if (colorMap && name && colorMap.has(name)) {
|
|
return colorMap.get(name)!;
|
|
}
|
|
return TIMELINE_FILL_COLORS[index % TIMELINE_FILL_COLORS.length];
|
|
}
|
|
|
|
const KNOWN_PAYLOAD_TYPE_SET = new Set<string>(KNOWN_PAYLOAD_TYPES);
|
|
const PAYLOAD_TYPE_COLOR_MAP = buildColorMap(KNOWN_PAYLOAD_TYPES);
|
|
|
|
function getPacketTypeName(
|
|
packet: RawPacket,
|
|
decoderOptions?: ReturnType<typeof createDecoderOptions>
|
|
): string {
|
|
try {
|
|
const decoded = MeshCoreDecoder.decode(packet.data, decoderOptions);
|
|
if (!decoded.isValid) return 'Unknown';
|
|
const name = Utils.getPayloadTypeName(decoded.payloadType);
|
|
return KNOWN_PAYLOAD_TYPE_SET.has(name) ? name : 'Unknown';
|
|
} catch {
|
|
return 'Unknown';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalize a raw-hex filter query. Lowercases, strips whitespace, `:`
|
|
* separators, and a leading `0x` so a pasted key prefix like `A1B2C3`,
|
|
* `a1:b2:c3`, or `0xa1b2` all match. Returns `invalid: true` when non-hex
|
|
* characters remain after cleaning so the UI can hint instead of silently
|
|
* showing zero results. Matches against `RawPacket.data` (stored lowercase hex).
|
|
*/
|
|
function normalizeHexQuery(raw: string): { query: string; invalid: boolean } {
|
|
const cleaned = raw
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/^0x/, '')
|
|
.replace(/[\s:]+/g, '');
|
|
if (cleaned === '') return { query: '', invalid: false };
|
|
return { query: cleaned, invalid: !/^[0-9a-f]+$/.test(cleaned) };
|
|
}
|
|
|
|
interface FeedFilterControlsProps {
|
|
className?: string;
|
|
allTypesEnabled: boolean;
|
|
enabledTypes: Set<string>;
|
|
onToggleAll: () => void;
|
|
onToggleType: (type: string) => void;
|
|
onOnly: (type: string) => void;
|
|
autoScroll: boolean;
|
|
onAutoScrollChange: (checked: boolean) => void;
|
|
hexFilter: string;
|
|
onHexFilterChange: (value: string) => void;
|
|
hexInvalid: boolean;
|
|
matchCount: number;
|
|
totalCount: number;
|
|
}
|
|
|
|
/**
|
|
* The feed filter bar: hex substring filter, payload-type checkboxes, and the
|
|
* autoscroll toggle. Rendered twice (mobile + desktop) with only the display
|
|
* classes differing, so the control set lives here to stay in sync. Display is
|
|
* driven entirely by `className` (no base `flex`) to avoid a Tailwind
|
|
* `flex`/`hidden` conflict.
|
|
*/
|
|
function FeedFilterControls({
|
|
className,
|
|
allTypesEnabled,
|
|
enabledTypes,
|
|
onToggleAll,
|
|
onToggleType,
|
|
onOnly,
|
|
autoScroll,
|
|
onAutoScrollChange,
|
|
hexFilter,
|
|
onHexFilterChange,
|
|
hexInvalid,
|
|
matchCount,
|
|
totalCount,
|
|
}: FeedFilterControlsProps) {
|
|
return (
|
|
<div className={cn('mt-1.5 flex-wrap items-center gap-x-3 gap-y-1', className)}>
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
value={hexFilter}
|
|
onChange={(event) => onHexFilterChange(event.target.value)}
|
|
placeholder="Filter by hex…"
|
|
aria-label="Filter loaded packets by hex substring"
|
|
className="w-44 rounded border border-input bg-background px-2 py-0.5 pr-6 text-xs"
|
|
/>
|
|
{hexFilter !== '' && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onHexFilterChange('')}
|
|
aria-label="Clear hex filter"
|
|
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{hexFilter.trim() !== '' &&
|
|
(hexInvalid ? (
|
|
<span className="text-[0.6875rem] text-warning">Enter hex only</span>
|
|
) : (
|
|
<span className="text-[0.6875rem] text-muted-foreground tabular-nums">
|
|
{matchCount.toLocaleString()} / {totalCount.toLocaleString()}
|
|
</span>
|
|
))}
|
|
<label className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={allTypesEnabled}
|
|
onChange={onToggleAll}
|
|
className="rounded"
|
|
/>
|
|
All
|
|
</label>
|
|
{KNOWN_PAYLOAD_TYPES.map((type) => (
|
|
<span key={type} className="inline-flex items-center gap-1 text-xs">
|
|
<label className="flex items-center gap-1 text-foreground cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={enabledTypes.has(type)}
|
|
onChange={() => onToggleType(type)}
|
|
className="rounded"
|
|
/>
|
|
{type}
|
|
</label>
|
|
<button
|
|
type="button"
|
|
className="text-[0.625rem] text-muted-foreground hover:text-primary transition-colors"
|
|
onClick={() => onOnly(type)}
|
|
>
|
|
(only)
|
|
</button>
|
|
</span>
|
|
))}
|
|
<label className="ml-auto flex items-center gap-1 text-xs text-foreground cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={autoScroll}
|
|
onChange={(event) => onAutoScrollChange(event.target.checked)}
|
|
className="rounded"
|
|
/>
|
|
Autoscroll
|
|
</label>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface RawPacketFeedViewProps {
|
|
contacts: Contact[];
|
|
channels: Channel[];
|
|
}
|
|
|
|
const TOOLTIP_STYLE = {
|
|
contentStyle: {
|
|
backgroundColor: 'hsl(var(--popover))',
|
|
border: '1px solid hsl(var(--border))',
|
|
borderRadius: '6px',
|
|
fontSize: '11px',
|
|
color: 'hsl(var(--popover-foreground))',
|
|
},
|
|
itemStyle: { color: 'hsl(var(--popover-foreground))' },
|
|
labelStyle: { color: 'hsl(var(--muted-foreground))' },
|
|
} as const;
|
|
|
|
const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
|
|
'1m': '1 min',
|
|
'5m': '5 min',
|
|
'10m': '10 min',
|
|
'30m': '30 min',
|
|
session: 'Session',
|
|
};
|
|
|
|
function formatTimestamp(timestampMs: number): string {
|
|
return new Date(timestampMs).toLocaleString([], {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
});
|
|
}
|
|
|
|
function formatDuration(seconds: number): string {
|
|
if (seconds < 60) {
|
|
return `${Math.max(1, Math.round(seconds))} sec`;
|
|
}
|
|
if (seconds < 3600) {
|
|
const minutes = Math.floor(seconds / 60);
|
|
const remainder = Math.round(seconds % 60);
|
|
return remainder > 0 ? `${minutes}m ${remainder}s` : `${minutes}m`;
|
|
}
|
|
const hours = Math.floor(seconds / 3600);
|
|
const minutes = Math.round((seconds % 3600) / 60);
|
|
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
|
}
|
|
|
|
function formatRate(value: number): string {
|
|
if (value >= 100) return value.toFixed(0);
|
|
if (value >= 10) return value.toFixed(1);
|
|
return value.toFixed(2);
|
|
}
|
|
|
|
function formatPercent(value: number): string {
|
|
return `${Math.round(value * 100)}%`;
|
|
}
|
|
|
|
function formatRssi(value: number | null): string {
|
|
return value === null ? '-' : `${Math.round(value)} dBm`;
|
|
}
|
|
|
|
function normalizeResolvableSourceKey(sourceKey: string): string {
|
|
return sourceKey.startsWith('hash1:') ? sourceKey.slice(6) : sourceKey;
|
|
}
|
|
|
|
function resolveContact(sourceKey: string | null, contacts: Contact[]): Contact | null {
|
|
if (!sourceKey || sourceKey.startsWith('name:')) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedSourceKey = normalizeResolvableSourceKey(sourceKey).toLowerCase();
|
|
const matches = contacts.filter((contact) =>
|
|
contact.public_key.toLowerCase().startsWith(normalizedSourceKey)
|
|
);
|
|
if (matches.length !== 1) {
|
|
return null;
|
|
}
|
|
|
|
return matches[0];
|
|
}
|
|
|
|
function resolveContactLabel(sourceKey: string | null, contacts: Contact[]): string | null {
|
|
const contact = resolveContact(sourceKey, contacts);
|
|
if (!contact) {
|
|
return null;
|
|
}
|
|
return getContactDisplayName(contact.name, contact.public_key, contact.last_advert);
|
|
}
|
|
|
|
function resolveNeighbor(item: NeighborStat, contacts: Contact[]): NeighborStat {
|
|
return {
|
|
...item,
|
|
label: resolveContactLabel(item.key, contacts) ?? item.label,
|
|
};
|
|
}
|
|
|
|
function mergeResolvedNeighbors(items: NeighborStat[], contacts: Contact[]): NeighborStat[] {
|
|
const merged = new Map<string, NeighborStat>();
|
|
|
|
for (const item of items) {
|
|
const contact = resolveContact(item.key, contacts);
|
|
const canonicalKey = contact?.public_key ?? item.key;
|
|
const resolvedLabel =
|
|
contact != null
|
|
? getContactDisplayName(contact.name, contact.public_key, contact.last_advert)
|
|
: item.label;
|
|
const existing = merged.get(canonicalKey);
|
|
|
|
if (!existing) {
|
|
merged.set(canonicalKey, {
|
|
...item,
|
|
key: canonicalKey,
|
|
label: resolvedLabel,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
existing.count += item.count;
|
|
existing.lastSeen = Math.max(existing.lastSeen, item.lastSeen);
|
|
existing.bestRssi =
|
|
existing.bestRssi === null
|
|
? item.bestRssi
|
|
: item.bestRssi === null
|
|
? existing.bestRssi
|
|
: Math.max(existing.bestRssi, item.bestRssi);
|
|
existing.label = resolvedLabel;
|
|
}
|
|
|
|
return Array.from(merged.values());
|
|
}
|
|
|
|
function isNeighborIdentityResolvable(item: NeighborStat, contacts: Contact[]): boolean {
|
|
if (item.key.startsWith('name:')) {
|
|
return true;
|
|
}
|
|
return resolveContact(item.key, contacts) !== null;
|
|
}
|
|
|
|
function formatStrongestNeighborDetail(
|
|
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
|
|
contacts: Contact[]
|
|
): string | undefined {
|
|
const strongestNeighbor = stats.strongestNeighbors[0];
|
|
if (!strongestNeighbor || strongestNeighbor.bestRssi === null) {
|
|
return undefined;
|
|
}
|
|
|
|
const resolvedNeighbor = resolveNeighbor(strongestNeighbor, contacts);
|
|
return `${formatRssi(resolvedNeighbor.bestRssi)} best heard`;
|
|
}
|
|
|
|
function getCoverageMessage(
|
|
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
|
|
session: RawPacketStatsSessionState
|
|
): { tone: 'default' | 'warning'; message: string } {
|
|
if (session.trimmedObservationCount > 0 && stats.window === 'session') {
|
|
return {
|
|
tone: 'warning',
|
|
message: `Detailed session history was trimmed after ${session.totalObservedPackets.toLocaleString()} observations.`,
|
|
};
|
|
}
|
|
|
|
if (!stats.windowFullyCovered) {
|
|
return {
|
|
tone: 'warning',
|
|
message: `This window is only covered for ${formatDuration(stats.coverageSeconds)} of frontend-collected history.`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
tone: 'default',
|
|
message: `Tracking ${session.observations.length.toLocaleString()} detailed observations from this browser session.`,
|
|
};
|
|
}
|
|
|
|
function StatTile({ label, value, detail }: { label: string; value: string; detail?: string }) {
|
|
return (
|
|
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/80 p-3">
|
|
<div className="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground">
|
|
{label}
|
|
</div>
|
|
<div className="mt-1 text-xl font-semibold tabular-nums text-foreground">{value}</div>
|
|
{detail ? <div className="mt-1 text-xs text-muted-foreground">{detail}</div> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RankedBars({
|
|
title,
|
|
items,
|
|
emptyLabel,
|
|
formatter,
|
|
colorMap,
|
|
}: {
|
|
title: string;
|
|
items: RankedPacketStat[];
|
|
emptyLabel: string;
|
|
formatter?: (item: RankedPacketStat) => string;
|
|
colorMap?: Map<string, string>;
|
|
}) {
|
|
const data = items.map((item) => ({
|
|
name: item.label,
|
|
value: item.count,
|
|
detail: formatter
|
|
? formatter(item)
|
|
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`,
|
|
}));
|
|
|
|
return (
|
|
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
|
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
|
{items.length === 0 ? (
|
|
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
|
|
) : (
|
|
<div className="mt-2">
|
|
<ResponsiveContainer width="100%" height={items.length * 28 + 8}>
|
|
<BarChart
|
|
data={data}
|
|
layout="vertical"
|
|
margin={{ top: 0, right: 4, bottom: 0, left: 0 }}
|
|
barCategoryGap="20%"
|
|
>
|
|
<XAxis type="number" hide />
|
|
<YAxis
|
|
type="category"
|
|
dataKey="name"
|
|
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
width={80}
|
|
/>
|
|
<RechartsTooltip
|
|
{...TOOLTIP_STYLE}
|
|
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
formatter={(_v: any, _n: any, props: any) => [props.payload.detail, null]}
|
|
/>
|
|
<Bar dataKey="value" radius={[0, 4, 4, 0]} maxBarSize={16}>
|
|
{data.map((entry, i) => (
|
|
<Cell key={i} fill={colorForIndex(i, colorMap, entry.name)} />
|
|
))}
|
|
</Bar>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function NeighborList({
|
|
title,
|
|
items,
|
|
emptyLabel,
|
|
mode,
|
|
contacts,
|
|
}: {
|
|
title: string;
|
|
items: NeighborStat[];
|
|
emptyLabel: string;
|
|
mode: 'heard' | 'signal' | 'recent';
|
|
contacts: Contact[];
|
|
}) {
|
|
const mergedItems = mergeResolvedNeighbors(items, contacts);
|
|
const sortedItems = [...mergedItems].sort((a, b) => {
|
|
if (mode === 'heard') {
|
|
return b.count - a.count || b.lastSeen - a.lastSeen || a.label.localeCompare(b.label);
|
|
}
|
|
if (mode === 'signal') {
|
|
return (
|
|
(b.bestRssi ?? Number.NEGATIVE_INFINITY) - (a.bestRssi ?? Number.NEGATIVE_INFINITY) ||
|
|
b.count - a.count ||
|
|
a.label.localeCompare(b.label)
|
|
);
|
|
}
|
|
return b.lastSeen - a.lastSeen || b.count - a.count || a.label.localeCompare(b.label);
|
|
});
|
|
|
|
return (
|
|
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
|
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
|
{sortedItems.length === 0 ? (
|
|
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
|
|
) : (
|
|
<div className="mt-3 space-y-2">
|
|
{sortedItems.map((item) => (
|
|
<div
|
|
key={item.key}
|
|
className="flex items-center justify-between gap-3 rounded-md bg-background/70 px-2 py-1.5"
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="truncate text-sm text-foreground">{item.label}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{mode === 'heard'
|
|
? `${item.count.toLocaleString()} packets`
|
|
: mode === 'signal'
|
|
? `${formatRssi(item.bestRssi)} best`
|
|
: `Last seen ${new Date(item.lastSeen * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
|
|
</div>
|
|
{!isNeighborIdentityResolvable(item, contacts) ? (
|
|
<div className="text-[0.6875rem] text-warning">Identity not resolvable</div>
|
|
) : null}
|
|
</div>
|
|
{mode !== 'signal' ? (
|
|
<div className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
|
{mode === 'recent' ? formatRssi(item.bestRssi) : formatRssi(item.bestRssi)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function TimelineChart({
|
|
bins,
|
|
colorMap,
|
|
}: {
|
|
bins: PacketTimelineBin[];
|
|
colorMap: Map<string, string>;
|
|
}) {
|
|
const typeOrder = Array.from(new Set(bins.flatMap((bin) => Object.keys(bin.countsByType)))).slice(
|
|
0,
|
|
TIMELINE_FILL_COLORS.length
|
|
);
|
|
|
|
const data = bins.map((bin) => {
|
|
const entry: Record<string, string | number> = { label: bin.label };
|
|
for (const type of typeOrder) {
|
|
entry[type] = bin.countsByType[type] ?? 0;
|
|
}
|
|
return entry;
|
|
});
|
|
|
|
return (
|
|
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
|
|
<div className="flex flex-wrap justify-end gap-2 text-[0.6875rem] text-muted-foreground">
|
|
{typeOrder.map((type) => (
|
|
<span key={type} className="inline-flex items-center gap-1">
|
|
<span
|
|
className="h-2 w-2 rounded-full"
|
|
style={{ backgroundColor: colorMap.get(type) ?? TIMELINE_FILL_COLORS[0] }}
|
|
/>
|
|
<span>{type}</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="mt-2">
|
|
<ResponsiveContainer width="100%" height={110}>
|
|
<BarChart data={data} margin={{ top: 4, right: 0, bottom: 0, left: -24 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
|
<XAxis
|
|
dataKey="label"
|
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<YAxis
|
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
allowDecimals={false}
|
|
/>
|
|
<RechartsTooltip
|
|
{...TOOLTIP_STYLE}
|
|
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
|
/>
|
|
{typeOrder.map((type, i) => (
|
|
<Bar
|
|
key={type}
|
|
dataKey={type}
|
|
stackId="packets"
|
|
fill={colorMap.get(type) ?? TIMELINE_FILL_COLORS[0]}
|
|
radius={i === typeOrder.length - 1 ? [2, 2, 0, 0] : undefined}
|
|
/>
|
|
))}
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function RawPacketFeedView({ contacts, channels }: RawPacketFeedViewProps) {
|
|
const packets = useRawPackets();
|
|
const rawPacketStatsSession = useRawPacketStatsSession();
|
|
const [statsOpen, setStatsOpen] = useState(() =>
|
|
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
|
? window.matchMedia('(min-width: 768px)').matches
|
|
: false
|
|
);
|
|
const [selectedWindow, setSelectedWindow] = useState<RawPacketStatsWindow>('10m');
|
|
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
|
|
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
|
|
const [analyzeModalOpen, setAnalyzeModalOpen] = useState(false);
|
|
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
|
const [enabledTypes, setEnabledTypes] = useState<Set<string>>(() => new Set(KNOWN_PAYLOAD_TYPES));
|
|
// Autoscroll defaults on; intentionally not persisted across refreshes.
|
|
const [autoScroll, setAutoScroll] = useState(true);
|
|
// Raw-hex substring filter over the in-memory feed buffer (session-only).
|
|
const [hexFilter, setHexFilter] = useState('');
|
|
|
|
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
|
|
|
|
const packetsWithTypes = useMemo(
|
|
() =>
|
|
packets.map((packet) => ({
|
|
packet,
|
|
payloadType: getPacketTypeName(packet, decoderOptions),
|
|
})),
|
|
[packets, decoderOptions]
|
|
);
|
|
|
|
const allTypesEnabled = enabledTypes.size === KNOWN_PAYLOAD_TYPES.length;
|
|
|
|
const { query: hexQuery, invalid: hexInvalid } = useMemo(
|
|
() => normalizeHexQuery(hexFilter),
|
|
[hexFilter]
|
|
);
|
|
|
|
const filteredPackets = useMemo(() => {
|
|
// A non-hex query matches nothing; the input surfaces a hint instead.
|
|
if (hexInvalid) return [];
|
|
// Fast path: no filters active.
|
|
if (allTypesEnabled && hexQuery === '') return packets;
|
|
return packetsWithTypes
|
|
.filter(
|
|
({ packet, payloadType }) =>
|
|
(allTypesEnabled || enabledTypes.has(payloadType)) &&
|
|
(hexQuery === '' || packet.data.toLowerCase().includes(hexQuery))
|
|
)
|
|
.map(({ packet }) => packet);
|
|
}, [packetsWithTypes, enabledTypes, packets, allTypesEnabled, hexQuery, hexInvalid]);
|
|
|
|
const handleToggleAll = () => {
|
|
setEnabledTypes(allTypesEnabled ? new Set() : new Set(KNOWN_PAYLOAD_TYPES));
|
|
};
|
|
|
|
const handleToggleType = (type: string) => {
|
|
setEnabledTypes((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(type)) {
|
|
next.delete(type);
|
|
} else {
|
|
next.add(type);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleOnly = (type: string) => {
|
|
setEnabledTypes(new Set([type]));
|
|
};
|
|
|
|
useEffect(() => {
|
|
const interval = window.setInterval(() => {
|
|
setNowSec(Math.floor(Date.now() / 1000));
|
|
}, 30000);
|
|
return () => window.clearInterval(interval);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setNowSec(Math.floor(Date.now() / 1000));
|
|
}, [packets, rawPacketStatsSession]);
|
|
|
|
const stats = useMemo(
|
|
() => buildRawPacketStatsSnapshot(rawPacketStatsSession, selectedWindow, nowSec),
|
|
[nowSec, rawPacketStatsSession, selectedWindow]
|
|
);
|
|
const coverageMessage = getCoverageMessage(stats, rawPacketStatsSession);
|
|
const strongestNeighbor = useMemo(() => {
|
|
const topNeighbor = stats.strongestNeighbors[0];
|
|
return topNeighbor ? resolveNeighbor(topNeighbor, contacts) : null;
|
|
}, [contacts, stats]);
|
|
|
|
const strongestNeighborDetail = useMemo(
|
|
() => formatStrongestNeighborDetail(stats, contacts),
|
|
[contacts, stats]
|
|
);
|
|
const strongestNeighbors = useMemo(
|
|
() => stats.strongestNeighbors.map((item) => resolveNeighbor(item, contacts)),
|
|
[contacts, stats.strongestNeighbors]
|
|
);
|
|
const mostActiveNeighbors = useMemo(
|
|
() => stats.mostActiveNeighbors.map((item) => resolveNeighbor(item, contacts)),
|
|
[contacts, stats.mostActiveNeighbors]
|
|
);
|
|
const newestNeighbors = useMemo(
|
|
() => stats.newestNeighbors.map((item) => resolveNeighbor(item, contacts)),
|
|
[contacts, stats.newestNeighbors]
|
|
);
|
|
return (
|
|
<>
|
|
<div className="border-b border-border px-4 py-2.5">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<h2 className="font-semibold text-base text-foreground">Raw Packet Feed</h2>
|
|
<p className="hidden md:block text-xs text-muted-foreground">
|
|
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setAnalyzeModalOpen(true)}
|
|
>
|
|
Analyze Packet
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setStatsOpen((current) => !current)}
|
|
aria-expanded={statsOpen}
|
|
>
|
|
{statsOpen ? (
|
|
<ChevronRight className="h-4 w-4" />
|
|
) : (
|
|
<ChevronLeft className="h-4 w-4" />
|
|
)}
|
|
{statsOpen ? 'Hide Stats' : 'Show Stats'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<p className="md:hidden text-xs text-muted-foreground">
|
|
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
|
|
{!mobileFiltersOpen && (
|
|
<>
|
|
{' · '}
|
|
<button
|
|
type="button"
|
|
className="text-primary hover:text-primary/80 transition-colors"
|
|
onClick={() => setMobileFiltersOpen(true)}
|
|
>
|
|
Show Filters
|
|
</button>
|
|
</>
|
|
)}
|
|
</p>
|
|
|
|
{mobileFiltersOpen && (
|
|
<FeedFilterControls
|
|
className="flex md:hidden"
|
|
allTypesEnabled={allTypesEnabled}
|
|
enabledTypes={enabledTypes}
|
|
onToggleAll={handleToggleAll}
|
|
onToggleType={handleToggleType}
|
|
onOnly={handleOnly}
|
|
autoScroll={autoScroll}
|
|
onAutoScrollChange={setAutoScroll}
|
|
hexFilter={hexFilter}
|
|
onHexFilterChange={setHexFilter}
|
|
hexInvalid={hexInvalid}
|
|
matchCount={filteredPackets.length}
|
|
totalCount={packets.length}
|
|
/>
|
|
)}
|
|
|
|
<FeedFilterControls
|
|
className="hidden md:flex"
|
|
allTypesEnabled={allTypesEnabled}
|
|
enabledTypes={enabledTypes}
|
|
onToggleAll={handleToggleAll}
|
|
onToggleType={handleToggleType}
|
|
onOnly={handleOnly}
|
|
autoScroll={autoScroll}
|
|
onAutoScrollChange={setAutoScroll}
|
|
hexFilter={hexFilter}
|
|
onHexFilterChange={setHexFilter}
|
|
hexInvalid={hexInvalid}
|
|
matchCount={filteredPackets.length}
|
|
totalCount={packets.length}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
|
<div className={cn('min-h-0 min-w-0 flex-1', statsOpen && 'md:border-r md:border-border')}>
|
|
<RawPacketList
|
|
packets={filteredPackets}
|
|
channels={channels}
|
|
onPacketClick={setSelectedPacket}
|
|
autoScroll={autoScroll}
|
|
/>
|
|
</div>
|
|
|
|
<aside
|
|
className={cn(
|
|
'shrink-0 overflow-hidden border-t border-border transition-all duration-300 md:border-l md:border-t-0',
|
|
statsOpen
|
|
? 'max-h-[42rem] md:max-h-none md:w-1/2 md:min-w-[30rem]'
|
|
: 'max-h-0 md:w-0 md:min-w-0 border-transparent'
|
|
)}
|
|
>
|
|
{statsOpen ? (
|
|
<div className="h-full overflow-y-auto bg-background p-4 [contain:layout_paint]">
|
|
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<div className="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground">
|
|
Coverage
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
'mt-1 text-sm',
|
|
coverageMessage.tone === 'warning'
|
|
? 'text-warning'
|
|
: 'text-muted-foreground'
|
|
)}
|
|
>
|
|
{coverageMessage.message}
|
|
</div>
|
|
</div>
|
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
|
<span className="text-muted-foreground">Window</span>
|
|
<select
|
|
value={selectedWindow}
|
|
onChange={(event) =>
|
|
setSelectedWindow(event.target.value as RawPacketStatsWindow)
|
|
}
|
|
className="rounded-md border border-input bg-background px-2 py-1 text-sm"
|
|
aria-label="Stats window"
|
|
>
|
|
{RAW_PACKET_STATS_WINDOWS.map((option) => (
|
|
<option key={option} value={option}>
|
|
{WINDOW_LABELS[option]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div className="mt-2 text-xs text-muted-foreground">
|
|
{stats.packetCount.toLocaleString()} packets in{' '}
|
|
{WINDOW_LABELS[selectedWindow].toLowerCase()} window
|
|
{' · '}
|
|
{rawPacketStatsSession.totalObservedPackets.toLocaleString()} observed this
|
|
session
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 grid grid-cols-2 gap-3 md:grid-cols-3">
|
|
<StatTile
|
|
label="Packets / min"
|
|
value={formatRate(stats.packetsPerMinute)}
|
|
detail={`${stats.packetCount.toLocaleString()} total in window`}
|
|
/>
|
|
<StatTile
|
|
label="Unique Sources"
|
|
value={stats.uniqueSources.toLocaleString()}
|
|
detail="Distinct identified senders"
|
|
/>
|
|
<StatTile
|
|
label="Decrypt Rate"
|
|
value={formatPercent(stats.decryptRate)}
|
|
detail={`${stats.decryptedCount.toLocaleString()} decrypted / ${stats.undecryptedCount.toLocaleString()} locked`}
|
|
/>
|
|
<StatTile
|
|
label="Path Diversity"
|
|
value={stats.distinctPaths.toLocaleString()}
|
|
detail={`${formatPercent(stats.pathBearingRate)} path-bearing packets`}
|
|
/>
|
|
<StatTile
|
|
label="Strongest Neighbor"
|
|
value={strongestNeighbor?.label ?? '-'}
|
|
detail={strongestNeighborDetail ?? 'No neighbor RSSI sample in window'}
|
|
/>
|
|
<StatTile
|
|
label="Median RSSI"
|
|
value={formatRssi(stats.medianRssi)}
|
|
detail={
|
|
stats.averageRssi === null
|
|
? 'No signal sample in window'
|
|
: `Average ${formatRssi(stats.averageRssi)}`
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<TimelineChart bins={stats.timeline} colorMap={PAYLOAD_TYPE_COLOR_MAP} />
|
|
</div>
|
|
|
|
<div className="md:columns-2 md:gap-4">
|
|
<RankedBars
|
|
title="Packet Types"
|
|
items={stats.payloadBreakdown}
|
|
emptyLabel="No packets in this window yet."
|
|
colorMap={PAYLOAD_TYPE_COLOR_MAP}
|
|
/>
|
|
|
|
<RankedBars
|
|
title="Route Mix"
|
|
items={stats.routeBreakdown}
|
|
emptyLabel="No packets in this window yet."
|
|
/>
|
|
|
|
<RankedBars
|
|
title="Hop Profile"
|
|
items={stats.hopProfile}
|
|
emptyLabel="No packets in this window yet."
|
|
/>
|
|
|
|
<RankedBars
|
|
title="Hop Byte Width"
|
|
items={stats.hopByteWidthProfile}
|
|
emptyLabel="No packets in this window yet."
|
|
/>
|
|
|
|
<RankedBars
|
|
title="Signal Distribution"
|
|
items={stats.rssiBuckets}
|
|
emptyLabel="No RSSI samples in this window yet."
|
|
/>
|
|
|
|
<NeighborList
|
|
title="Most-Heard Neighbors"
|
|
items={mostActiveNeighbors}
|
|
emptyLabel="No sender identities resolved in this window yet."
|
|
mode="heard"
|
|
contacts={contacts}
|
|
/>
|
|
|
|
<NeighborList
|
|
title="Strongest Recent Neighbors"
|
|
items={strongestNeighbors}
|
|
emptyLabel="No RSSI-tagged neighbors in this window yet."
|
|
mode="signal"
|
|
contacts={contacts}
|
|
/>
|
|
|
|
<NeighborList
|
|
title="Newest Heard Neighbors"
|
|
items={newestNeighbors}
|
|
emptyLabel="No newly identified neighbors in this window yet."
|
|
mode="recent"
|
|
contacts={contacts}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</aside>
|
|
</div>
|
|
|
|
<RawPacketInspectorDialog
|
|
open={selectedPacket !== null}
|
|
onOpenChange={(isOpen) => !isOpen && setSelectedPacket(null)}
|
|
channels={channels}
|
|
source={
|
|
selectedPacket
|
|
? { kind: 'packet', packet: selectedPacket }
|
|
: { kind: 'loading', message: 'Loading packet...' }
|
|
}
|
|
title="Packet Details"
|
|
description="Detailed byte and field breakdown for the selected raw packet."
|
|
/>
|
|
|
|
<RawPacketInspectorDialog
|
|
open={analyzeModalOpen}
|
|
onOpenChange={setAnalyzeModalOpen}
|
|
channels={channels}
|
|
source={{ kind: 'paste' }}
|
|
title="Analyze Packet"
|
|
description="Paste and inspect a raw packet hex string."
|
|
/>
|
|
</>
|
|
);
|
|
}
|