mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-10-12 09:57:10 +02:00
fix(layers): reconcile saved toggles after backend restarts
This commit is contained in:
1 parent
72ed7eae65
commit
f9ae967580
1 file changed
+110
@@ -1,8 +1,10 @@
|
|||||||
import type { ActiveLayers } from '@/types/dashboard';
|
import type { ActiveLayers } from '@/types/dashboard';
|
||||||
|
import { API_BASE } from '@/lib/api';
|
||||||
|
|
||||||
export const LAYER_PREFERENCES_STORAGE_KEY = 'sb_active_layers_v1';
|
export const LAYER_PREFERENCES_STORAGE_KEY = 'sb_active_layers_v1';
|
||||||
export const DASHBOARD_PREFS_STORAGE_KEY = 'sb_dashboard_prefs_v1';
|
export const DASHBOARD_PREFS_STORAGE_KEY = 'sb_dashboard_prefs_v1';
|
||||||
export const DASHBOARD_PREFS_VERSION = 1;
|
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 const MAP_STYLES = ['DEFAULT', 'SATELLITE'] as const;
|
||||||
export type MapStyle = (typeof MAP_STYLES)[number];
|
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 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> {
|
function isBooleanRecord(value: unknown): value is Record<string, boolean> {
|
||||||
if (!value || typeof value !== 'object') return false;
|
if (!value || typeof value !== 'object') return false;
|
||||||
@@ -174,8 +182,107 @@ export function loadActiveLayers(): ActiveLayers {
|
|||||||
return mergeActiveLayers(defaults, readDashboardPrefs().layers);
|
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 {
|
export function saveActiveLayers(layers: ActiveLayers): void {
|
||||||
writeDashboardPrefs({ layers });
|
writeDashboardPrefs({ layers });
|
||||||
|
emitLayerPreferencesChanged(layers);
|
||||||
|
ensureLayerBackendSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearActiveLayersPreference(): void {
|
export function clearActiveLayersPreference(): void {
|
||||||
@@ -190,6 +297,9 @@ export function clearActiveLayersPreference(): void {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
const defaults = getDefaultActiveLayers();
|
||||||
|
emitLayerPreferencesChanged(defaults);
|
||||||
|
ensureLayerBackendSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultMapStyle(): MapStyle {
|
export function getDefaultMapStyle(): MapStyle {
|
||||||
|
|||||||
Reference in new issue
Block a user