diff --git a/src/App.tsx b/src/App.tsx index fd6cb68..28c68b8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -176,6 +176,12 @@ function AppInner() { setPathMapInitialKey(key); }, []); + // "View path on map" from anywhere that already holds a detail — no key, so the modal picks its own + const handleViewPath = useCallback((detail: PacketDetail) => { + setPathMapDetail(detail); + setPathMapInitialKey(null); + }, []); + const handleAnalyze = useCallback((hash: string | null) => { setSelectedObservationId(null); setSearchParams((p) => { @@ -264,7 +270,15 @@ function AppInner() { }, []); const tabContent: Record = { - Packets: , + Packets: ( + + ), Nodes: , Observers: , Routes: , @@ -302,7 +316,7 @@ function AppInner() { onSelectObservation={setSelectedObservationId} onClose={() => handleAnalyze(null)} onViewNode={setOverlayNodeId} - onViewPath={() => { if (analyzerDetail) { setPathMapDetail(analyzerDetail); setPathMapInitialKey(null); } }} + onViewPath={() => { if (analyzerDetail) handleViewPath(analyzerDetail); }} /> )} {(activeTab === "Map" || activeTab === "Nodes") && selectedNodeId && ( @@ -337,7 +351,7 @@ function AppInner() { handleTabChange("Observers"); setSelectedObserverId(observerId); }} - onViewPath={() => { if (overlayPacketDetail) { setPathMapDetail(overlayPacketDetail); setPathMapInitialKey(null); } }} + onViewPath={() => { if (overlayPacketDetail) handleViewPath(overlayPacketDetail); }} inactive={!!pathMapDetail} /> )} diff --git a/src/features/packets/PacketExpansion.tsx b/src/features/packets/PacketExpansion.tsx index a412870..49c0c05 100644 --- a/src/features/packets/PacketExpansion.tsx +++ b/src/features/packets/PacketExpansion.tsx @@ -32,7 +32,7 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb const emptyState =
No observations
; return ( -
+
first last diff --git a/src/features/packets/PacketList.tsx b/src/features/packets/PacketList.tsx index f33e32d..0340b6b 100644 --- a/src/features/packets/PacketList.tsx +++ b/src/features/packets/PacketList.tsx @@ -1,6 +1,8 @@ import { useState, useCallback, useEffect, useMemo } from "react"; import { useSearchParams } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; import { usePackets } from "./usePackets"; +import { usePacketDetail } from "./usePacketDetail"; import { usePacketFilters, matchesFilters, toServerFilter } from "./usePacketFilters"; import { useScopes } from "../../hooks/useScopes"; import { useRegion } from "../../hooks/useRegion"; @@ -11,6 +13,8 @@ import { LoadingPill } from "../../components/LoadingPill"; import { SkeletonRows } from "../../components/SkeletonRows"; import { PAYLOAD_TYPE_NAMES, ROUTE_TYPE_NAMES } from "../../types/enums"; import type { WsManager } from "../../api/ws-manager"; +import type { PacketDetail } from "../../types/api"; +import type { WsPacketObservation } from "../../types/ws"; // filter options and storage keys @@ -27,13 +31,16 @@ const ROUTE_OPTIONS = Object.entries(ROUTE_TYPE_NAMES).map(([value, label]) => ( interface PacketListProps { wsManager: WsManager; onAnalyze: (hash: string | null) => void; + onViewPath: (detail: PacketDetail) => void; + selectedObservationId: number | null; + onSelectObservation: (id: number) => void; } // main packet view: filters, banner, virtual list -// onAnalyze isn't called here — it's retained for the row-expansion's future "Open analyzer" button -export function PacketList({ wsManager }: PacketListProps) { +export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservationId, onSelectObservation }: PacketListProps) { const [searchParams, setSearchParams] = useSearchParams(); + const queryClient = useQueryClient(); const { filters, setFilter, setSearch, setSearchField, clearFilters } = usePacketFilters(); // single-value selections go to the server so scrolling pages through matching history const serverFilter = useMemo(() => toServerFilter(filters), [filters]); @@ -81,7 +88,27 @@ export function PacketList({ wsManager }: PacketListProps) { }, { replace: true }); }, [expandedHash, setSearchParams]); - useWsPacketHandler(wsManager, handlePacketObservation); + // Shared with the expanded row's own usePacketDetail, so reading it here costs no extra request. + const { data: expandedDetail } = usePacketDetail(expandedHash); + + const handleOpenAnalyzer = useCallback(() => { + if (expandedHash) onAnalyze(expandedHash); + }, [expandedHash, onAnalyze]); + + const handleViewPath = useCallback(() => { + if (expandedDetail) onViewPath(expandedDetail); + }, [expandedDetail, onViewPath]); + + // Refetch only the open row's detail, so its observation table keeps pace with the count ticking + // up beside it. Every other observation just lands in the list. + const handleObservation = useCallback((data: WsPacketObservation["data"]) => { + handlePacketObservation(data); + if (data.packetHash === expandedHash) { + queryClient.invalidateQueries({ queryKey: ["packet-detail", expandedHash] }); + } + }, [handlePacketObservation, expandedHash, queryClient]); + + useWsPacketHandler(wsManager, handleObservation); useWsLaggedHandler(wsManager, handleLagged); const bannerCount = isScrolledAway ? newPacketCount : 0; @@ -177,6 +204,10 @@ export function PacketList({ wsManager }: PacketListProps) { onAtTopChange={setIsAtTop} expandedHash={expandedHash} onToggleExpand={handleToggleExpand} + onOpenAnalyzer={handleOpenAnalyzer} + onViewPath={handleViewPath} + selectedObservationId={selectedObservationId} + onSelectObservation={onSelectObservation} /> )} void; expandedHash: string | null; onToggleExpand: (hash: string) => void; + // only the expanded row renders an expansion, so these need no hash argument + onOpenAnalyzer: () => void; + onViewPath: () => void; + selectedObservationId: number | null; + onSelectObservation: (id: number) => void; } // virtualized scroll list with fresh-item highlighting and infinite load @@ -31,6 +38,10 @@ export function PacketVirtualList({ onAtTopChange, expandedHash, onToggleExpand, + onOpenAnalyzer, + onViewPath, + selectedObservationId, + onSelectObservation, }: PacketVirtualListProps) { const parentRef = useRef(null); const freshHashes = useFreshHashes(packets); @@ -40,7 +51,7 @@ export function PacketVirtualList({ const virtualizer = useVirtualizer({ count: packets.length, getScrollElement: () => parentRef.current, - estimateSize: () => 64, // rough -- rows vary a lot when expanded, tanstack remeasures + estimateSize: () => 64, // a collapsed two-line row; expanded ones are remeasured overscan: 10, getItemKey: (index) => packets[index]?.packetHash ?? index, }); @@ -84,16 +95,19 @@ export function PacketVirtualList({ className="flex-1 overflow-y-auto px-4 pb-10" onScroll={handleScroll} > +
{virtualizer.getVirtualItems().map((virtualRow) => { const packet = packets[virtualRow.index]; if (!packet) return null; + const expanded = expandedHash === packet.packetHash; return (
- onToggleExpand(packet.packetHash)} /> + {expanded && ( + + )}
); diff --git a/tests/features/packets/PacketList.test.tsx b/tests/features/packets/PacketList.test.tsx index 175ef3a..d704489 100644 --- a/tests/features/packets/PacketList.test.tsx +++ b/tests/features/packets/PacketList.test.tsx @@ -1,9 +1,11 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { PacketList } from "../../../src/features/packets/PacketList"; import type { WsManager } from "../../../src/api/ws-manager"; -import type { PacketSummary } from "../../../src/types/api"; +import type { PacketSummary, PacketDetail } from "../../../src/types/api"; +import type { WsPacketObservation } from "../../../src/types/ws"; const basePackets = () => ({ allPackets: [] as PacketSummary[], @@ -26,30 +28,45 @@ vi.mock("../../../src/features/packets/usePackets", () => ({ usePackets: (...args: unknown[]) => usePackets(...(args as [])), })); +const usePacketDetail = vi.fn(() => ({ data: undefined as PacketDetail | undefined })); +vi.mock("../../../src/features/packets/usePacketDetail", () => ({ + usePacketDetail: (hash: string | null) => usePacketDetail(hash as never), +})); + vi.mock("../../../src/hooks/useScopes", () => ({ useScopes: () => [] })); vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }), })); +// capture the packet handler so tests can push a live observation through it +let packetHandler: ((data: WsPacketObservation["data"]) => void) | null = null; vi.mock("../../../src/hooks/useWsHandlers", () => ({ - useWsPacketHandler: () => {}, + useWsPacketHandler: (_manager: unknown, handler: (data: WsPacketObservation["data"]) => void) => { + packetHandler = handler; + }, useWsLaggedHandler: () => {}, })); -// the virtual list needs ResizeObserver in jsdom; stub it down to the expand wiring under test +// the virtual list needs ResizeObserver in jsdom; stub it down to the wiring under test vi.mock("../../../src/features/packets/PacketVirtualList", () => ({ PacketVirtualList: ({ packets, expandedHash, onToggleExpand, + onOpenAnalyzer, + onViewPath, }: { packets: PacketSummary[]; expandedHash: string | null; onToggleExpand: (hash: string) => void; + onOpenAnalyzer: () => void; + onViewPath: () => void; }) => (
{String(expandedHash)}
+ + {packets.map((p) => (