From 6314b2a34b375f8c14c4c3d04b33d34bb3160d6e Mon Sep 17 00:00:00 2001 From: Cheewye Date: Mon, 2 Mar 2026 09:29:41 -0300 Subject: [PATCH 1/3] fix(sap): stabilize PNBOIA lifecycle, de-duplicate Layers UI, and restore FMAP signals seed Made-with: Cursor --- .../components/maps/PnboiaLayerLeaflet.tsx | 120 ++++++- .../components/maps/realtime/FisherDock.tsx | 301 ++++++++++++++++-- .../maps/realtime/FmapSignalsLayerLeaflet.tsx | 208 ++++++++++-- .../maps/realtime/LayerManagerPanel.tsx | 4 +- .../maps/realtime/MapLayersPanel.tsx | 264 +++++++++++++++ .../maps/realtime/RealTimeMapCore.tsx | 46 ++- .../maps/realtime/SeamarksInspectorHost.tsx | 11 - .../src/components/maps/realtime/constants.ts | 3 +- frontend/src/config/apiBase.ts | 19 +- frontend/src/data/fmapSignalsSeed.ts | 27 +- 10 files changed, 902 insertions(+), 101 deletions(-) diff --git a/frontend/src/components/maps/PnboiaLayerLeaflet.tsx b/frontend/src/components/maps/PnboiaLayerLeaflet.tsx index 95c93bf0f..d6bc9e264 100644 --- a/frontend/src/components/maps/PnboiaLayerLeaflet.tsx +++ b/frontend/src/components/maps/PnboiaLayerLeaflet.tsx @@ -70,6 +70,30 @@ const normalizeBuoys = (rawItems: any[]): BuoyData[] => }) .filter((item): item is BuoyData => item !== null); +function resolvePnboiaUrl(lastFetchUrl: string | null): string | null { + if (!lastFetchUrl) return null; + const raw = String(lastFetchUrl); + if (raw.startsWith("http://") || raw.startsWith("https://")) return raw; + return `${window.location.origin}${raw.startsWith("/") ? raw : `/${raw}`}`; +} + +function inferRequestOutcome(args: { + loading: boolean; + fetchErrorName: string | null; + lastAttempt: { outcome: string | null } | null; + lastHttpStatus: number | null; + lastError: string | null; +}): "ok" | "aborted" | "blocked" | "pending" { + if (args.loading) return "pending"; + const outcome = args.lastAttempt?.outcome || null; + if (outcome === "ok") return "ok"; + if (outcome === "abort" || args.fetchErrorName === "AbortError") return "aborted"; + if (outcome === "error" || outcome === "empty") return "blocked"; + if (args.lastHttpStatus && args.lastHttpStatus >= 200 && args.lastHttpStatus < 400) return "ok"; + if (args.lastError) return "blocked"; + return "pending"; +} + export const PnboiaLayer: React.FC = ({ maxVisible = 50, showLabels = false, @@ -120,11 +144,18 @@ export const PnboiaLayer: React.FC = ({ const [lastFetchUrl, setLastFetchUrl] = useState("/api/v1/pnboia/list"); const [lastOkAtISO, setLastOkAtISO] = useState(null); const [loadingMs, setLoadingMs] = useState(0); + const [abortReason, setAbortReason] = useState<"timeout" | "cleanup" | null>(null); const [retryNonce, setRetryNonce] = useState(0); const [mapViewKey, setMapViewKey] = useState(0); const inFlightRef = useRef(false); const loadingStartedAtRef = useRef(null); const staleCacheRef = useRef<{ ts: number; buoys: BuoyData[] } | null>(null); + const abortReasonRef = useRef<"timeout" | "cleanup" | null>(null); + + const onBuoysLoadedRef = useRef(onBuoysLoaded); + const onLoadingChangeRef = useRef(onLoadingChange); + const onErrorChangeRef = useRef(onErrorChange); + const onDebugInfoChangeRef = useRef(onDebugInfoChange); const paneName = pane || "pnboia"; @@ -149,8 +180,50 @@ export const PnboiaLayer: React.FC = ({ return () => window.removeEventListener("iuri:pnboia-retry", onRetry as EventListener); }, []); - useEffect(() => onLoadingChange?.(loading), [loading, onLoadingChange]); - useEffect(() => onErrorChange?.(lastError), [lastError, onErrorChange]); + useEffect(() => { + onBuoysLoadedRef.current = onBuoysLoaded; + }, [onBuoysLoaded]); + useEffect(() => { + onLoadingChangeRef.current = onLoadingChange; + }, [onLoadingChange]); + useEffect(() => { + onErrorChangeRef.current = onErrorChange; + }, [onErrorChange]); + useEffect(() => { + onDebugInfoChangeRef.current = onDebugInfoChange; + }, [onDebugInfoChange]); + + useEffect(() => onLoadingChangeRef.current?.(loading), [loading]); + useEffect(() => onErrorChangeRef.current?.(lastError), [lastError]); + + useEffect(() => { + loadedBuoysRef.current = loadedBuoys; + }, [loadedBuoys]); + + useEffect(() => { + // Cold start: hydrate last-good from localStorage cache (best-effort). + try { + const raw = localStorage.getItem(CACHE_KEY); + if (!raw) return; + const parsed = JSON.parse(raw) as any; + const ts = Number(parsed?.ts); + const buoys = Array.isArray(parsed?.buoys) ? parsed.buoys : null; + if (!Number.isFinite(ts) || !buoys) return; + const normalized = normalizeBuoys(buoys); + if (normalized.length === 0) return; + const ageMs = Math.max(0, Date.now() - ts); + if (ageMs <= CACHE_TTL_MS) { + cacheTsRef.current = ts; + setLoadedBuoys(normalized); + setSource("cache"); + setLastOkAtISO(new Date(ts).toISOString()); + } else { + staleCacheRef.current = { ts, buoys: normalized }; + } + } catch { + // ignore + } + }, []); useEffect(() => { loadedBuoysRef.current = loadedBuoys; @@ -207,6 +280,8 @@ export const PnboiaLayer: React.FC = ({ setFetchErrorName(null); setLastHttpStatus(null); setLastError(null); + setAbortReason(null); + abortReasonRef.current = null; const nextAttempt = attemptRef.current + 1; attemptRef.current = nextAttempt; @@ -223,7 +298,10 @@ export const PnboiaLayer: React.FC = ({ let cancelled = false; const controller = new AbortController(); - const abortTimer = window.setTimeout(() => controller.abort(), ABORT_MS); + const abortTimer = window.setTimeout(() => { + abortReasonRef.current = "timeout"; + controller.abort(); + }, ABORT_MS); const url = "/api/v1/pnboia/list"; setLastFetchUrl(url); @@ -270,7 +348,7 @@ export const PnboiaLayer: React.FC = ({ } catch { // ignore } - onBuoysLoaded?.(items.length); + onBuoysLoadedRef.current?.(items.length); setLastAttempt((prev) => prev ? { ...prev, endedAtISO: new Date().toISOString(), elapsedMs: now - startedAt, outcome: "ok" } : prev ); @@ -286,6 +364,7 @@ export const PnboiaLayer: React.FC = ({ // Fail-open: keep the last loaded list, only update error. setLastError(message); setFetchErrorName(isAbort ? "AbortError" : errName); + setAbortReason(isAbort ? (abortReasonRef.current ?? "cleanup") : null); // keep current list (fail-open); keep source as-is (live/cache) setLastAttempt((prev) => prev @@ -323,10 +402,13 @@ export const PnboiaLayer: React.FC = ({ return () => { cancelled = true; window.clearTimeout(abortTimer); + if (!controller.signal.aborted) { + abortReasonRef.current = abortReasonRef.current ?? "cleanup"; + } controller.abort(); inFlightRef.current = false; }; - }, [ABORT_MS, onBuoysLoaded, retryNonce, fallbackBuoys]); + }, [retryNonce]); const { visibleCount, nearest, paddedBounds } = useMemo(() => { const bounds = map.getBounds().pad(0.15); @@ -371,6 +453,14 @@ export const PnboiaLayer: React.FC = ({ const status = loading ? "loading" : lastError ? "error" : loadedBuoys.length > 0 ? "ok" : "empty"; const cacheAgeMs = cacheTsRef.current ? Math.max(0, Date.now() - cacheTsRef.current) : null; const lastOkAgeMs = lastOkTs ? Math.max(0, Date.now() - lastOkTs) : null; + const resolvedUrl = resolvePnboiaUrl(lastFetchUrl); + const requestOutcome = inferRequestOutcome({ + loading, + fetchErrorName, + lastAttempt, + lastHttpStatus, + lastError, + }); window.dispatchEvent( new CustomEvent("iuri:pnboia-status", { detail: { @@ -387,10 +477,14 @@ export const PnboiaLayer: React.FC = ({ slow, slowMs: SLOW_MS, abortMs: ABORT_MS, + abortReason, fetchErrorName, source, cacheAgeMs, lastOkAgeMs, + lastFetchUrl, + resolvedUrl, + requestOutcome, attempt, timing: lastAttempt, }, @@ -403,6 +497,7 @@ export const PnboiaLayer: React.FC = ({ fetchErrorName, lastAttempt, lastError, + lastFetchUrl, lastOkTs, loadedBuoys.length, loading, @@ -420,6 +515,14 @@ export const PnboiaLayer: React.FC = ({ const ne = bounds.getNorthEast(); const cacheAgeMs = cacheTsRef.current ? Math.max(0, Date.now() - cacheTsRef.current) : null; const lastOkAgeMs = lastOkTs ? Math.max(0, Date.now() - lastOkTs) : null; + const resolvedUrl = resolvePnboiaUrl(lastFetchUrl); + const requestOutcome = inferRequestOutcome({ + loading, + fetchErrorName, + lastAttempt, + lastHttpStatus, + lastError, + }); window.dispatchEvent( new CustomEvent("iuri:pnboia-runtime", { detail: { @@ -429,6 +532,7 @@ export const PnboiaLayer: React.FC = ({ loadedCount: loadedBuoys.length, visibleCount, lastFetchUrl, + resolvedUrl, httpStatus: lastHttpStatus, lastOkAtISO, zoom: map.getZoom(), @@ -437,10 +541,12 @@ export const PnboiaLayer: React.FC = ({ slow, slowMs: SLOW_MS, abortMs: ABORT_MS, + abortReason, fetchErrorName, source, cacheAgeMs, lastOkAgeMs, + requestOutcome, attempt, timing: lastAttempt, }, @@ -484,8 +590,8 @@ export const PnboiaLayer: React.FC = ({ lastOkTs, lastError, }; - onDebugInfoChange?.(info); - }, [lastError, lastOkTs, loadedBuoys, map, maxVisible, onDebugInfoChange, visibleCount]); + onDebugInfoChangeRef.current?.(info); + }, [lastError, lastOkTs, loadedBuoys, map, maxVisible, visibleCount]); const toRender = loadedBuoys.slice(0, Math.max(0, maxVisible)); diff --git a/frontend/src/components/maps/realtime/FisherDock.tsx b/frontend/src/components/maps/realtime/FisherDock.tsx index b6bc5f8ef..ecba4e5ed 100644 --- a/frontend/src/components/maps/realtime/FisherDock.tsx +++ b/frontend/src/components/maps/realtime/FisherDock.tsx @@ -17,7 +17,9 @@ type PnboiaRuntime = { status: "idle" | "loading" | "ok" | "empty" | "error"; error: string | null; lastFetchUrl: string | null; + resolvedUrl: string | null; httpStatus: number | null; + requestOutcome: "ok" | "aborted" | "blocked" | "pending" | null; lastOkAtISO: string | null; zoom: number | null; bounds: { sw: { lat: number; lon: number }; ne: { lat: number; lon: number } } | null; @@ -25,6 +27,7 @@ type PnboiaRuntime = { slow: boolean; slowMs: number | null; abortMs: number | null; + abortReason: "timeout" | "cleanup" | null; fetchErrorName: string | null; source: "live" | "cache" | "fallback" | null; cacheAgeMs: number | null; @@ -39,6 +42,21 @@ type PnboiaRuntime = { } | null; }; +type FmapRuntime = { + enabled: boolean; + disabled: boolean; + error: string | null; + counts: { points: number; polygons: number; pending: number }; + bounds: { sw: { lat: number; lon: number }; ne: { lat: number; lon: number } } | null; + sample: Array<{ + id: string; + label: string; + category: string | null; + kind: "point" | "polygon"; + hasCoords: boolean; + }>; +}; + const rowBase = "w-full rounded-lg border border-slate-800/60 bg-slate-950/70 px-2 py-2 text-left text-[12px] text-slate-100 shadow-sm hover:bg-slate-950/85"; @@ -74,7 +92,9 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) status: "idle", error: null, lastFetchUrl: null, + resolvedUrl: null, httpStatus: null, + requestOutcome: null, lastOkAtISO: null, zoom: null, bounds: null, @@ -82,6 +102,7 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) slow: false, slowMs: null, abortMs: null, + abortReason: null, fetchErrorName: null, source: null, cacheAgeMs: null, @@ -91,6 +112,29 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) })); const [pnboiaCopyFeedback, setPnboiaCopyFeedback] = useState(null); const [pnboiaCopyFallbackText, setPnboiaCopyFallbackText] = useState(null); + const [signalsTilesRequested, setSignalsTilesRequested] = useState(0); + const [signalsTilesLoaded, setSignalsTilesLoaded] = useState(0); + const [signalsTilesError, setSignalsTilesError] = useState(0); + const [fmapRuntime, setFmapRuntime] = useState(() => { + const points = FMAP_SIGNALS_SEED.filter((s) => s.kind === "point").length; + const polygons = FMAP_SIGNALS_SEED.filter((s) => s.kind === "polygon").length; + const pending = FMAP_SIGNALS_SEED.filter((s) => { + if (s.kind === "point") return !s.point; + return !s.polygon || s.polygon.length < 3; + }).length; + return { + enabled: false, + disabled: false, + error: null, + counts: { points, polygons, pending }, + bounds: null, + sample: [], + }; + }); + const [fmapDetailsOpen, setFmapDetailsOpen] = useState(false); + const [fmapDetailsLimit, setFmapDetailsLimit] = useState(200); + const [fmapCopyFeedback, setFmapCopyFeedback] = useState(null); + const [fmapCopyFallbackText, setFmapCopyFallbackText] = useState(null); const debugMode = useMemo(() => { try { return new URLSearchParams(window.location.search).get("debug") === "1"; @@ -115,7 +159,12 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) : "idle", error, lastFetchUrl: detail?.lastFetchUrl ? String(detail.lastFetchUrl) : null, + resolvedUrl: detail?.resolvedUrl ? String(detail.resolvedUrl) : null, httpStatus: Number.isFinite(Number(detail?.httpStatus)) ? Number(detail.httpStatus) : null, + requestOutcome: + detail?.requestOutcome === "ok" || detail?.requestOutcome === "aborted" || detail?.requestOutcome === "blocked" || detail?.requestOutcome === "pending" + ? detail.requestOutcome + : null, lastOkAtISO: detail?.lastOkAtISO ? String(detail.lastOkAtISO) : null, zoom: Number.isFinite(Number(detail?.zoom)) ? Number(detail.zoom) : null, bounds: @@ -135,6 +184,7 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) slow: Boolean(detail?.slow), slowMs: Number.isFinite(Number(detail?.slowMs)) ? Number(detail.slowMs) : null, abortMs: Number.isFinite(Number(detail?.abortMs)) ? Number(detail.abortMs) : null, + abortReason: detail?.abortReason === "timeout" || detail?.abortReason === "cleanup" ? detail.abortReason : null, fetchErrorName: detail?.fetchErrorName ? String(detail.fetchErrorName) : null, source: detail?.source === "live" || detail?.source === "cache" || detail?.source === "fallback" @@ -162,6 +212,64 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) return () => window.removeEventListener("iuri:pnboia-runtime", onPnboiaRuntime as EventListener); }, []); + useEffect(() => { + const onFmapRuntime = (event: Event) => { + const detail = (event as CustomEvent).detail || {}; + const counts = detail?.counts || {}; + setFmapRuntime({ + enabled: Boolean(detail?.enabled), + disabled: Boolean(detail?.disabled), + error: detail?.error ? String(detail.error) : null, + counts: { + points: Number.isFinite(Number(counts.points)) ? Number(counts.points) : 0, + polygons: Number.isFinite(Number(counts.polygons)) ? Number(counts.polygons) : 0, + pending: Number.isFinite(Number(counts.pending)) ? Number(counts.pending) : 0, + }, + bounds: + detail?.bounds && + detail.bounds.sw && + detail.bounds.ne && + Number.isFinite(Number(detail.bounds.sw.lat)) && + Number.isFinite(Number(detail.bounds.sw.lon)) && + Number.isFinite(Number(detail.bounds.ne.lat)) && + Number.isFinite(Number(detail.bounds.ne.lon)) + ? { + sw: { lat: Number(detail.bounds.sw.lat), lon: Number(detail.bounds.sw.lon) }, + ne: { lat: Number(detail.bounds.ne.lat), lon: Number(detail.bounds.ne.lon) }, + } + : null, + sample: Array.isArray(detail?.sample) + ? detail.sample + .slice(0, 10) + .map((x: any) => ({ + id: String(x?.id ?? ""), + label: String(x?.label ?? ""), + category: x?.category ? String(x.category) : null, + kind: x?.kind === "polygon" ? "polygon" : "point", + hasCoords: Boolean(x?.hasCoords), + })) + .filter((x: any) => x.id && x.label) + : [], + }); + }; + window.addEventListener("iuri:fmap-runtime", onFmapRuntime as EventListener); + return () => window.removeEventListener("iuri:fmap-runtime", onFmapRuntime as EventListener); + }, []); + + useEffect(() => { + const onSignalsTiles = (event: Event) => { + const detail = (event as CustomEvent).detail || {}; + const nextRequested = Number(detail?.tilesRequested ?? 0); + const nextLoaded = Number(detail?.tilesLoaded ?? 0); + const nextError = Number(detail?.tilesError ?? 0); + if (Number.isFinite(nextRequested) && nextRequested >= 0) setSignalsTilesRequested(nextRequested); + if (Number.isFinite(nextLoaded) && nextLoaded >= 0) setSignalsTilesLoaded(nextLoaded); + if (Number.isFinite(nextError) && nextError >= 0) setSignalsTilesError(nextError); + }; + window.addEventListener("iuri:signals-tiles", onSignalsTiles as EventListener); + return () => window.removeEventListener("iuri:signals-tiles", onSignalsTiles as EventListener); + }, []); + const gpsEnabled = Boolean(layers.my_location_leaflet?.enabled || layers.my_location_maplibre?.enabled); const pnboiaEnabled = Boolean(layers.pnboia?.enabled); const aisEnabled = Boolean(layers.ais?.enabled); @@ -201,9 +309,11 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) const version = resp ? await resp.json().catch(() => null) : null; gitCommit = version?.gitCommit ?? (window as any).__IURI_VERSION__?.gitCommit ?? null; const resolvedUrl = - pnboiaRuntime.lastFetchUrl && (pnboiaRuntime.lastFetchUrl.startsWith("http://") || pnboiaRuntime.lastFetchUrl.startsWith("https://")) + pnboiaRuntime.resolvedUrl ?? + (pnboiaRuntime.lastFetchUrl && + (pnboiaRuntime.lastFetchUrl.startsWith("http://") || pnboiaRuntime.lastFetchUrl.startsWith("https://")) ? pnboiaRuntime.lastFetchUrl - : `${window.location.origin}${pnboiaRuntime.lastFetchUrl || "/api/v1/pnboia/list"}`; + : `${window.location.origin}${pnboiaRuntime.lastFetchUrl || "/api/v1/pnboia/list"}`); const payload = { href: window.location.href, version: { gitCommit }, @@ -215,12 +325,14 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) lastFetchUrl: pnboiaRuntime.lastFetchUrl, resolvedUrl, httpStatus: pnboiaRuntime.httpStatus, + requestOutcome: pnboiaRuntime.requestOutcome, lastOkAtISO: pnboiaRuntime.lastOkAtISO, lastOkAgeMs: pnboiaRuntime.lastOkAgeMs, loadingMs: pnboiaRuntime.loadingMs, slow: pnboiaRuntime.slow, slowMs: pnboiaRuntime.slowMs, abortMs: pnboiaRuntime.abortMs, + abortReason: pnboiaRuntime.abortReason, fetchErrorName: pnboiaRuntime.fetchErrorName, cacheAgeMs: pnboiaRuntime.cacheAgeMs, source: pnboiaRuntime.source, @@ -232,6 +344,13 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) bounds: pnboiaRuntime.bounds, }, stateHint, + signals: { + tilesRequested: signalsTilesRequested, + tilesLoaded: signalsTilesLoaded, + tilesError: signalsTilesError, + fmapDisabled: fmapRuntime.disabled, + fmapError: fmapRuntime.error, + }, }; const text = JSON.stringify(payload, null, 2); const ok = await copyText(text); @@ -247,6 +366,51 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) } }; + const handleFmapGo = () => { + window.dispatchEvent(new CustomEvent("iuri:fmap-go")); + }; + + const handleFmapCopyDebug = async () => { + try { + setFmapCopyFallbackText(null); + let gitCommit: string | null = null; + const resp = await fetch("/version.json", { cache: "no-store" }).catch(() => null); + const version = resp ? await resp.json().catch(() => null) : null; + gitCommit = version?.gitCommit ?? (window as any).__IURI_VERSION__?.gitCommit ?? null; + const payload = { + href: window.location.href, + version: { gitCommit }, + fmap: { + enabled: fmapRuntime.enabled, + disabled: fmapRuntime.disabled, + error: fmapRuntime.error, + counts: fmapRuntime.counts, + bounds: fmapRuntime.bounds, + sample: fmapRuntime.sample, + }, + map: { + zoom: pnboiaRuntime.zoom, + bounds: pnboiaRuntime.bounds, + }, + stateHint, + signals: { + tilesRequested: signalsTilesRequested, + tilesLoaded: signalsTilesLoaded, + tilesError: signalsTilesError, + }, + }; + const text = JSON.stringify(payload, null, 2); + const ok = await copyText(text); + if (!ok && debugMode) setFmapCopyFallbackText(text); + if (!ok) throw new Error("copy failed"); + setFmapCopyFeedback("Copied"); + window.setTimeout(() => setFmapCopyFeedback(null), 1500); + } catch { + setFmapCopyFeedback("Copy failed"); + window.setTimeout(() => setFmapCopyFeedback(null), 1500); + } + }; + if (hidden) return null; return ( @@ -349,6 +513,21 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) > Copy + {debugMode && pnboiaRuntime.resolvedUrl ? ( + + ) : null} ) : null} @@ -411,32 +590,104 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) {seamarksEnabled ? (
-
-
FMAP Señales (seed)
-
{FMAP_SIGNALS_SEED.length}
+
+
+
FMAP Señales (seed)
+
+ Points: {fmapRuntime.counts.points} · Polygons: {fmapRuntime.counts.polygons} · Pending:{" "} + {fmapRuntime.counts.pending} + {fmapRuntime.disabled || fmapRuntime.error ? ( + · (error) + ) : null} +
+
+
+
+ + + {debugMode ? ( + + ) : null} +
+
-
- {FMAP_SIGNALS_SEED.slice(0, 6).map((s) => ( -
-
-
{s.label}
- {s.kind === "point" ? ( -
- {s.point ? `${s.point.lat.toFixed(4)}, ${s.point.lon.toFixed(4)}` : "pending coordinates"} -
- ) : ( -
- {s.polygon ? `${s.polygon.length} pts` : "pending coordinates"} -
- )} + {debugMode && fmapCopyFallbackText ? ( +