mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-24 19:32:42 +02:00
fix(layers): reconcile saved toggles after backend restarts
This commit is contained in:
@@ -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,12 @@ export function getDefaultActiveLayers(): ActiveLayers {
|
||||
}
|
||||
|
||||
const ACTIVE_LAYER_KEYS = Object.keys(getDefaultActiveLayers()) as (keyof ActiveLayers)[];
|
||||
const LAYER_BACKEND_SYNC_INTERVAL_MS = 5000;
|
||||
let layerBackendSyncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let layerBackendSyncInFlight = false;
|
||||
let layerBackendSeenSuccess = false;
|
||||
let layerBackendSawFailure = false;
|
||||
let repairedDefaultBackendState = false;
|
||||
|
||||
function isBooleanRecord(value: unknown): value is Record<string, boolean> {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
@@ -174,8 +182,107 @@ 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 a connection is first established, after an observed outage,
|
||||
* or when the backend has unmistakably fallen back to its shipped defaults.
|
||||
* It does not continuously overwrite arbitrary non-default server state, so two
|
||||
* 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 &&
|
||||
(!layerBackendSeenSuccess ||
|
||||
layerBackendSawFailure ||
|
||||
(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 = backendAtDefaults;
|
||||
layerBackendSeenSuccess = true;
|
||||
layerBackendSawFailure = false;
|
||||
return 'synced';
|
||||
}
|
||||
|
||||
if (!backendAtDefaults) repairedDefaultBackendState = false;
|
||||
layerBackendSeenSuccess = true;
|
||||
layerBackendSawFailure = false;
|
||||
return 'noop';
|
||||
} catch {
|
||||
layerBackendSawFailure = true;
|
||||
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 +297,9 @@ export function clearActiveLayersPreference(): void {
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const defaults = getDefaultActiveLayers();
|
||||
emitLayerPreferencesChanged(defaults);
|
||||
ensureLayerBackendSync();
|
||||
}
|
||||
|
||||
export function getDefaultMapStyle(): MapStyle {
|
||||
|
||||
Reference in New Issue
Block a user