mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-24 11:22:37 +02:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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" />,
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user