Add packet analyzer launch to visualizer feed. Closes #328.

This commit is contained in:
Jack Kingsman
2026-07-21 11:42:34 -07:00
parent 0b53534ec0
commit bc22f537d9
5 changed files with 106 additions and 8 deletions
+6 -1
View File
@@ -242,7 +242,12 @@ export function ConversationPane({
if (activeConversation.type === 'visualizer') {
return (
<Suspense fallback={<LoadingPane label="Loading visualizer..." />}>
<VisualizerView packets={rawPackets} contacts={contacts} config={config} />
<VisualizerView
packets={rawPackets}
contacts={contacts}
channels={channels}
config={config}
/>
</Suspense>
);
}
@@ -49,6 +49,8 @@ interface RawPacketInspectorDialogProps {
description: string;
notice?: ReactNode;
signalOverride?: SignalOverride;
/** Portal target; see `DialogContent`. Needed when a host pane uses `requestFullscreen()`. */
container?: HTMLElement | null;
}
interface RawPacketInspectionPanelProps {
@@ -768,6 +770,7 @@ export function RawPacketInspectorDialog({
description,
notice,
signalOverride,
container,
}: RawPacketInspectorDialogProps) {
const [packetInput, setPacketInput] = useState('');
@@ -847,7 +850,10 @@ export function RawPacketInspectorDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex h-[92dvh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
<DialogContent
container={container}
className="flex h-[92dvh] 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>
+31 -4
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Maximize2, Minimize2 } from 'lucide-react';
import type { Contact, RawPacket, RadioConfig } from '../types';
import type { Channel, Contact, RawPacket, RadioConfig } from '../types';
import { PacketVisualizer3D } from './PacketVisualizer3D';
import { RawPacketList } from './RawPacketList';
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { cn } from '@/lib/utils';
import { getVisualizerSettings, saveVisualizerSettings } from '../utils/visualizerSettings';
@@ -10,12 +11,14 @@ import { getVisualizerSettings, saveVisualizerSettings } from '../utils/visualiz
interface VisualizerViewProps {
packets: RawPacket[];
contacts: Contact[];
channels: Channel[];
config: RadioConfig | null;
}
export function VisualizerView({ packets, contacts, config }: VisualizerViewProps) {
export function VisualizerView({ packets, contacts, channels, config }: VisualizerViewProps) {
const [fullScreen, setFullScreen] = useState(() => getVisualizerSettings().hidePacketFeed);
const [paneFullScreen, setPaneFullScreen] = useState(false);
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Persist packet feed visibility to localStorage
@@ -71,7 +74,11 @@ export function VisualizerView({ packets, contacts, config }: VisualizerViewProp
<PacketVisualizer3D packets={packets} contacts={contacts} config={config} />
</TabsContent>
<TabsContent value="packets" className="flex-1 m-0 overflow-hidden">
<RawPacketList packets={packets} />
<RawPacketList
packets={packets}
channels={channels}
onPacketClick={setSelectedPacket}
/>
</TabsContent>
</Tabs>
</div>
@@ -106,11 +113,31 @@ export function VisualizerView({ packets, contacts, config }: VisualizerViewProp
Packet Feed
</div>
<div className="flex-1 overflow-hidden">
<RawPacketList packets={packets} />
<RawPacketList
packets={packets}
channels={channels}
onPacketClick={setSelectedPacket}
/>
</div>
</div>
</div>
</div>
{/* While natively fullscreened, portal into the fullscreen element so the
dialog stays visible; otherwise keep the default document.body target. */}
<RawPacketInspectorDialog
open={selectedPacket !== null}
onOpenChange={(isOpen) => !isOpen && setSelectedPacket(null)}
channels={channels}
container={paneFullScreen ? containerRef.current : undefined}
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."
/>
</div>
);
}
+8 -2
View File
@@ -33,13 +33,19 @@ interface DialogContentProps extends React.ComponentPropsWithoutRef<
typeof DialogPrimitive.Content
> {
hideCloseButton?: boolean;
/**
* Portal target. Defaults to `document.body`. Pass the active fullscreen
* element when rendering inside `requestFullscreen()`, otherwise the dialog
* mounts outside that subtree and is invisible while still trapping focus.
*/
container?: HTMLElement | null;
}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
DialogContentProps
>(({ className, children, hideCloseButton = false, ...props }, ref) => (
<DialogPortal>
>(({ className, children, hideCloseButton = false, container, ...props }, ref) => (
<DialogPortal container={container ?? undefined}>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
+54
View File
@@ -0,0 +1,54 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { VisualizerView } from '../components/VisualizerView';
import type { RawPacket } from '../types';
// The 3D scene needs WebGL, which jsdom does not provide.
vi.mock('../components/PacketVisualizer3D', () => ({
PacketVisualizer3D: () => <div data-testid="packet-visualizer-3d" />,
}));
function createPacket(overrides: Partial<RawPacket> = {}): RawPacket {
return {
id: 1,
timestamp: 1700000000,
data: '000000000000',
payload_type: 'REQ',
snr: null,
rssi: null,
decrypted: false,
decrypted_info: null,
...overrides,
};
}
describe('VisualizerView packet feed', () => {
beforeEach(() => {
window.localStorage.clear();
});
it('opens the packet analyzer when a feed packet is clicked', () => {
render(
<VisualizerView
packets={[createPacket({ id: 7, observation_id: 21 })]}
contacts={[]}
channels={[]}
config={null}
/>
);
expect(screen.queryByText('Packet Details')).not.toBeInTheDocument();
// Desktop split-pane and mobile tab both render the feed, so take the first.
fireEvent.click(screen.getAllByRole('button', { name: /TF/ })[0]);
expect(screen.getByText('Packet Details')).toBeInTheDocument();
});
it('does not render the analyzer until a packet is selected', () => {
render(<VisualizerView packets={[]} contacts={[]} channels={[]} config={null} />);
expect(screen.queryByText('Packet Details')).not.toBeInTheDocument();
});
});