mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-02 11:33:05 +02:00
820 lines
26 KiB
TypeScript
820 lines
26 KiB
TypeScript
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
import { ChannelCrypto, PayloadType } from '@michaelhart/meshcore-decoder';
|
|
|
|
import type { Channel, RawPacket } from '../types';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
createDecoderOptions,
|
|
inspectRawPacketWithOptions,
|
|
type PacketByteField,
|
|
} from '../utils/rawPacketInspector';
|
|
import { toast } from './ui/sonner';
|
|
import { Button } from './ui/button';
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
|
|
|
|
interface RawPacketDetailModalProps {
|
|
packet: RawPacket | null;
|
|
channels: Channel[];
|
|
onClose: () => void;
|
|
}
|
|
|
|
type RawPacketInspectorDialogSource =
|
|
| {
|
|
kind: 'packet';
|
|
packet: RawPacket;
|
|
}
|
|
| {
|
|
kind: 'paste';
|
|
}
|
|
| {
|
|
kind: 'loading';
|
|
message: string;
|
|
}
|
|
| {
|
|
kind: 'unavailable';
|
|
message: string;
|
|
};
|
|
|
|
interface RawPacketInspectorDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
channels: Channel[];
|
|
source: RawPacketInspectorDialogSource;
|
|
title: string;
|
|
description: string;
|
|
notice?: ReactNode;
|
|
}
|
|
|
|
interface RawPacketInspectionPanelProps {
|
|
packet: RawPacket;
|
|
channels: Channel[];
|
|
}
|
|
|
|
interface FieldPaletteEntry {
|
|
box: string;
|
|
boxActive: string;
|
|
hex: string;
|
|
hexActive: string;
|
|
}
|
|
|
|
interface GroupTextResolutionCandidate {
|
|
key: string;
|
|
name: string;
|
|
hash: string;
|
|
}
|
|
|
|
const FIELD_PALETTE: FieldPaletteEntry[] = [
|
|
{
|
|
box: 'border-sky-500/30 bg-sky-500/10',
|
|
boxActive: 'border-sky-600 bg-sky-500/20 shadow-sm shadow-sky-500/20',
|
|
hex: 'bg-sky-500/20 ring-1 ring-inset ring-sky-500/35',
|
|
hexActive: 'bg-sky-500/40 ring-1 ring-inset ring-sky-600/70',
|
|
},
|
|
{
|
|
box: 'border-emerald-500/30 bg-emerald-500/10',
|
|
boxActive: 'border-emerald-600 bg-emerald-500/20 shadow-sm shadow-emerald-500/20',
|
|
hex: 'bg-emerald-500/20 ring-1 ring-inset ring-emerald-500/35',
|
|
hexActive: 'bg-emerald-500/40 ring-1 ring-inset ring-emerald-600/70',
|
|
},
|
|
{
|
|
box: 'border-amber-500/30 bg-amber-500/10',
|
|
boxActive: 'border-amber-600 bg-amber-500/20 shadow-sm shadow-amber-500/20',
|
|
hex: 'bg-amber-500/20 ring-1 ring-inset ring-amber-500/35',
|
|
hexActive: 'bg-amber-500/40 ring-1 ring-inset ring-amber-600/70',
|
|
},
|
|
{
|
|
box: 'border-rose-500/30 bg-rose-500/10',
|
|
boxActive: 'border-rose-600 bg-rose-500/20 shadow-sm shadow-rose-500/20',
|
|
hex: 'bg-rose-500/20 ring-1 ring-inset ring-rose-500/35',
|
|
hexActive: 'bg-rose-500/40 ring-1 ring-inset ring-rose-600/70',
|
|
},
|
|
{
|
|
box: 'border-violet-500/30 bg-violet-500/10',
|
|
boxActive: 'border-violet-600 bg-violet-500/20 shadow-sm shadow-violet-500/20',
|
|
hex: 'bg-violet-500/20 ring-1 ring-inset ring-violet-500/35',
|
|
hexActive: 'bg-violet-500/40 ring-1 ring-inset ring-violet-600/70',
|
|
},
|
|
{
|
|
box: 'border-cyan-500/30 bg-cyan-500/10',
|
|
boxActive: 'border-cyan-600 bg-cyan-500/20 shadow-sm shadow-cyan-500/20',
|
|
hex: 'bg-cyan-500/20 ring-1 ring-inset ring-cyan-500/35',
|
|
hexActive: 'bg-cyan-500/40 ring-1 ring-inset ring-cyan-600/70',
|
|
},
|
|
{
|
|
box: 'border-lime-500/30 bg-lime-500/10',
|
|
boxActive: 'border-lime-600 bg-lime-500/20 shadow-sm shadow-lime-500/20',
|
|
hex: 'bg-lime-500/20 ring-1 ring-inset ring-lime-500/35',
|
|
hexActive: 'bg-lime-500/40 ring-1 ring-inset ring-lime-600/70',
|
|
},
|
|
{
|
|
box: 'border-fuchsia-500/30 bg-fuchsia-500/10',
|
|
boxActive: 'border-fuchsia-600 bg-fuchsia-500/20 shadow-sm shadow-fuchsia-500/20',
|
|
hex: 'bg-fuchsia-500/20 ring-1 ring-inset ring-fuchsia-500/35',
|
|
hexActive: 'bg-fuchsia-500/40 ring-1 ring-inset ring-fuchsia-600/70',
|
|
},
|
|
];
|
|
|
|
function formatTimestamp(timestamp: number): string {
|
|
return new Date(timestamp * 1000).toLocaleString([], {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
});
|
|
}
|
|
|
|
function formatSignal(packet: RawPacket): string {
|
|
const parts: string[] = [];
|
|
if (packet.rssi !== null) {
|
|
parts.push(`${packet.rssi} dBm RSSI`);
|
|
}
|
|
if (packet.snr !== null) {
|
|
parts.push(`${packet.snr.toFixed(1)} dB SNR`);
|
|
}
|
|
return parts.length > 0 ? parts.join(' · ') : 'No signal sample';
|
|
}
|
|
|
|
function formatByteRange(field: PacketByteField): string {
|
|
if (field.absoluteStartByte === field.absoluteEndByte) {
|
|
return `Byte ${field.absoluteStartByte}`;
|
|
}
|
|
return `Bytes ${field.absoluteStartByte}-${field.absoluteEndByte}`;
|
|
}
|
|
|
|
function formatPathMode(hashSize: number | undefined, hopCount: number): string {
|
|
if (hopCount === 0) {
|
|
return 'No path hops';
|
|
}
|
|
if (!hashSize) {
|
|
return `${hopCount} hop${hopCount === 1 ? '' : 's'}`;
|
|
}
|
|
return `${hopCount} hop${hopCount === 1 ? '' : 's'} · ${hashSize} byte hash${hashSize === 1 ? '' : 'es'}`;
|
|
}
|
|
|
|
function buildGroupTextResolutionCandidates(channels: Channel[]): GroupTextResolutionCandidate[] {
|
|
return channels.map((channel) => ({
|
|
key: channel.key,
|
|
name: channel.name,
|
|
hash: ChannelCrypto.calculateChannelHash(channel.key).toUpperCase(),
|
|
}));
|
|
}
|
|
|
|
function resolveGroupTextRoomName(
|
|
payload: {
|
|
channelHash?: string;
|
|
cipherMac?: string;
|
|
ciphertext?: string;
|
|
decrypted?: { message?: string };
|
|
},
|
|
candidates: GroupTextResolutionCandidate[]
|
|
): string | null {
|
|
if (!payload.channelHash) {
|
|
return null;
|
|
}
|
|
|
|
const hashMatches = candidates.filter(
|
|
(candidate) => candidate.hash === payload.channelHash?.toUpperCase()
|
|
);
|
|
if (hashMatches.length === 1) {
|
|
return hashMatches[0].name;
|
|
}
|
|
if (
|
|
hashMatches.length <= 1 ||
|
|
!payload.cipherMac ||
|
|
!payload.ciphertext ||
|
|
!payload.decrypted?.message
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const decryptMatches = hashMatches.filter(
|
|
(candidate) =>
|
|
ChannelCrypto.decryptGroupTextMessage(payload.ciphertext!, payload.cipherMac!, candidate.key)
|
|
.success
|
|
);
|
|
return decryptMatches.length === 1 ? decryptMatches[0].name : null;
|
|
}
|
|
|
|
function packetShowsDecryptedState(
|
|
packet: RawPacket,
|
|
inspection: ReturnType<typeof inspectRawPacketWithOptions>
|
|
): boolean {
|
|
const payload = inspection.decoded?.payload.decoded as { decrypted?: unknown } | null | undefined;
|
|
return packet.decrypted || Boolean(packet.decrypted_info) || Boolean(payload?.decrypted);
|
|
}
|
|
|
|
function getPacketContext(
|
|
packet: RawPacket,
|
|
inspection: ReturnType<typeof inspectRawPacketWithOptions>,
|
|
groupTextCandidates: GroupTextResolutionCandidate[]
|
|
) {
|
|
const fallbackSender = packet.decrypted_info?.sender ?? null;
|
|
const fallbackRoom = packet.decrypted_info?.channel_name ?? null;
|
|
|
|
if (!inspection.decoded?.payload.decoded) {
|
|
if (!fallbackSender && !fallbackRoom) {
|
|
return null;
|
|
}
|
|
return {
|
|
title: fallbackRoom ? 'Room' : 'Context',
|
|
primary: fallbackRoom ?? 'Sender metadata available',
|
|
secondary: fallbackSender ? `Sender: ${fallbackSender}` : null,
|
|
};
|
|
}
|
|
|
|
if (inspection.decoded.payloadType === PayloadType.GroupText) {
|
|
const payload = inspection.decoded.payload.decoded as {
|
|
channelHash?: string;
|
|
cipherMac?: string;
|
|
ciphertext?: string;
|
|
decrypted?: { sender?: string; message?: string };
|
|
};
|
|
const roomName = fallbackRoom ?? resolveGroupTextRoomName(payload, groupTextCandidates);
|
|
return {
|
|
title: roomName ? 'Room' : 'Channel',
|
|
primary:
|
|
roomName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'),
|
|
secondary: payload.decrypted?.sender
|
|
? `Sender: ${payload.decrypted.sender}`
|
|
: fallbackSender
|
|
? `Sender: ${fallbackSender}`
|
|
: null,
|
|
};
|
|
}
|
|
|
|
if (fallbackSender) {
|
|
return {
|
|
title: 'Context',
|
|
primary: fallbackSender,
|
|
secondary: null,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function buildDisplayFields(inspection: ReturnType<typeof inspectRawPacketWithOptions>) {
|
|
return [
|
|
...inspection.packetFields.filter((field) => field.name !== 'Payload'),
|
|
...inspection.payloadFields,
|
|
];
|
|
}
|
|
|
|
function buildFieldColorMap(fields: PacketByteField[]) {
|
|
return new Map(
|
|
fields.map((field, index) => [field.id, FIELD_PALETTE[index % FIELD_PALETTE.length]])
|
|
);
|
|
}
|
|
|
|
function buildByteOwners(totalBytes: number, fields: PacketByteField[]) {
|
|
const owners = new Array<string | null>(totalBytes).fill(null);
|
|
for (const field of fields) {
|
|
for (let index = field.absoluteStartByte; index <= field.absoluteEndByte; index += 1) {
|
|
if (index >= 0 && index < owners.length) {
|
|
owners[index] = field.id;
|
|
}
|
|
}
|
|
}
|
|
return owners;
|
|
}
|
|
|
|
function buildByteRuns(bytes: string[], owners: Array<string | null>) {
|
|
const runs: Array<{ fieldId: string | null; text: string }> = [];
|
|
|
|
for (let index = 0; index < bytes.length; index += 1) {
|
|
const fieldId = owners[index];
|
|
const lastRun = runs[runs.length - 1];
|
|
if (lastRun && lastRun.fieldId === fieldId) {
|
|
lastRun.text += ` ${bytes[index]}`;
|
|
continue;
|
|
}
|
|
|
|
runs.push({
|
|
fieldId,
|
|
text: bytes[index],
|
|
});
|
|
}
|
|
|
|
return runs;
|
|
}
|
|
|
|
function CompactMetaCard({
|
|
label,
|
|
primary,
|
|
secondary,
|
|
}: {
|
|
label: string;
|
|
primary: string;
|
|
secondary?: string | null;
|
|
}) {
|
|
return (
|
|
<div className="rounded-lg border border-border/70 bg-card/70 p-2.5">
|
|
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">{label}</div>
|
|
<div className="mt-1 text-sm font-medium leading-tight text-foreground">{primary}</div>
|
|
{secondary ? (
|
|
<div className="mt-1 text-xs leading-tight text-muted-foreground">{secondary}</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FullPacketHex({
|
|
packetHex,
|
|
fields,
|
|
colorMap,
|
|
hoveredFieldId,
|
|
onHoverField,
|
|
}: {
|
|
packetHex: string;
|
|
fields: PacketByteField[];
|
|
colorMap: Map<string, FieldPaletteEntry>;
|
|
hoveredFieldId: string | null;
|
|
onHoverField: (fieldId: string | null) => void;
|
|
}) {
|
|
const normalized = packetHex.toUpperCase();
|
|
const bytes = useMemo(() => normalized.match(/.{1,2}/g) ?? [], [normalized]);
|
|
const byteOwners = useMemo(() => buildByteOwners(bytes.length, fields), [bytes.length, fields]);
|
|
const byteRuns = useMemo(() => buildByteRuns(bytes, byteOwners), [byteOwners, bytes]);
|
|
|
|
return (
|
|
<div className="font-mono text-[15px] leading-7 text-foreground">
|
|
{byteRuns.map((run, index) => {
|
|
const fieldId = run.fieldId;
|
|
const palette = fieldId ? colorMap.get(fieldId) : null;
|
|
const active = fieldId !== null && hoveredFieldId === fieldId;
|
|
return (
|
|
<span key={`${fieldId ?? 'plain'}-${index}`}>
|
|
<span
|
|
onMouseEnter={() => onHoverField(fieldId)}
|
|
onMouseLeave={() => onHoverField(null)}
|
|
className={cn(
|
|
'inline rounded-sm px-0.5 py-0.5 transition-colors',
|
|
palette ? (active ? palette.hexActive : palette.hex) : ''
|
|
)}
|
|
>
|
|
{run.text}
|
|
</span>
|
|
{index < byteRuns.length - 1 ? ' ' : ''}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderFieldValue(field: PacketByteField) {
|
|
if (field.name !== 'Path Data') {
|
|
return field.value.toUpperCase();
|
|
}
|
|
|
|
const parts = field.value
|
|
.toUpperCase()
|
|
.split(' → ')
|
|
.filter((part) => part.length > 0);
|
|
|
|
if (parts.length <= 1) {
|
|
return field.value.toUpperCase();
|
|
}
|
|
|
|
return (
|
|
<span className="inline-flex flex-wrap justify-start gap-x-1 sm:justify-end">
|
|
{parts.map((part, index) => {
|
|
const isLast = index === parts.length - 1;
|
|
return (
|
|
<span key={`${field.id}-${part}-${index}`} className="whitespace-nowrap">
|
|
{isLast ? part : `${part} →`}
|
|
</span>
|
|
);
|
|
})}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function normalizePacketHex(input: string): string {
|
|
return input.replace(/\s+/g, '').toUpperCase();
|
|
}
|
|
|
|
function validatePacketHex(input: string): string | null {
|
|
if (!input) {
|
|
return 'Paste a packet hex string to analyze.';
|
|
}
|
|
if (!/^[0-9A-F]+$/.test(input)) {
|
|
return 'Packet hex may only contain 0-9 and A-F characters.';
|
|
}
|
|
if (input.length % 2 !== 0) {
|
|
return 'Packet hex must contain an even number of characters.';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function buildPastedRawPacket(packetHex: string): RawPacket {
|
|
return {
|
|
id: -1,
|
|
timestamp: Math.floor(Date.now() / 1000),
|
|
data: packetHex,
|
|
payload_type: 'Unknown',
|
|
snr: null,
|
|
rssi: null,
|
|
decrypted: false,
|
|
decrypted_info: null,
|
|
};
|
|
}
|
|
|
|
function FieldBox({
|
|
field,
|
|
palette,
|
|
active,
|
|
onHoverField,
|
|
}: {
|
|
field: PacketByteField;
|
|
palette: FieldPaletteEntry;
|
|
active: boolean;
|
|
onHoverField: (fieldId: string | null) => void;
|
|
}) {
|
|
return (
|
|
<div
|
|
onMouseEnter={() => onHoverField(field.id)}
|
|
onMouseLeave={() => onHoverField(null)}
|
|
className={cn(
|
|
'rounded-lg border p-2.5 transition-colors',
|
|
active ? palette.boxActive : palette.box
|
|
)}
|
|
>
|
|
<div className="flex flex-col items-start gap-2 sm:flex-row sm:justify-between">
|
|
<div className="min-w-0">
|
|
<div className="text-base font-semibold leading-tight text-foreground">{field.name}</div>
|
|
<div className="mt-0.5 text-[11px] text-muted-foreground">{formatByteRange(field)}</div>
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
'w-full font-mono text-sm leading-5 text-foreground sm:max-w-[14rem] sm:text-right',
|
|
field.name === 'Path Data' ? 'break-normal' : 'break-all'
|
|
)}
|
|
>
|
|
{renderFieldValue(field)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-2 whitespace-pre-wrap text-sm leading-5 text-foreground">
|
|
{field.description}
|
|
</div>
|
|
|
|
{field.decryptedMessage ? (
|
|
<div className="mt-2 rounded border border-border/50 bg-background/40 p-2">
|
|
<div className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
|
{field.name === 'Ciphertext' ? 'Plaintext' : 'Decoded value'}
|
|
</div>
|
|
<PlaintextContent text={field.decryptedMessage} />
|
|
</div>
|
|
) : null}
|
|
|
|
{field.headerBreakdown ? (
|
|
<div className="mt-2 space-y-1.5">
|
|
<div className="font-mono text-xs tracking-[0.16em] text-muted-foreground">
|
|
{field.headerBreakdown.fullBinary}
|
|
</div>
|
|
{field.headerBreakdown.fields.map((part) => (
|
|
<div
|
|
key={`${field.id}-${part.bits}-${part.field}`}
|
|
className="rounded border border-border/50 bg-background/40 p-2"
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div>
|
|
<div className="text-sm font-medium leading-tight text-foreground">
|
|
{part.field}
|
|
</div>
|
|
<div className="mt-0.5 text-[11px] text-muted-foreground">Bits {part.bits}</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="font-mono text-sm text-foreground">{part.binary}</div>
|
|
<div className="mt-0.5 text-[11px] text-muted-foreground">{part.value}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PlaintextContent({ text }: { text: string }) {
|
|
const lines = text.split('\n');
|
|
|
|
return (
|
|
<div className="mt-1 space-y-1 text-sm leading-5 text-foreground">
|
|
{lines.map((line, index) => {
|
|
const separatorIndex = line.indexOf(': ');
|
|
if (separatorIndex === -1) {
|
|
return (
|
|
<div key={`${line}-${index}`} className="font-mono">
|
|
{line}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const label = line.slice(0, separatorIndex + 1);
|
|
const value = line.slice(separatorIndex + 2);
|
|
|
|
return (
|
|
<div key={`${line}-${index}`}>
|
|
<span>{label} </span>
|
|
<span className="font-mono">{value}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FieldSection({
|
|
title,
|
|
fields,
|
|
colorMap,
|
|
hoveredFieldId,
|
|
onHoverField,
|
|
}: {
|
|
title: string;
|
|
fields: PacketByteField[];
|
|
colorMap: Map<string, FieldPaletteEntry>;
|
|
hoveredFieldId: string | null;
|
|
onHoverField: (fieldId: string | null) => void;
|
|
}) {
|
|
return (
|
|
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
|
|
<div className="mb-2 text-sm font-semibold text-foreground">{title}</div>
|
|
{fields.length === 0 ? (
|
|
<div className="text-sm text-muted-foreground">No decoded fields available.</div>
|
|
) : (
|
|
<div className="grid gap-2">
|
|
{fields.map((field) => (
|
|
<FieldBox
|
|
key={field.id}
|
|
field={field}
|
|
palette={colorMap.get(field.id) ?? FIELD_PALETTE[0]}
|
|
active={hoveredFieldId === field.id}
|
|
onHoverField={onHoverField}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspectionPanelProps) {
|
|
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
|
|
const groupTextCandidates = useMemo(
|
|
() => buildGroupTextResolutionCandidates(channels),
|
|
[channels]
|
|
);
|
|
const inspection = useMemo(
|
|
() => inspectRawPacketWithOptions(packet, decoderOptions),
|
|
[decoderOptions, packet]
|
|
);
|
|
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
|
|
|
|
const packetDisplayFields = useMemo(
|
|
() => inspection.packetFields.filter((field) => field.name !== 'Payload'),
|
|
[inspection]
|
|
);
|
|
const fullPacketFields = useMemo(() => buildDisplayFields(inspection), [inspection]);
|
|
const colorMap = useMemo(() => buildFieldColorMap(fullPacketFields), [fullPacketFields]);
|
|
const packetContext = useMemo(
|
|
() => getPacketContext(packet, inspection, groupTextCandidates),
|
|
[groupTextCandidates, inspection, packet]
|
|
);
|
|
const packetIsDecrypted = useMemo(
|
|
() => packetShowsDecryptedState(packet, inspection),
|
|
[inspection, packet]
|
|
);
|
|
|
|
return (
|
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
|
<div className="grid gap-2 lg:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
|
|
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
|
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
|
Summary
|
|
</div>
|
|
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
|
|
{inspection.summary.summary}
|
|
</div>
|
|
</div>
|
|
<div className="shrink-0 text-xs text-muted-foreground">
|
|
{formatTimestamp(packet.timestamp)}
|
|
</div>
|
|
</div>
|
|
{packetContext ? (
|
|
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
|
|
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
|
{packetContext.title}
|
|
</div>
|
|
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
|
|
{packetContext.primary}
|
|
</div>
|
|
{packetContext.secondary ? (
|
|
<div className="mt-1 text-xs leading-tight text-muted-foreground">
|
|
{packetContext.secondary}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
|
|
<section className="grid gap-2 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
|
|
<CompactMetaCard
|
|
label="Packet"
|
|
primary={`${packet.data.length / 2} bytes · ${packetIsDecrypted ? 'Decrypted' : 'Encrypted'}`}
|
|
secondary={`Storage #${packet.id}${packet.observation_id !== undefined ? ` · Observation #${packet.observation_id}` : ''}`}
|
|
/>
|
|
<CompactMetaCard
|
|
label="Transport"
|
|
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
|
|
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
|
|
/>
|
|
<CompactMetaCard
|
|
label="Signal"
|
|
primary={formatSignal(packet)}
|
|
secondary={packetContext ? null : undefined}
|
|
/>
|
|
</section>
|
|
</div>
|
|
|
|
{inspection.validationErrors.length > 0 ? (
|
|
<div className="mt-3 rounded-lg border border-warning/40 bg-warning/10 p-2.5">
|
|
<div className="text-sm font-semibold text-foreground">Validation notes</div>
|
|
<div className="mt-1.5 space-y-1 text-sm text-foreground">
|
|
{inspection.validationErrors.map((error) => (
|
|
<div key={error}>{error}</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="mt-3 rounded-lg border border-border/70 bg-card/70 p-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="text-xl font-semibold text-foreground">Full packet hex</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(packet.data);
|
|
toast.success('Packet hex copied!');
|
|
}}
|
|
>
|
|
Copy
|
|
</Button>
|
|
</div>
|
|
<div className="mt-2.5">
|
|
<FullPacketHex
|
|
packetHex={packet.data}
|
|
fields={fullPacketFields}
|
|
colorMap={colorMap}
|
|
hoveredFieldId={hoveredFieldId}
|
|
onHoverField={setHoveredFieldId}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-3 grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
|
|
<FieldSection
|
|
title="Packet fields"
|
|
fields={packetDisplayFields}
|
|
colorMap={colorMap}
|
|
hoveredFieldId={hoveredFieldId}
|
|
onHoverField={setHoveredFieldId}
|
|
/>
|
|
|
|
<FieldSection
|
|
title="Payload fields"
|
|
fields={inspection.payloadFields}
|
|
colorMap={colorMap}
|
|
hoveredFieldId={hoveredFieldId}
|
|
onHoverField={setHoveredFieldId}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function RawPacketInspectorDialog({
|
|
open,
|
|
onOpenChange,
|
|
channels,
|
|
source,
|
|
title,
|
|
description,
|
|
notice,
|
|
}: RawPacketInspectorDialogProps) {
|
|
const [packetInput, setPacketInput] = useState('');
|
|
|
|
useEffect(() => {
|
|
if (!open || source.kind !== 'paste') {
|
|
setPacketInput('');
|
|
}
|
|
}, [open, source.kind]);
|
|
|
|
const normalizedPacketInput = useMemo(() => normalizePacketHex(packetInput), [packetInput]);
|
|
const packetInputError = useMemo(
|
|
() => (normalizedPacketInput.length > 0 ? validatePacketHex(normalizedPacketInput) : null),
|
|
[normalizedPacketInput]
|
|
);
|
|
const analyzedPacket = useMemo(
|
|
() =>
|
|
normalizedPacketInput.length > 0 && packetInputError === null
|
|
? buildPastedRawPacket(normalizedPacketInput)
|
|
: null,
|
|
[normalizedPacketInput, packetInputError]
|
|
);
|
|
|
|
let body: ReactNode;
|
|
if (source.kind === 'packet') {
|
|
body = <RawPacketInspectionPanel packet={source.packet} channels={channels} />;
|
|
} else if (source.kind === 'paste') {
|
|
body = (
|
|
<>
|
|
<div className="border-b border-border px-4 py-3 pr-14">
|
|
<div className="flex flex-col gap-3">
|
|
<label className="text-sm font-medium text-foreground" htmlFor="raw-packet-input">
|
|
Packet Hex
|
|
</label>
|
|
<textarea
|
|
id="raw-packet-input"
|
|
value={packetInput}
|
|
onChange={(event) => setPacketInput(event.target.value)}
|
|
placeholder="Paste raw packet hex here..."
|
|
className="min-h-14 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
|
|
spellCheck={false}
|
|
/>
|
|
{packetInputError ? (
|
|
<div className="text-sm text-destructive">{packetInputError}</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
{analyzedPacket ? (
|
|
<RawPacketInspectionPanel packet={analyzedPacket} channels={channels} />
|
|
) : (
|
|
<div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
|
|
Paste a packet above to inspect it.
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
} else if (source.kind === 'loading') {
|
|
body = (
|
|
<div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
|
|
{source.message}
|
|
</div>
|
|
);
|
|
} else {
|
|
body = (
|
|
<div className="flex flex-1 items-center justify-center p-6">
|
|
<div className="max-w-xl rounded-lg border border-warning/40 bg-warning/10 p-4 text-sm text-foreground">
|
|
{source.message}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="flex h-[92vh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
|
|
<DialogHeader className="border-b border-border px-5 py-3">
|
|
<DialogTitle>{title}</DialogTitle>
|
|
<DialogDescription className="sr-only">{description}</DialogDescription>
|
|
</DialogHeader>
|
|
{notice ? (
|
|
<div className="border-b border-border px-3 py-3 text-sm text-foreground">
|
|
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-destructive">
|
|
{notice}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{body}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) {
|
|
if (!packet) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<RawPacketInspectorDialog
|
|
open={packet !== null}
|
|
onOpenChange={(isOpen) => !isOpen && onClose()}
|
|
channels={channels}
|
|
source={{ kind: 'packet', packet }}
|
|
title="Packet Details"
|
|
description="Detailed byte and field breakdown for the selected raw packet."
|
|
/>
|
|
);
|
|
}
|