Keep the packet drawer and windows open across tabs (#120)

This commit is contained in:
MrAlders0n
2026-10-01 04:09:00 -04:00
committed by GitHub
parent 66419a768e
commit a8a248fd9d
8 changed files with 58 additions and 112 deletions
+14 -48
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, useRef, lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route, useLocation, useNavigate, useSearchParams, type Location } from "react-router-dom";
import { BrowserRouter, Routes, Route, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RegionProvider, useRegion, useRegionSelection } from "./hooks/useRegion";
@@ -148,14 +148,10 @@ function TabLoading({ tab }: { tab: string }) {
return <EmptyState title={t(`tabs.${tab}`, { defaultValue: tab })} subtitle={t("common.loading")} />;
}
function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: {
observerVisit?: Location; onObserverDashboard: (id: string) => void;
onReturn: () => void; onExitVisit: () => void;
}) {
const { t } = useTranslation();
function AppInner() {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const isMobile = useIsMobile();
const location = useLocation();
// The URL is the single source of truth for the active tab — back/forward just work, and an
// unknown ?tab value falls back to Packets instead of rendering a blank pane.
// "Stats" was renamed to "Analytics"; keep old ?tab=Stats links working.
@@ -170,11 +166,10 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }:
const [selectedObservationId, setSelectedObservationId] = useState<number | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(() => searchParams.get("node"));
const [panels, setPanels] = useState<Investigation[]>([]);
const scene = JSON.stringify([activeTab, searchParams.get("route"), searchParams.get("hash"), searchParams.get("node"), searchParams.get("observer"), searchParams.get("analyze")]);
const scene = JSON.stringify([searchParams.get("hash"), searchParams.get("node"), searchParams.get("analyze")]);
const [panelScene, setPanelScene] = useState(scene);
// Browser Back to another entity ends its panels; dashboard visits freeze this location instead.
// Moving to another packet or node ends its panels; switching tabs keeps them (desktop).
if (panelScene !== scene) { setPanelScene(scene); setPanels([]); }
const dashboardFocus = useRef<HTMLElement | null>(null);
const openPanel = useCallback((target: InvestigationTarget) => {
const key = target.kind === "packet" ? `packet:${target.hash.toLowerCase()}:${target.observationId ?? ""}` : target.kind === "path" ? `path:${target.detail.packetHash.toLowerCase()}:${target.selectedKey ?? "all"}` : `${target.kind}:${target.id}`;
setPanels(previous => {
@@ -189,10 +184,6 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }:
const [pathLink] = useState(() => ({ path: searchParams.get("path"), hash: searchParams.get("hash") }));
const { data: analyzerDetail, isLoading: analyzerLoading } = usePacketDetail(analyzerHash);
useEffect(() => {
if (!observerVisit && dashboardFocus.current?.isConnected) dashboardFocus.current.focus();
}, [observerVisit]);
const closePanel = (index: number) => {
setPanels(previous => previous.slice(0, index));
if (panels[index]?.target.kind === "path" && searchParams.has("path")) setSearchParams(previous => {
@@ -220,11 +211,9 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }:
}, [setSearchParams]);
const handleTabChange = (tab: string, mapFocus?: { lat: number; lng: number }) => {
setPanels([]);
dashboardFocus.current = null;
onExitVisit();
// On mobile a detail panel (and the analyzer) fills the screen, so leaving its tab must close it;
// desktop side panels persist across tabs. Cross-nav (onViewObserver) re-sets its selection after this.
// desktop side panels persist across tabs. Map and Analytics start without carried-over windows.
if (isMobile || tab === "Map" || tab === "Analytics") setPanels([]);
if (isMobile) {
setSelectedObservationId(null);
setSelectedNodeId(null);
@@ -265,9 +254,7 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }:
const clearSelection = useCallback(() => {
setSelectedNodeId(null);
setPanels([]);
dashboardFocus.current = null;
onExitVisit();
}, [onExitVisit, setSelectedNodeId, setPanels]);
}, [setSelectedNodeId, setPanels]);
// Closing a detail panel drops its deep-link param so a reload can't reopen it (mirrors the packet
// analyzer's ?analyze cleanup). Selecting a different node/observer doesn't touch the URL — the panel's
@@ -286,9 +273,8 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }:
}, [dropSelectionParam, setSelectedNodeId]);
const handleViewObserverStats = useCallback((id: string) => {
dashboardFocus.current = document.activeElement !== document.body ? document.activeElement as HTMLElement | null : null;
onObserverDashboard(id);
}, [onObserverDashboard]);
navigate({ search: "?" + observerDestination(searchParams, id).toString() });
}, [navigate, searchParams]);
useEffect(() => {
// Region slugs can't be expanded yet (region details load async) — connect with the directly
@@ -322,7 +308,7 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }:
return (
<RegionProvider defaultSelection={initialSelection}>
<RegionWatcher wsManager={wsManager} />
<Routes location={observerVisit ?? location}><Route path="*" element={<RegionUrlSync />} /></Routes>
<Routes><Route path="*" element={<RegionUrlSync />} /></Routes>
<SelectionResetOnRegion onRegionChange={clearSelection} />
<PathLinkRestore
initialPath={pathLink.path}
@@ -330,16 +316,14 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }:
analyzerDetail={analyzerDetail}
onRestore={handleViewPath}
/>
<AppShell activeTab={observerVisit ? "Observers" : activeTab} onTabChange={handleTabChange} wsManager={wsManager}>
<AppShell activeTab={activeTab} onTabChange={handleTabChange} wsManager={wsManager}>
<div className="relative flex flex-1 min-h-0 min-w-0">
{/* Preserve measurable geometry: display:none makes virtualized lists lose their offset. */}
<div aria-hidden={observerVisit ? true : undefined} inert={!!observerVisit} className={`${observerVisit ? "invisible absolute inset-0 pointer-events-none" : "relative"} flex flex-1 min-h-0 min-w-0`}>
<div key={activeTab} className="flex flex-1 min-h-0 min-w-0 fade-in">
<Suspense fallback={<TabLoading tab={activeTab} />}>
{tabContent[activeTab]}
</Suspense>
</div>
{analyzerHash && (activeTab === "Packets" || activeTab === "Channels") && (
{analyzerHash && activeTab !== "Map" && activeTab !== "Analytics" && (
<PacketAnalyzerDrawer
detail={analyzerDetail}
loading={analyzerLoading}
@@ -363,36 +347,18 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }:
)}
{panels.map((panel, index) => <InvestigationPanel key={panel.key} target={panel.target} inactive={index !== panels.length - 1} onClose={() => closePanel(index)} onOpen={openPanel} onObserverDashboard={handleViewObserverStats} onViewOnMap={activeTab === "Map" ? undefined : handleViewOnMap} />)}
</div>
{observerVisit && <Routes location={observerVisit}><Route path="*" element={<ObserverPage wsManager={wsManager} onReturn={onReturn} returnLabel={t(`tabs.${activeTab}`)} />} /></Routes>}
</div>
</AppShell>
</RegionProvider>
);
}
// A dashboard visit keeps one originating screen under its original location context. A reload has
// no in-memory origin, so copied/canonical dashboard URLs remain standalone destinations.
function AppNavigation() {
const location = useLocation();
const navigate = useNavigate();
const [origin, setOrigin] = useState<Location | null>(null);
const visiting = origin != null && location.state?.beaconObserverReturnKey === origin.key && new URLSearchParams(location.search).get("tab") === "Observers";
if (origin && !visiting && location.key !== origin.key) setOrigin(null);
const exitVisit = useCallback(() => setOrigin(null), []);
const openDashboard = (id: string) => {
setOrigin(location);
navigate({ search: "?" + observerDestination(new URLSearchParams(location.search), id).toString() }, { state: { beaconObserverReturnKey: location.key } });
};
return <Routes location={visiting ? origin : location}><Route path="*" element={<AppInner observerVisit={visiting ? location : undefined} onObserverDashboard={openDashboard} onReturn={() => navigate(-1)} onExitVisit={exitVisit} />} /></Routes>;
}
export function App() {
return (
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<SplashScreen />
<AppNavigation />
<AppInner />
</ThemeProvider>
</QueryClientProvider>
</BrowserRouter>
+8 -16
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense, useState, useEffect, useMemo, useRef } from "react";
import { useLocation, useSearchParams } from "react-router-dom";
import { lazy, Suspense, useState, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { getBrokers } from "../../api/client";
@@ -19,13 +19,8 @@ const ObserverTab = lazy(() => import("../stats/ObserverTab").then(m => ({ defau
const NAV_CHIP = "inline-flex items-center gap-1.5 rounded-sm border border-border bg-bg-raised px-2 py-0.5 font-mono text-[11px] text-text-normal transition-colors hover:border-text-dim hover:text-text-bright";
export function ObserverPage({ wsManager, onReturn, returnLabel }: { wsManager: WsManager; onReturn?: () => void; returnLabel?: string }) {
export function ObserverPage({ wsManager }: { wsManager: WsManager }) {
const [params, setParams] = useSearchParams();
const location = useLocation();
const returnButton = useRef<HTMLButtonElement>(null);
useEffect(() => { returnButton.current?.focus(); }, []);
// Dashboard adjustments belong to this visit; Back returns to the original investigation.
const visitOptions = onReturn ? { replace: true, state: location.state } : undefined;
const { t } = useTranslation();
const now = useTick(60_000);
const id = params.get("observer");
@@ -50,7 +45,7 @@ export function ObserverPage({ wsManager, onReturn, returnLabel }: { wsManager:
const share = new URL(window.location.pathname, window.location.origin); share.search = params.toString();
if (id) share.searchParams.set("range", range);
if (comparing && until != null) share.searchParams.set("compareUntil", String(until));
const select = (observer: string | null) => setParams(observerDestination(params, observer, range), visitOptions);
const select = (observer: string | null) => setParams(observerDestination(params, observer, range));
const compare = (observer: string) => {
// eslint-disable-next-line react-hooks/purity -- Capture time when the user invokes this event callback.
const clickedAt = Date.now();
@@ -60,9 +55,9 @@ export function ObserverPage({ wsManager, onReturn, returnLabel }: { wsManager:
next.set("compareWith", observer);
next.set("compareUntil", String(comparing ? until ?? hourAt(clickedAt) : hourAt(clickedAt)));
return next;
}, visitOptions);
});
};
const closeCompare = () => setParams(old => { const next = new URLSearchParams(old); next.delete("compareWith"); next.delete("compareUntil"); return next; }, visitOptions);
const closeCompare = () => setParams(old => { const next = new URLSearchParams(old); next.delete("compareWith"); next.delete("compareUntil"); return next; });
const refreshCompare = () => {
const clickedAt = Date.now();
setActionTime(clickedAt);
@@ -70,20 +65,17 @@ export function ObserverPage({ wsManager, onReturn, returnLabel }: { wsManager:
const next = new URLSearchParams(old);
next.set("compareUntil", String(hourAt(clickedAt)));
return next;
}, { replace: true, state: location.state });
}, { replace: true });
return hourAt(clickedAt);
};
return <div className="flex min-h-0 min-w-0 flex-1 flex-col">
{onReturn && <div className="flex items-center border-b border-border px-4 py-2">
<button ref={returnButton} type="button" aria-label={t("observerPage.returnTo", { page: returnLabel })} onClick={onReturn} className={NAV_CHIP}><span aria-hidden>‹</span>{returnLabel}</button>
</div>}
<ObserverFilterBar
search={search} onSearchChange={setSearch} searchField={searchField} onSearchFieldChange={setSearchField}
statusFilter={status} onStatusChange={setStatus} typeFilter={type} onTypeChange={setType} typeOptions={typeOptions}
brokerFilter={broker} onBrokerChange={setBroker} brokerOptions={brokers?.map((b) => b.name) ?? []}
scopeFilter={scope} onScopeChange={setScope} scopeOptions={scopeOptions}
trailing={<span className="ml-auto flex items-center gap-2 font-mono text-[11px] text-text-muted"><span className="hidden sm:inline">{t("observerPage.range")}</span>
<Segmented ariaLabel={t("observerPage.range")} size="sm" value={range} options={[{ value: "24h", label: t("stats.ranges.24h") }, { value: "7d", label: t("stats.ranges.7d") }, { value: "30d", label: t("stats.ranges.30d") }]} onChange={v => setParams(observerDestination(params, id, observerRange(v)), visitOptions)} />
<Segmented ariaLabel={t("observerPage.range")} size="sm" value={range} options={[{ value: "24h", label: t("stats.ranges.24h") }, { value: "7d", label: t("stats.ranges.7d") }, { value: "30d", label: t("stats.ranges.30d") }]} onChange={v => setParams(observerDestination(params, id, observerRange(v)))} />
</span>}
/>
<div className="flex min-h-0 min-w-0 flex-1">
@@ -5,7 +5,7 @@ export const observerRange = (value: string | null): StatsRange => value === "24
export function observerDestination(params: URLSearchParams, id: string | null, range = observerRange(params.get("range"))) {
const next = new URLSearchParams(params);
next.set("tab", "Observers");
for (const key of ["statsTab", "observerId", "node", "hash", "analyze", "path", "observation", "route", "routeIata", "routeRange", "routeSince", "routeUntil", "compareA", "compareB", "compareSince"]) next.delete(key);
for (const key of ["statsTab", "observerId", "node", "route", "routeIata", "routeRange", "routeSince", "routeUntil", "compareA", "compareB", "compareSince"]) next.delete(key);
if (id) { next.set("observer", id); next.set("range", range); }
else { next.delete("observer"); next.delete("range"); next.delete("compareWith"); next.delete("compareUntil"); }
return next;
-1
View File
@@ -309,7 +309,6 @@
"recording": "Loading observer…",
"hour": "hour",
"day": "day",
"returnTo": "Back to {{page}}",
"directory": "All observers",
"sidebarSearch": "Search observers",
"noMatches": "No matches",
-1
View File
@@ -313,7 +313,6 @@
"recording": "Chargement de l’observateur…",
"hour": "heure",
"day": "jour",
"returnTo": "Retour à « {{page}} »",
"directory": "Tous les observateurs",
"sidebarSearch": "Rechercher les observateurs",
"noMatches": "Aucun résultat",
+28 -38
View File
@@ -51,7 +51,7 @@ const click = (name: string) => { const button = screen.getByRole("button", { na
const escape = () => fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
const browserBack = async () => { await act(async () => { window.history.back(); }); await waitFor(() => expect(window.location.search).toContain("tab=Routes")); };
describe("observer investigation return", () => {
describe("observer investigation windows", () => {
it("dismisses only the observer with Escape and returns keyboard focus", () => {
render(<App />); click("Route observer"); escape();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
@@ -77,32 +77,35 @@ describe("observer investigation return", () => {
expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Observer o1");
escape(); expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Packet aa report 8");
});
it("returns from a changed dashboard window to the same route, filter, scroll and observer", async () => {
render(<App />);
fireEvent.change(screen.getByLabelText("Route filter"), { target: { value: "roof" } });
screen.getByTestId("route-scroll").scrollTop = 180;
click("Route observer"); click("Open dashboard");
it("opens the dashboard as a normal tab with every window left open", async () => {
render(<App />); click("Route packet"); click("Packet observer"); click("Open dashboard");
expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o1 7d");
expect(window.location.search).toContain("tab=Observers&observer=o1");
expect(screen.getByTestId("origin-url")).toHaveTextContent("route=full-route");
fireEvent.click(within(screen.getByRole("group", { name: "Time range" })).getByRole("button", { name: "24h" }));
click("Compare with…");
expect(window.location.search).toContain("compareWith=");
click("Back to Routes");
await waitFor(() => expect(window.location.search).toContain("tab=Routes"));
expect(screen.getByLabelText("Route filter")).toHaveValue("roof");
expect(screen.getByTestId("route-scroll").scrollTop).toBe(180);
await waitFor(() => expect(screen.getByRole("button", { name: "Open dashboard" })).toHaveFocus());
escape(); expect(screen.getByRole("button", { name: "Route observer" })).toHaveFocus();
expect(screen.queryByRole("button", { name: "Back to Routes" })).not.toBeInTheDocument();
expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Observer o1");
escape(); expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Packet aa");
});
it("supports browser Back/Forward without rebuilding the originating screen", async () => {
render(<App />); fireEvent.change(screen.getByLabelText("Route filter"), { target: { value: "keep" } });
click("Route observer"); click("Open dashboard");
it("returns to the previous tab with browser Back", async () => {
render(<App />); click("Route observer"); click("Open dashboard");
await screen.findByRole("heading", { level: 1 });
await browserBack(); expect(screen.getByLabelText("Route filter")).toHaveValue("keep");
await act(async () => { window.history.forward(); });
expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o1");
await browserBack(); expect(screen.getByLabelText("Route filter")).toHaveValue("keep");
await browserBack(); expect(screen.getByTestId("route-origin")).toBeInTheDocument();
});
it("keeps the packet drawer and stacked windows across tabs, but not into Analytics", async () => {
await import("../src/features/stats/StatsOverview");
window.history.replaceState({}, "", "/?tab=Routes&hash=aa&analyze=1");
render(<App />);
expect(screen.getByRole("heading", { name: /Packet aa/ })).toBeInTheDocument();
click("Packet node");
click("Observer tab");
expect(await screen.findByRole("heading", { level: 2, name: /Packet aa/ })).toBeInTheDocument();
expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Node n1");
click("Analytics tab");
await screen.findByRole("button", { name: "Leaderboard observer" });
expect(screen.queryByRole("heading", { name: /Packet aa/ })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
click("Route tab");
expect(await screen.findByRole("heading", { name: /Packet aa/ })).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("opens the Observers dashboard from the leaderboard", async () => {
await import("../src/features/stats/StatsOverview");
@@ -122,23 +125,10 @@ describe("observer investigation return", () => {
expect(window.location.search).toContain("tab=Observers");
expect(window.location.search).not.toContain("statsTab");
});
it("does not invent a return destination for a reloaded/shared dashboard", async () => {
window.history.replaceState({ usr: { beaconObserverReturnKey: "old" } }, "", "/?tab=Observers&observer=o2&range=24h");
render(<App />); expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o2 24h");
expect(screen.queryByRole("button", { name: "Back to Routes" })).not.toBeInTheDocument();
click("Back to observers"); expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument();
expect(screen.getByText("Observer directory")).toBeInTheDocument();
});
it("ends the retained investigation when the operator changes tabs or regions", async () => {
it("closes the stacked windows when the region changes", async () => {
render(<App />); click("Route observer"); click("Open dashboard"); await screen.findByRole("heading", { level: 1 });
click("Change region");
await waitFor(() => expect(screen.queryByRole("button", { name: "Back to Routes" })).not.toBeInTheDocument());
expect(screen.queryByTestId("route-origin")).not.toBeInTheDocument();
click("Route tab"); expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("translates the return action", async () => {
await i18n.changeLanguage("fr"); render(<App />); click("Route observer"); click("Open dashboard");
expect(await screen.findByRole("button", { name: /Retour à « Routes »/ })).toBeInTheDocument();
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
});
it("clears comparison params from both the observer dashboard and analytics compare tabs on tab change", () => {
window.history.replaceState({}, "", "/?tab=Observers&observer=o1&compareWith=o2&compareUntil=1700000000000&compareA=x&compareB=y&compareSince=1");
+4 -4
View File
@@ -141,7 +141,7 @@ afterEach(() => {
});
describe("Packets deep links", () => {
it("returns from an observer dashboard to the same packet, report and filters", async () => {
it("keeps the packet drawer beside the observer dashboard and returns to the same packet, report and filters", async () => {
setMobile(false);
window.history.pushState({}, "", "/?tab=Packets&hash=AA11&analyze=1&observation=1&iata=YOW&q=missing");
render(<App />);
@@ -150,10 +150,10 @@ describe("Packets deep links", () => {
fireEvent.click(within(section).getByRole("button", { name: "Inspect observer" }));
fireEvent.click(await screen.findByRole("button", { name: "Open observer dashboard" }));
expect(await screen.findByRole("heading", { name: "Observer dashboard" })).toBeInTheDocument();
expect(new URLSearchParams(window.location.search).has("observation")).toBe(false);
expect(new URLSearchParams(window.location.search).get("observation")).toBe("1");
expect(screen.getByTestId("packet-analyzer-drawer")).toBeInTheDocument();
act(() => window.history.back());
// The originating drawer now stays mounted while hidden; wait for history, not its existence.
await waitFor(() => expect(new URLSearchParams(window.location.search).get("hash")).toBe("AA11"));
await waitFor(() => expect(new URLSearchParams(window.location.search).get("tab")).toBe("Packets"));
expect(await screen.findByTestId("packet-analyzer-drawer")).toBeInTheDocument();
const params = new URLSearchParams(window.location.search);
expect(params.get("hash")).toBe("AA11"); expect(params.get("observation")).toBe("1");
@@ -15,9 +15,9 @@ describe("observerDestination", () => {
expect(p.has("statsTab")).toBe(false);
});
it("drops unrelated investigation selectors and defaults the range to 7d", () => {
const p = observerDestination(new URLSearchParams("hash=abc&analyze=1&node=n&path=p"), "b");
expect(p.toString()).toBe("tab=Observers&observer=b&range=7d");
it("keeps the open packet analyzer, drops other tabs' selectors and defaults the range to 7d", () => {
const p = observerDestination(new URLSearchParams("hash=abc&analyze=1&observation=5&path=p&node=n&route=r"), "b");
expect(Object.fromEntries(p)).toEqual({ hash: "abc", analyze: "1", observation: "5", path: "p", tab: "Observers", observer: "b", range: "7d" });
});
it("drops the analytics compare params so they don't ride along into the dashboard URL", () => {