diff --git a/frontend/src/__tests__/components/FindLocateBar.test.tsx b/frontend/src/__tests__/components/FindLocateBar.test.tsx new file mode 100644 index 0000000..6fcd7e9 --- /dev/null +++ b/frontend/src/__tests__/components/FindLocateBar.test.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import FindLocateBar from '@/components/FindLocateBar'; +import { getDefaultActiveLayers, saveActiveLayers } from '@/lib/layerPreferences'; + +const mockState = vi.hoisted(() => ({ + data: {} as Record, +})); + +vi.mock('@/hooks/useDataStore', () => ({ + useDataKeys: () => mockState.data, +})); + +vi.mock('@/i18n', () => ({ + useTranslation: () => ({ t: () => 'Find / Locate' }), +})); + +describe('FindLocateBar supplemental visible-layer search', () => { + beforeEach(() => { + localStorage.clear(); + mockState.data = { + commercial_flights: [], + private_flights: [], + private_jets: [], + military_flights: [], + tracked_flights: [], + ships: [], + fishing_activity: [ + { + id: 'gfw-rauda', + type: 'fishing', + lat: 57.1, + lng: 21.4, + start: '2026-08-20T00:00:00Z', + end: '2026-08-20T01:00:00Z', + vessel_id: 'v-1', + vessel_ssvid: '275123456', + vessel_name: 'RAUDA', + vessel_flag: 'LVA', + duration_hrs: 1, + }, + ], + satellites: [], + trains: [], + military_bases: [], + kiwisdr: [], + }; + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + }); + + it('finds a fishing vessel while the fishing layer is visible', async () => { + const onLocate = vi.fn(); + render(); + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'RAUDA' } }); + + const result = await screen.findByText('RAUDA'); + expect(result).toBeTruthy(); + fireEvent.click(result); + expect(onLocate).toHaveBeenCalledWith(57.1, 21.4, 'fishing-gfw-rauda', 'fishing_activity'); + }); + + it('does not surface assets from a hidden supplemental layer', async () => { + saveActiveLayers({ ...getDefaultActiveLayers(), fishing_activity: false }); + render(); + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'RAUDA' } }); + + expect(await screen.findByText('NO MATCHING ASSETS')).toBeTruthy(); + expect(screen.queryByText('RAUDA')).toBeNull(); + }); +}); diff --git a/frontend/src/__tests__/lib/layerBackendReconciliation.test.ts b/frontend/src/__tests__/lib/layerBackendReconciliation.test.ts new file mode 100644 index 0000000..d14d0e6 --- /dev/null +++ b/frontend/src/__tests__/lib/layerBackendReconciliation.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('layer backend reconciliation', () => { + beforeEach(() => { + vi.resetModules(); + localStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + localStorage.clear(); + }); + + it('replays saved operator toggles when a restarted backend is back at defaults', async () => { + const prefs = await import('@/lib/layerPreferences'); + const desired = { + ...prefs.getDefaultActiveLayers(), + cctv: true, + datacenters: true, + power_plants: true, + }; + prefs.saveActiveLayers(desired); + + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ layers: prefs.getDefaultActiveLayers(), overrides: {} }), + }) + .mockResolvedValueOnce({ ok: true }); + vi.stubGlobal('fetch', fetchMock); + + await expect(prefs.reconcileActiveLayersWithBackend()).resolves.toBe('synced'); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1]?.[1]?.method).toBe('POST'); + const posted = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)); + expect(posted.layers.cctv).toBe(true); + expect(posted.layers.datacenters).toBe(true); + expect(posted.layers.power_plants).toBe(true); + }); + + it('does not fight a non-default backend state after a healthy connection', async () => { + const prefs = await import('@/lib/layerPreferences'); + const desired = { ...prefs.getDefaultActiveLayers(), cctv: true }; + prefs.saveActiveLayers(desired); + const otherClientState = { ...desired, datacenters: true }; + + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ layers: desired, overrides: {} }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ layers: otherClientState, overrides: {} }), + }); + vi.stubGlobal('fetch', fetchMock); + + await expect(prefs.reconcileActiveLayersWithBackend()).resolves.toBe('noop'); + await expect(prefs.reconcileActiveLayersWithBackend()).resolves.toBe('noop'); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls.every((call) => call[1]?.method !== 'POST')).toBe(true); + }); + + it('retries saved state after an observed backend outage', async () => { + const prefs = await import('@/lib/layerPreferences'); + const desired = { ...prefs.getDefaultActiveLayers(), cctv: true }; + prefs.saveActiveLayers(desired); + + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error('backend restarting')) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ layers: prefs.getDefaultActiveLayers(), overrides: {} }), + }) + .mockResolvedValueOnce({ ok: true }); + vi.stubGlobal('fetch', fetchMock); + + await expect(prefs.reconcileActiveLayersWithBackend()).resolves.toBe('offline'); + await expect(prefs.reconcileActiveLayersWithBackend()).resolves.toBe('synced'); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls[2]?.[1]?.method).toBe('POST'); + }); +}); diff --git a/frontend/src/components/FindLocateBar.tsx b/frontend/src/components/FindLocateBar.tsx index deaebb1..cc0989a 100644 --- a/frontend/src/components/FindLocateBar.tsx +++ b/frontend/src/components/FindLocateBar.tsx @@ -1,10 +1,15 @@ 'use client'; -import React, { useState, useMemo, useRef, useEffect } from 'react'; +import React, { useDeferredValue, useState, useMemo, useRef, useEffect } from 'react'; import { Search, Crosshair, Plane, Shield, Star, Ship, X, Database } from 'lucide-react'; import { motion, AnimatePresence } from '@/lib/motion'; import { trackedOperators } from '../lib/trackedData'; import { useDataKeys } from '@/hooks/useDataStore'; +import { + LAYER_PREFERENCES_CHANGED_EVENT, + loadActiveLayers, +} from '@/lib/layerPreferences'; +import type { ActiveLayers } from '@/types/dashboard'; import { useTranslation } from '@/i18n'; interface FindLocateBarProps { @@ -24,14 +29,63 @@ interface SearchResult { extra?: string; } +const SEARCH_RESULT_LIMIT = 12; + +function isFiniteCoordinate(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function matchesQuery(query: string, ...values: unknown[]): boolean { + return values.some((value) => { + if (value === null || value === undefined) return false; + if (Array.isArray(value)) return value.some((item) => String(item).toLowerCase().includes(query)); + return String(value).toLowerCase().includes(query); + }); +} + const FindLocateBar = React.memo(function FindLocateBar({ onLocate, onFilter }: FindLocateBarProps) { const { t } = useTranslation(); - const data = useDataKeys(['commercial_flights', 'private_flights', 'private_jets', 'military_flights', 'tracked_flights', 'ships'] as const); + const data = useDataKeys([ + 'commercial_flights', + 'private_flights', + 'private_jets', + 'military_flights', + 'tracked_flights', + 'ships', + 'fishing_activity', + 'satellites', + 'trains', + 'military_bases', + 'kiwisdr', + ] as const); const [query, setQuery] = useState(''); + const deferredQuery = useDeferredValue(query.trim().toLowerCase()); const [isOpen, setIsOpen] = useState(false); + const [visibleLayers, setVisibleLayers] = useState(loadActiveLayers); const inputRef = useRef(null); const containerRef = useRef(null); + // Keep search visibility aligned with the DATA LAYERS panel without requiring + // a heavyweight page-level prop chain. Same-tab saves emit a custom event; + // the storage listener covers another tab changing the shared preferences. + useEffect(() => { + const onPreferenceChange = (event: Event) => { + const detail = (event as CustomEvent).detail; + setVisibleLayers(detail ?? loadActiveLayers()); + }; + const onStorage = (event: StorageEvent) => { + if (event.key === null || event.key.startsWith('sb_')) { + setVisibleLayers(loadActiveLayers()); + } + }; + window.addEventListener(LAYER_PREFERENCES_CHANGED_EVENT, onPreferenceChange); + window.addEventListener('storage', onStorage); + return () => { + window.removeEventListener(LAYER_PREFERENCES_CHANGED_EVENT, onPreferenceChange); + window.removeEventListener('storage', onStorage); + }; + }, []); + // Close dropdown when clicking outside useEffect(() => { const handler = (e: MouseEvent) => { @@ -43,7 +97,8 @@ const FindLocateBar = React.memo(function FindLocateBar({ onLocate, onFilter }: return () => document.removeEventListener('mousedown', handler); }, []); - // Build searchable index from all data + // Keep the long-standing flight/ship/database index cheap and ready. Heavy + // optional layers are matched lazily below only while the user is searching. const allEntities = useMemo(() => { const results: SearchResult[] = []; @@ -99,7 +154,9 @@ const FindLocateBar = React.memo(function FindLocateBar({ onLocate, onFilter }: const operator = f.alert_operator || 'Unknown Operator'; const category = f.alert_category || 'Tracked'; const type = f.alert_type || f.model || 'Unknown'; - const extras = [f.alert_operator, f.alert_tags, f.owner, f.name, f.callsign, f.registration].filter(Boolean).join(' '); + const extras = [f.alert_operator, f.alert_tags, f.owner, f.name, f.callsign, f.registration] + .filter(Boolean) + .join(' '); results.push({ id: `tracked-${uid}`, label: operator, @@ -144,17 +201,145 @@ const FindLocateBar = React.memo(function FindLocateBar({ onLocate, onFilter }: return results; }, [data]); - // Filter results based on query + // Filter results based on query. Large/optional datasets are scanned only + // for a real search (2+ chars), only when their layer is visible, and stop + // as soon as the 12-result UI cap is satisfied. useDeferredValue keeps the + // input responsive even with tens of thousands of fishing events. const filtered = useMemo(() => { - if (!query.trim()) return []; - const q = query.toLowerCase(); - return allEntities + if (!deferredQuery) return []; + const q = deferredQuery; + const results = allEntities .filter((e) => { const searchable = `${e.label} ${e.sublabel} ${e.id} ${e.extra || ''}`.toLowerCase(); return searchable.includes(q); }) - .slice(0, 12); - }, [query, allEntities]); + .slice(0, SEARCH_RESULT_LIMIT); + + if (q.length < 2 || results.length >= SEARCH_RESULT_LIMIT) return results; + + const push = ( + result: SearchResult, + searchableValues: unknown[], + ) => { + if (results.length >= SEARCH_RESULT_LIMIT) return; + if (!isFiniteCoordinate(result.lat) || !isFiniteCoordinate(result.lng)) return; + if (matchesQuery(q, ...searchableValues)) results.push(result); + }; + + if (visibleLayers.fishing_activity) { + for (const event of data?.fishing_activity || []) { + push( + { + id: `fishing-${event.id || event.vessel_id || event.vessel_ssvid || ''}`, + label: event.vessel_name || event.vessel_ssvid || 'UNKNOWN VESSEL', + sublabel: `${event.type || 'Fishing activity'} · ${event.vessel_flag || 'Unknown flag'}`, + category: 'FISHING', + categoryColor: 'text-teal-400', + lat: event.lat, + lng: event.lng, + entityType: 'fishing_activity', + }, + [ + event.vessel_name, + event.vessel_flag, + event.vessel_id, + event.vessel_ssvid, + event.type, + event.id, + ], + ); + if (results.length >= SEARCH_RESULT_LIMIT) break; + } + } + + if (visibleLayers.satellites && results.length < SEARCH_RESULT_LIMIT) { + for (const satellite of data?.satellites || []) { + push( + { + id: `satellite-${satellite.id}`, + label: satellite.name || String(satellite.id), + sublabel: `${satellite.mission || satellite.sat_type || 'Satellite'} · ${satellite.country || 'Unknown'}`, + category: 'SATELLITE', + categoryColor: 'text-violet-400', + lat: satellite.lat, + lng: satellite.lng, + entityType: 'satellite', + }, + [satellite.name, satellite.id, satellite.mission, satellite.sat_type, satellite.country], + ); + if (results.length >= SEARCH_RESULT_LIMIT) break; + } + } + + if (visibleLayers.trains && results.length < SEARCH_RESULT_LIMIT) { + for (const train of data?.trains || []) { + push( + { + id: `train-${train.id || train.number || train.name || ''}`, + label: train.name || train.number || train.id || 'TRAIN', + sublabel: `${train.operator || train.source_label || train.source || 'Rail'} · ${train.route || train.status || 'Unknown route'}`, + category: 'TRAIN', + categoryColor: 'text-emerald-400', + lat: train.lat, + lng: train.lng, + entityType: 'train', + }, + [ + train.id, + train.name, + train.number, + train.operator, + train.country, + train.route, + train.status, + train.source, + train.source_label, + ], + ); + if (results.length >= SEARCH_RESULT_LIMIT) break; + } + } + + if (visibleLayers.military_bases && results.length < SEARCH_RESULT_LIMIT) { + for (const base of data?.military_bases || []) { + push( + { + id: `military-base-${base.name || ''}-${base.country || ''}`, + label: base.name || 'MILITARY BASE', + sublabel: `${base.branch || base.operator || 'Military'} · ${base.country || 'Unknown'}`, + category: 'BASE', + categoryColor: 'text-red-400', + lat: base.lat, + lng: base.lng, + entityType: 'military_base', + }, + [base.name, base.country, base.operator, base.branch], + ); + if (results.length >= SEARCH_RESULT_LIMIT) break; + } + } + + if (visibleLayers.kiwisdr && results.length < SEARCH_RESULT_LIMIT) { + for (const receiver of data?.kiwisdr || []) { + push( + { + id: `kiwisdr-${receiver.name || ''}-${receiver.lat}-${receiver.lon}`, + label: receiver.name || receiver.location || 'KIWISDR', + sublabel: `${receiver.location || receiver.bands || 'KiwiSDR'} · ${receiver.users ?? 0}/${receiver.users_max ?? '?'} users`, + category: 'SDR', + categoryColor: 'text-green-400', + lat: receiver.lat, + lng: receiver.lon, + entityType: 'kiwisdr', + }, + [receiver.name, receiver.location, receiver.bands, receiver.antenna, receiver.url], + ); + if (results.length >= SEARCH_RESULT_LIMIT) break; + } + } + + return results; + }, [deferredQuery, allEntities, data, visibleLayers]); const handleSelect = (result: SearchResult) => { if (result.entityType === 'database_operator') { @@ -172,6 +357,11 @@ const FindLocateBar = React.memo(function FindLocateBar({ onLocate, onFilter }: MILITARY: , TRACKED: , MARITIME: , + FISHING: , + SATELLITE: , + TRAIN: , + BASE: , + SDR: , DATABASE: , }; diff --git a/frontend/src/lib/layerPreferences.ts b/frontend/src/lib/layerPreferences.ts index 1fc2b77..71a3a61 100644 --- a/frontend/src/lib/layerPreferences.ts +++ b/frontend/src/lib/layerPreferences.ts @@ -1,8 +1,10 @@ import type { ActiveLayers } from '@/types/dashboard'; +import { API_BASE } from '@/lib/api'; export const LAYER_PREFERENCES_STORAGE_KEY = 'sb_active_layers_v1'; export const DASHBOARD_PREFS_STORAGE_KEY = 'sb_dashboard_prefs_v1'; export const DASHBOARD_PREFS_VERSION = 1; +export const LAYER_PREFERENCES_CHANGED_EVENT = 'sb:layer-preferences-changed'; export const MAP_STYLES = ['DEFAULT', 'SATELLITE'] as const; export type MapStyle = (typeof MAP_STYLES)[number]; @@ -91,6 +93,10 @@ export function getDefaultActiveLayers(): ActiveLayers { } const ACTIVE_LAYER_KEYS = Object.keys(getDefaultActiveLayers()) as (keyof ActiveLayers)[]; +const LAYER_BACKEND_SYNC_INTERVAL_MS = 5000; +let layerBackendSyncTimer: number | null = null; +let layerBackendSyncInFlight = false; +let repairedDefaultBackendState = false; function isBooleanRecord(value: unknown): value is Record { if (!value || typeof value !== 'object') return false; @@ -174,8 +180,99 @@ export function loadActiveLayers(): ActiveLayers { return mergeActiveLayers(defaults, readDashboardPrefs().layers); } +function layerMapsEqual(left: ActiveLayers, right: ActiveLayers): boolean { + return ACTIVE_LAYER_KEYS.every((key) => left[key] === right[key]); +} + +function emitLayerPreferencesChanged(layers: ActiveLayers): void { + if (typeof window === 'undefined') return; + window.dispatchEvent( + new CustomEvent(LAYER_PREFERENCES_CHANGED_EVENT, { detail: layers }), + ); +} + +/** + * Quietly repair the backend's process-local layer gate after a runtime restart. + * + * The dashboard already saves the operator's layer choices locally. The backend, + * however, resets its fetcher gates on process restart. This reconciliation loop + * observes the existing GET /api/layers endpoint and replays the saved operator + * state only when the backend is unmistakably back at its shipped defaults. + * It deliberately leaves other non-default server state alone, so independent + * clients do not get into a five-second preference tug-of-war. + */ +export async function reconcileActiveLayersWithBackend(): Promise<'synced' | 'noop' | 'offline'> { + if (typeof window === 'undefined' || typeof fetch !== 'function') return 'offline'; + if (layerBackendSyncInFlight) return 'noop'; + layerBackendSyncInFlight = true; + + try { + const response = await fetch(`${API_BASE}/api/layers`, { + headers: { Accept: 'application/json' }, + cache: 'no-store', + }); + if (!response.ok) throw new Error(`layer state probe failed (${response.status})`); + + const payload: unknown = await response.json(); + const rawLayers = + payload && typeof payload === 'object' ? (payload as { layers?: unknown }).layers : null; + if (!isBooleanRecord(rawLayers)) { + throw new Error('layer state probe returned an invalid payload'); + } + + const defaults = getDefaultActiveLayers(); + const desired = loadActiveLayers(); + const backend = mergeActiveLayers(defaults, rawLayers); + const mismatch = !layerMapsEqual(desired, backend); + const backendAtDefaults = layerMapsEqual(backend, defaults); + const shouldRepair = mismatch && backendAtDefaults && !repairedDefaultBackendState; + + if (shouldRepair) { + const syncResponse = await fetch(`${API_BASE}/api/layers`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ layers: desired }), + }); + if (!syncResponse.ok) { + throw new Error(`layer state sync failed (${syncResponse.status})`); + } + // useDataPolling already listens for this event and refreshes gated data immediately. + window.dispatchEvent(new Event('sb:layer-toggle')); + repairedDefaultBackendState = true; + return 'synced'; + } + + // Once the repaired non-default state is observable, arm the watchdog for a + // future backend restart that returns the process-local gate to defaults. + if (!backendAtDefaults) repairedDefaultBackendState = false; + return 'noop'; + } catch { + return 'offline'; + } finally { + layerBackendSyncInFlight = false; + } +} + +function ensureLayerBackendSync(): void { + if ( + typeof window === 'undefined' || + typeof fetch !== 'function' || + process.env.NODE_ENV === 'test' + ) { + return; + } + if (layerBackendSyncTimer !== null) return; + + void reconcileActiveLayersWithBackend(); + layerBackendSyncTimer = window.setInterval(() => { + void reconcileActiveLayersWithBackend(); + }, LAYER_BACKEND_SYNC_INTERVAL_MS); +} + export function saveActiveLayers(layers: ActiveLayers): void { writeDashboardPrefs({ layers }); + emitLayerPreferencesChanged(layers); + ensureLayerBackendSync(); } export function clearActiveLayersPreference(): void { @@ -190,6 +287,9 @@ export function clearActiveLayersPreference(): void { } catch { /* ignore */ } + const defaults = getDefaultActiveLayers(); + emitLayerPreferencesChanged(defaults); + ensureLayerBackendSync(); } export function getDefaultMapStyle(): MapStyle { @@ -242,3 +342,7 @@ export function loadLayerSectionExpanded(): Record { export function saveLayerSectionExpanded(expanded: Record): void { writeDashboardPrefs({ layerSectionsExpanded: expanded }); } + +// Start the passive restart watchdog as soon as the preferences module is loaded +// in a real browser. SSR and Vitest are guarded inside ensureLayerBackendSync(). +ensureLayerBackendSync();