Merge pull request #529 from BigBodyCobain/fix/layer-state-search-527-528

fix(layers): restore restart sync and visible asset search
This commit is contained in:
Shadowbroker
2026-08-20 21:21:14 -06:00
committed by GitHub
4 changed files with 468 additions and 10 deletions
@@ -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<string, unknown>,
}));
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(<FindLocateBar onLocate={onLocate} />);
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(<FindLocateBar onLocate={vi.fn()} />);
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'RAUDA' } });
expect(await screen.findByText('NO MATCHING ASSETS')).toBeTruthy();
expect(screen.queryByText('RAUDA')).toBeNull();
});
});
@@ -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');
});
});
+200 -10
View File
@@ -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<ActiveLayers>(loadActiveLayers);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(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<ActiveLayers>).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: <Shield size={10} className="text-yellow-400" />,
TRACKED: <Star size={10} className="text-pink-400" />,
MARITIME: <Ship size={10} className="text-blue-400" />,
FISHING: <Ship size={10} className="text-teal-400" />,
SATELLITE: <Star size={10} className="text-violet-400" />,
TRAIN: <Database size={10} className="text-emerald-400" />,
BASE: <Shield size={10} className="text-red-400" />,
SDR: <Crosshair size={10} className="text-green-400" />,
DATABASE: <Database size={10} className="text-purple-400" />,
};
+104
View File
@@ -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<string, boolean> {
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<ActiveLayers>(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<string, boolean> {
export function saveLayerSectionExpanded(expanded: Record<string, boolean>): 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();