mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-23 14:32:28 +00:00
round out the review fixes: chunk reload, spiderfy reset, search debounce
- a failed lazy chunk now offers 'reload' (React.lazy caches the rejection, so remounting could never recover Map/Stats) - an open spiderfy fan closes on region/type-filter change instead of showing leaves from the previous dataset - map settings persistence moved out of the setState updater - observer search debounces 250ms and shows 'Search failed' instead of 'No matches' on errors - packet-detail staleTime drops to 30s so a reopened analyzer picks up new observations - leaving Stats clears its statsTab/observerId/range URL params - ws types now match the wire (batteryMv omitted not null, scopes can be null, lastObservationId isn't sent today); drop the unused microToDeg shim now that nothing imports it
This commit is contained in:
1 parent
ad4e68f817
commit
4d6d76e451
8 files changed
+54
-17
No files matched your search
+10
-2
@@ -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<string | null>(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;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -19,15 +19,18 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
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 (
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-8 text-text-dim">
|
||||
<p className="text-sm font-mono">Something went wrong rendering this view.</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-mono underline cursor-pointer text-primary"
|
||||
onClick={() => this.setState({ error: null })}
|
||||
onClick={() => (chunkFailure ? window.location.reload() : this.setState({ error: null }))}
|
||||
>
|
||||
try again
|
||||
{chunkFailure ? "reload" : "try again"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="absolute top-3 left-3 z-10 w-60 max-w-[calc(100vw-1.5rem)] bg-bg-raised border border-border rounded-md shadow-lg overflow-hidden font-mono">
|
||||
|
||||
@@ -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 (
|
||||
<div className="relative flex flex-1 min-h-0">
|
||||
|
||||
@@ -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<Spiderfy | null>(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]);
|
||||
}
|
||||
@@ -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({
|
||||
/>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{loading && <div className="py-6 text-center font-mono text-[11px] text-text-dim">{searching ? "Searching…" : "Loading…"}</div>}
|
||||
{!loading && rows.length === 0 && (
|
||||
{searching && search.isError && (
|
||||
<div className="py-6 text-center font-mono text-[11px] text-danger">Search failed</div>
|
||||
)}
|
||||
{!loading && !(searching && search.isError) && rows.length === 0 && (
|
||||
<div className="py-6 text-center font-mono text-[11px] text-text-dim">{searching ? "No matches" : "No observers"}</div>
|
||||
)}
|
||||
{rows.map((r) => {
|
||||
|
||||
+4
-4
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user