diff --git a/src/App.tsx b/src/App.tsx index 663a643..afb2274 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -135,18 +135,20 @@ function AppInner() { // packet analyzer shown as a modal over the node panel (clicking a node's observation row) const [overlayPacketHash, setOverlayPacketHash] = useState(null); + // short staleTime: observations keep accruing, so reopening the analyzer should show them + // instead of a snapshot frozen at first open const { data: analyzerDetail, isLoading: analyzerLoading } = useQuery({ queryKey: ["packet-detail", analyzerHash], queryFn: () => getPacketDetail(analyzerHash!), enabled: !!analyzerHash, - staleTime: Infinity, + staleTime: 30_000, }); const { data: overlayPacketDetail, isLoading: overlayPacketLoading } = useQuery({ queryKey: ["packet-detail", overlayPacketHash], queryFn: () => getPacketDetail(overlayPacketHash!), enabled: !!overlayPacketHash, - staleTime: Infinity, + staleTime: 30_000, }); const handleAnalyze = useCallback((hash: string | null) => { @@ -168,6 +170,12 @@ function AppInner() { setSearchParams((prev) => { const next = new URLSearchParams(prev); next.set("tab", tab); + // stats sub-state shouldn't haunt the URL on other tabs + if (tab !== "Stats") { + next.delete("statsTab"); + next.delete("observerId"); + next.delete("range"); + } return next; }); }; diff --git a/src/api/ws-manager.ts b/src/api/ws-manager.ts index 981264b..93f71a4 100644 --- a/src/api/ws-manager.ts +++ b/src/api/ws-manager.ts @@ -176,7 +176,7 @@ export class WsManager { this.sendSubscribe(); if (isReconnect) { // we were dark during the outage — synthesize a lag notice so live views heal the gap - const notice: WsLagged = { v: 1, type: "lagged", droppedCount: 0, since: this.lastEventTimestamp, lastObservationId: 0 }; + const notice: WsLagged = { v: 1, type: "lagged", droppedCount: 0, since: this.lastEventTimestamp }; for (const handler of this.laggedHandlers) { handler(notice); } diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 87f053a..bebdabc 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -19,15 +19,18 @@ export class ErrorBoundary extends Component { render() { if (this.state.error) { if (this.props.fallback) return this.props.fallback; + // React.lazy caches a rejected import, so remounting can't recover a failed chunk load + // (e.g. a deploy swapped the hashed assets out from under us) — only a real reload can + const chunkFailure = /dynamically imported module|Loading chunk|error loading/i.test(this.state.error.message); return (

Something went wrong rendering this view.

); diff --git a/src/features/map/MapSettingsPanel.tsx b/src/features/map/MapSettingsPanel.tsx index 54ce2a9..5b88cf0 100644 --- a/src/features/map/MapSettingsPanel.tsx +++ b/src/features/map/MapSettingsPanel.tsx @@ -38,12 +38,15 @@ export function MapSettingsPanel({ return stored === null ? !isMobile : stored === "true"; }); - const toggle = () => - setOpen((v) => { - const next = !v; + const toggle = () => { + const next = !open; + setOpen(next); + try { localStorage.setItem(OPEN_STORAGE_KEY, String(next)); - return next; - }); + } catch { + // private mode / quota — the toggle still works, just not persisted + } + }; return (
diff --git a/src/features/map/MapView.tsx b/src/features/map/MapView.tsx index 8512160..6e7d7e3 100644 --- a/src/features/map/MapView.tsx +++ b/src/features/map/MapView.tsx @@ -96,7 +96,7 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp const { containerRef, mapRef, isReady, error } = useMapLibre(styleId, fitPoints, handleStyleError); const isDark = resolveMapStyle(styleId).dark; // drives marker theming + maplibre control chrome - useMapNodes(mapRef, isReady, geojson, isDark, themeKey, clustered, onSelectNode, selectedNodeId); + useMapNodes(mapRef, isReady, geojson, isDark, themeKey, clustered, onSelectNode, selectedNodeId, `${regionKey}:${typeFilter}`); return (
diff --git a/src/features/map/useMapNodes.ts b/src/features/map/useMapNodes.ts index 4f1998f..545811d 100644 --- a/src/features/map/useMapNodes.ts +++ b/src/features/map/useMapNodes.ts @@ -98,8 +98,12 @@ export function useMapNodes( clustered: boolean, onSelectNode: (id: string) => void, selectedNodeId: string | null, + // identity of the dataset (region + type filter); an open spiderfy fan closes when it changes, + // since its leaves were drawn from the previous dataset + resetKey = "", ) { const geojsonRef = useRef(geojson); + const spiderRef = useRef(null); const onSelectNodeRef = useRef(onSelectNode); const selectedNodeIdRef = useRef(selectedNodeId); const appliedClusteredRef = useRef(clustered); @@ -338,6 +342,7 @@ export function useMapNodes( spiderLeavesLayout: SPIDER_LEAVES_LAYOUT, }); spider.applyTo(NODES_CLUSTER_LAYER_ID); + spiderRef.current = spider; // @nazka/map-gl-js-spiderfy registers its cluster-click handler inside a one-shot map.once("idle"). // With 3D terrain that idle often doesn't fire before this effect re-runs, so the handler never // attaches (clusters look unclickable) or attaches late as an orphan after cleanup. Run that @@ -384,6 +389,7 @@ export function useMapNodes( map.off("mouseenter", layer, setPointer); map.off("mouseleave", layer, clearPointer); } + spiderRef.current = null; try { spider.unspiderfyAll(); } catch { @@ -392,4 +398,13 @@ export function useMapNodes( }; // themeKey is a dep so spiderfy rebuilds and its legs + leaf icons pick up the new palette }, [mapRef, isReady, clustered, themeKey]); + + // close any open fan when the dataset identity changes — its leaves no longer exist + useEffect(() => { + try { + spiderRef.current?.unspiderfyAll(); + } catch { + /* map may already be removed */ + } + }, [resetKey]); } diff --git a/src/features/stats/ObserverTab.tsx b/src/features/stats/ObserverTab.tsx index 625b6ec..dd8823c 100644 --- a/src/features/stats/ObserverTab.tsx +++ b/src/features/stats/ObserverTab.tsx @@ -29,7 +29,12 @@ function ObserverList({ }) { const { iatas, regionKey } = useRegion(); const [query, setQuery] = useState(""); - const q = query.trim(); + // debounce so the server-side lookup fires once per pause, not once per keystroke + const [q, setQ] = useState(""); + useEffect(() => { + const id = setTimeout(() => setQ(query.trim()), 250); + return () => clearTimeout(id); + }, [query]); const searching = q.length > 0; // default: top observers by activity (with count + activity bar). Searching swaps to a server-side @@ -63,7 +68,10 @@ function ObserverList({ />
{loading &&
{searching ? "Searching…" : "Loading…"}
} - {!loading && rows.length === 0 && ( + {searching && search.isError && ( +
Search failed
+ )} + {!loading && !(searching && search.isError) && rows.length === 0 && (
{searching ? "No matches" : "No observers"}
)} {rows.map((r) => { diff --git a/src/types/ws.ts b/src/types/ws.ts index 73287ed..3a0590a 100644 --- a/src/types/ws.ts +++ b/src/types/ws.ts @@ -67,9 +67,9 @@ export interface WsObserverStatus { iata: string; online: boolean; radio?: string; // compact "freq,bw,sf" string - scopes: string[]; - batteryMv: number | null; - uptimeSeconds: number | null; + scopes: string[] | null; // a nil slice marshals to null + batteryMv?: number; // omitempty — absent when unknown (0) + uptimeSeconds: number; lastStatusAt: number; }; } @@ -107,7 +107,7 @@ export interface WsLagged { type: "lagged"; droppedCount: number; since: number; - lastObservationId: number; + lastObservationId?: number; // declared in the protocol but not sent today } export interface WsError {