mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-17 15:39:54 +00:00
260 lines
12 KiB
TypeScript
260 lines
12 KiB
TypeScript
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 { parsePathSearch } from "./path-search";
|
|
import { useScopes } from "../../hooks/useScopes";
|
|
import { useRegion } from "../../hooks/useRegion";
|
|
import { useWsPacketHandler, useWsLaggedHandler } from "../../hooks/useWsHandlers";
|
|
import { PacketVirtualList } from "./PacketVirtualList";
|
|
import { FilterBar } from "../../components/FilterBar";
|
|
import { LoadingPill } from "../../components/LoadingPill";
|
|
import { SkeletonRows } from "../../components/SkeletonRows";
|
|
import { CloseButton } from "../../components/CloseButton";
|
|
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
|
|
|
|
const TYPE_OPTIONS = Object.entries(PAYLOAD_TYPE_NAMES).map(([value, label]) => ({
|
|
value: String(value),
|
|
label,
|
|
}));
|
|
|
|
const ROUTE_OPTIONS = Object.entries(ROUTE_TYPE_NAMES).map(([value, label]) => ({
|
|
value: String(value),
|
|
label,
|
|
}));
|
|
|
|
interface PacketListProps {
|
|
wsManager: WsManager;
|
|
onAnalyze: (hash: string | null, observationId?: number) => void;
|
|
onViewPath: (detail: PacketDetail) => void;
|
|
selectedObservationId: number | null;
|
|
onSelectObservation: (id: number) => void;
|
|
}
|
|
|
|
// main packet view: filters, banner, virtual list
|
|
|
|
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]);
|
|
const pathSearch = useMemo(() => parsePathSearch(filters.search), [filters.search]);
|
|
const scopeNames = useScopes(filters.scopes);
|
|
const scopeOptions = useMemo(() => scopeNames.map((s) => ({ value: s, label: s })), [scopeNames]);
|
|
const { regionKey } = useRegion();
|
|
|
|
// isAtTop drives the freeze (list held static while scrolled off the very top); isScrolledAway
|
|
// (a wider deadband) drives the banner. listResetKey remounts the list to reveal held packets.
|
|
const [isScrolledAway, setIsScrolledAway] = useState(false);
|
|
const [isAtTop, setIsAtTop] = useState(true);
|
|
const [listResetKey, setListResetKey] = useState(0);
|
|
|
|
const {
|
|
allPackets,
|
|
observerOptions,
|
|
newPacketCount,
|
|
acknowledgeNewPackets,
|
|
fetchNextPage,
|
|
hasNextPage,
|
|
isFetching,
|
|
isFetchingNextPage,
|
|
isLoading,
|
|
isError,
|
|
observersByHash,
|
|
handlePacketObservation,
|
|
handleLagged,
|
|
laggedCount,
|
|
dismissLagged,
|
|
} = usePackets(!isAtTop, serverFilter);
|
|
|
|
const packets = useMemo(
|
|
() => allPackets.filter((p) => matchesFilters(p, filters, observersByHash, pathSearch)),
|
|
[allPackets, filters, observersByHash, pathSearch],
|
|
);
|
|
|
|
// ?hash is the selected packet — it expands the row inline. The analyzer is a separate state (?analyze=1).
|
|
const expandedHash = searchParams.get("hash");
|
|
|
|
const handleToggleExpand = useCallback((hash: string) => {
|
|
const next = expandedHash === hash ? null : hash;
|
|
setSearchParams((p) => {
|
|
const n = new URLSearchParams(p);
|
|
n.delete("observation");
|
|
if (next) n.set("hash", next); else n.delete("hash");
|
|
return n;
|
|
}, { replace: true });
|
|
}, [expandedHash, setSearchParams]);
|
|
|
|
// Shared with the expanded row's own usePacketDetail, so reading it here costs no extra request.
|
|
const { data: expandedDetail, isError: selectedError, error: detailError, refetch: retryDetail } = usePacketDetail(expandedHash);
|
|
const detailStatus = detailError && "status" in detailError ? detailError.status : undefined;
|
|
|
|
const handleOpenAnalyzer = useCallback((observationId?: number) => {
|
|
if (!expandedHash) return;
|
|
if (observationId == null) onAnalyze(expandedHash);
|
|
else onAnalyze(expandedHash, observationId);
|
|
}, [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;
|
|
|
|
// Remount the list (fresh at the top, no stale scroll anchor for the virtualizer to preserve)
|
|
// when returning to the top with packets held while away — a big prepend into the live list
|
|
// would otherwise keep the old row anchored instead of landing on the newest.
|
|
const [prevAtTop, setPrevAtTop] = useState(isAtTop);
|
|
if (prevAtTop !== isAtTop) {
|
|
setPrevAtTop(isAtTop);
|
|
if (isAtTop && newPacketCount > 0) setListResetKey((k) => k + 1);
|
|
}
|
|
|
|
// A region switch starts fresh at the top so the new region's list isn't held frozen.
|
|
const [prevRegionKey, setPrevRegionKey] = useState(regionKey);
|
|
if (prevRegionKey !== regionKey) {
|
|
setPrevRegionKey(regionKey);
|
|
setListResetKey((k) => k + 1);
|
|
setIsAtTop(true);
|
|
setIsScrolledAway(false);
|
|
}
|
|
|
|
// At the top the held packets are revealed, so acknowledge continuously there — the banner then
|
|
// counts only what arrived while the user was away (and never flashes a count at the top).
|
|
useEffect(() => {
|
|
if (isAtTop && newPacketCount > 0) acknowledgeNewPackets();
|
|
}, [isAtTop, newPacketCount, acknowledgeNewPackets]);
|
|
|
|
// Returning to the top (revealing held packets) is a remount; releasing the freeze first lets
|
|
// the fresh list mount with the newest packet already in place.
|
|
const handleScrollToTop = useCallback(() => {
|
|
setIsScrolledAway(false);
|
|
setIsAtTop(true);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="flex flex-1 min-h-0">
|
|
<div className="relative flex flex-col flex-1 min-h-0 min-w-0">
|
|
<FilterBar
|
|
typeOptions={TYPE_OPTIONS}
|
|
routeOptions={ROUTE_OPTIONS}
|
|
observerOptions={observerOptions}
|
|
scopeOptions={scopeOptions}
|
|
activeTypes={filters.payloadTypes.map(String)}
|
|
activeRoutes={filters.routeTypes.map(String)}
|
|
activeObservers={filters.observers}
|
|
activeScopes={filters.scopes}
|
|
onTypesChange={(v) => setFilter("payloadTypes", v.map(Number))}
|
|
onRoutesChange={(v) => setFilter("routeTypes", v.map(Number))}
|
|
onObserversChange={(v) => setFilter("observers", v)}
|
|
onScopesChange={(v) => setFilter("scopes", v)}
|
|
search={filters.search}
|
|
onSearchChange={setSearch}
|
|
searchField={filters.searchField}
|
|
onSearchFieldChange={setSearchField}
|
|
onClear={clearFilters}
|
|
/>
|
|
|
|
{filters.searchField === "path" && (
|
|
<p role={pathSearch === null ? "alert" : undefined} className={`px-4 py-1.5 text-xs font-mono ${pathSearch === null ? "text-danger" : "text-text-muted"}`}>
|
|
{pathSearch === null
|
|
? "Enter whole hop hashes of the same length, separated by spaces, commas or → (e.g. 7f a4)."
|
|
: "Searches the latest relay path in loaded packets. Use whole hop hashes, e.g. 7f a4 (spaces, commas or →). Trace packets are excluded."}
|
|
</p>
|
|
)}
|
|
|
|
{/* A hash-only link still selects a row; offer the existing analyzer when there is no row. */}
|
|
{expandedHash && !isLoading && searchParams.get("analyze") !== "1" && !packets.some(p => p.packetHash === expandedHash) && (
|
|
<section aria-label="Selected packet" className="mx-4 my-2 px-3 py-2 border border-border rounded-sm bg-bg-surface flex items-start justify-between gap-3 text-xs text-text-muted">
|
|
<div>
|
|
{selectedError ? (
|
|
<>
|
|
<p role="alert">{detailStatus === 400 ? "Invalid packet hash." : detailStatus === 404 ? "Packet not found." : "Failed to load selected packet."}</p>
|
|
<button type="button" className="mt-1 px-2 py-1 border border-border rounded-sm hover:bg-bg-raised cursor-pointer" onClick={() => retryDetail()} aria-label="Retry selected packet">Retry</button>
|
|
</>
|
|
) : expandedDetail ? (
|
|
<>
|
|
<p>Selected packet is outside the loaded results.</p>
|
|
<button type="button" className="mt-1 px-2 py-1 border border-border rounded-sm hover:bg-bg-raised cursor-pointer" onClick={() => handleOpenAnalyzer()}>Open analyzer</button>
|
|
</>
|
|
) : <p role="status">Loading selected packet…</p>}
|
|
</div>
|
|
<CloseButton label="Dismiss selected packet" onClose={() => handleToggleExpand(expandedHash)} />
|
|
</section>
|
|
)}
|
|
|
|
{laggedCount > 0 && (
|
|
<div className="mx-4 px-3 py-1.5 bg-warn/6 border border-warn/12 text-warn text-xs font-medium font-mono rounded-b flex items-center justify-between">
|
|
<span>{laggedCount} packet{laggedCount === 1 ? "" : "s"} dropped — data may be incomplete</span>
|
|
<button type="button" className="underline cursor-pointer" onClick={dismissLagged}>dismiss</button>
|
|
</div>
|
|
)}
|
|
|
|
{bannerCount > 0 ? (
|
|
<button
|
|
type="button"
|
|
className="mx-4 flex items-center justify-center gap-2 px-3 py-1.5 bg-primary/10 hover:bg-primary/15 border border-primary/20 border-t-0 text-primary text-[11px] font-medium tracking-wide cursor-pointer font-mono rounded-b transition-colors"
|
|
onClick={handleScrollToTop}
|
|
>
|
|
<span aria-hidden>▲</span>
|
|
{bannerCount} new packet{bannerCount === 1 ? "" : "s"}
|
|
<span className="text-primary/60 font-normal">· scroll to top</span>
|
|
</button>
|
|
) : (
|
|
<div className="mx-4 flex items-center justify-center gap-1.5 px-3 py-1.5 bg-primary/8 border border-primary/15 border-t-0 text-primary text-[11px] font-medium tracking-wide font-mono rounded-b">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
|
|
Live Packets
|
|
</div>
|
|
)}
|
|
|
|
{isLoading && packets.length === 0 ? (
|
|
<SkeletonRows />
|
|
) : (
|
|
<PacketVirtualList
|
|
key={listResetKey}
|
|
packets={packets}
|
|
hasNextPage={hasNextPage}
|
|
isFetching={isFetching}
|
|
fetchNextPage={fetchNextPage}
|
|
onScrollAwayFromTop={setIsScrolledAway}
|
|
onAtTopChange={setIsAtTop}
|
|
expandedHash={expandedHash}
|
|
onToggleExpand={handleToggleExpand}
|
|
onOpenAnalyzer={handleOpenAnalyzer}
|
|
onViewPath={handleViewPath}
|
|
selectedObservationId={selectedObservationId}
|
|
onSelectObservation={onSelectObservation}
|
|
/>
|
|
)}
|
|
<LoadingPill
|
|
loading={isLoading || isFetchingNextPage}
|
|
error={isError}
|
|
count={packets.length}
|
|
noun="packets"
|
|
position="bottom-3 right-3"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|