fix: sync Data Layers toggle-all icon and improve RSS feed saves

Unify toggle-all exclusions for Earth imagery overlays so the icon matches layer state, and let Docker operators save news feeds via the proxy without a misleading network error.
This commit is contained in:
BigBodyCobain
2026-06-15 16:21:38 -06:00
parent 5ede669a12
commit 51f377f03d
2 changed files with 107 additions and 41 deletions
+68 -8
View File
@@ -171,6 +171,40 @@ function migratePrivacySensitiveBrowserState(): void {
const MAX_FEEDS = 50;
function formatFeedSettingsError(error: unknown, fallback: string): string {
const message = error instanceof Error ? error.message : String(error || '');
if (!message) return fallback;
if (message === 'admin_session_required') {
return 'Admin key required — paste ADMIN_KEY in Settings and unlock operator tools.';
}
if (message === 'backend_unavailable' || message === 'local_control_plane_unavailable') {
return 'Backend unavailable — check that the backend container is running.';
}
if (message === 'control_plane_rate_limited') {
return 'Too many requests — wait a moment and try again.';
}
return message;
}
function validateFeedEntries(feeds: FeedEntry[]): string | null {
for (const [idx, feed] of feeds.entries()) {
const name = feed.name.trim();
const url = feed.url.trim();
if (!name || !url) {
return `Feed ${idx + 1} needs both a name and URL before saving.`;
}
try {
const parsed = new URL(url);
if (!['http:', 'https:'].includes(parsed.protocol)) {
return `Feed ${idx + 1} must use an http:// or https:// URL.`;
}
} catch {
return `Feed ${idx + 1} has an invalid URL.`;
}
}
return null;
}
// Category colors for the tactical UI
const CATEGORY_COLORS: Record<string, string> = {
Aviation: 'text-cyan-400 border-cyan-500/30 bg-cyan-950/20',
@@ -606,7 +640,11 @@ const SettingsPanel = React.memo(function SettingsPanel({
const fetchFeeds = useCallback(async () => {
try {
setFeeds(await controlPlaneJson<FeedEntry[]>('/api/settings/news-feeds'));
setFeeds(
await controlPlaneJson<FeedEntry[]>('/api/settings/news-feeds', {
requireAdminSession: false,
}),
);
setFeedsDirty(false);
return true;
} catch (e) {
@@ -769,11 +807,10 @@ const SettingsPanel = React.memo(function SettingsPanel({
void fetchEnvMeta();
return;
}
if (!adminSessionReady) return;
if (activeTab === 'news-feeds') {
void fetchFeeds();
}
}, [isOpen, adminSessionReady, activeTab, fetchKeys, fetchEnvMeta, fetchFeeds]);
}, [isOpen, activeTab, fetchKeys, fetchEnvMeta, fetchFeeds]);
useEffect(() => {
if (!isOpen || activeTab !== 'protocol' || !showOperatorTools) return;
@@ -828,6 +865,11 @@ const SettingsPanel = React.memo(function SettingsPanel({
};
const saveFeeds = async () => {
const validationError = validateFeedEntries(feeds);
if (validationError) {
setFeedMsg({ type: 'err', text: validationError });
return;
}
setFeedSaving(true);
setFeedMsg(null);
try {
@@ -835,6 +877,7 @@ const SettingsPanel = React.memo(function SettingsPanel({
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(feeds),
requireAdminSession: false,
});
if (res.ok) {
setFeedsDirty(false);
@@ -844,28 +887,45 @@ const SettingsPanel = React.memo(function SettingsPanel({
});
} else {
const d = await res.json().catch(() => ({}));
setFeedMsg({ type: 'err', text: d.message || 'Save failed' });
setFeedMsg({
type: 'err',
text: String(d.message || d.detail || 'Save failed'),
});
}
} catch {
setFeedMsg({ type: 'err', text: 'Network error' });
} catch (error) {
setFeedMsg({
type: 'err',
text: formatFeedSettingsError(error, 'Could not reach the settings API'),
});
} finally {
setFeedSaving(false);
}
};
const resetFeeds = async () => {
setFeedMsg(null);
try {
const res = await controlPlaneFetch('/api/settings/news-feeds/reset', {
method: 'POST',
requireAdminSession: false,
});
if (res.ok) {
const d = await res.json();
setFeeds(d.feeds || []);
setFeedsDirty(false);
setFeedMsg({ type: 'ok', text: 'Reset to defaults' });
} else {
const d = await res.json().catch(() => ({}));
setFeedMsg({
type: 'err',
text: String(d.message || d.detail || 'Reset failed'),
});
}
} catch {
setFeedMsg({ type: 'err', text: 'Reset failed' });
} catch (error) {
setFeedMsg({
type: 'err',
text: formatFeedSettingsError(error, 'Could not reach the settings API'),
});
}
};
+39 -33
View File
@@ -633,6 +633,16 @@ function SdrTracker({
);
}
// Earth-imagery overlays are intentionally excluded from bulk toggle — stacking
// GIBS, Sentinel Hub, nightlights, and high-res tiles is redundant/noisy.
const TOGGLE_ALL_EXCLUDED_LAYERS = new Set<string>([
'gibs_imagery',
'highres_satellite',
'sentinel_hub',
'viirs_nightlights',
'road_corridor_trends',
]);
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
activeLayers,
setActiveLayers,
@@ -730,6 +740,31 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
[needsConsentBeforeEnable],
);
const isAllToggleableLayersOn = useMemo(
() =>
Object.entries(activeLayers)
.filter(([key]) => !TOGGLE_ALL_EXCLUDED_LAYERS.has(key))
.every(([, enabled]) => enabled),
[activeLayers],
);
const toggleAllLayers = useCallback(() => {
const enableAll = () => {
setActiveLayers((prev: ActiveLayers) => {
const next = { ...prev } as ActiveLayers;
for (const key of Object.keys(prev) as Array<keyof ActiveLayers>) {
next[key] = TOGGLE_ALL_EXCLUDED_LAYERS.has(String(key)) ? prev[key] : !isAllToggleableLayersOn;
}
return next;
});
};
if (!isAllToggleableLayersOn) {
withGlobalIncidentsConsent('global_incidents', true, enableAll);
} else {
enableAll();
}
}, [isAllToggleableLayersOn, setActiveLayers, withGlobalIncidentsConsent]);
// Auto-detect: if the backend already has Mode B creds configured
// (via env or a previous runtime save), promote the stored choice to
// 'b_active' without prompting. If it flips back to off, reset so the
@@ -1456,45 +1491,16 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({
</div>
<div className="flex items-center gap-2">
<button
title={
Object.entries(activeLayers)
.filter(([k]) => !['gibs_imagery', 'highres_satellite', 'sentinel_hub', 'viirs_nightlights', 'road_corridor_trends'].includes(k))
.every(([, v]) => v)
? 'Disable all layers'
: 'Enable all layers'
}
title={isAllToggleableLayersOn ? 'Disable all layers' : 'Enable all layers'}
className={`${
Object.entries(activeLayers)
.filter(([k]) => !['gibs_imagery', 'highres_satellite', 'sentinel_hub', 'viirs_nightlights', 'road_corridor_trends'].includes(k))
.every(([, v]) => v)
? 'text-cyan-400'
: 'text-[var(--text-muted)]'
isAllToggleableLayersOn ? 'text-cyan-400' : 'text-[var(--text-muted)]'
} hover:text-cyan-400 transition-colors`}
onClick={(e) => {
e.stopPropagation();
const excluded = new Set(['gibs_imagery', 'highres_satellite', 'sentinel_hub', 'viirs_nightlights', 'road_corridor_trends']);
const allOn = Object.entries(activeLayers)
.filter(([k]) => !excluded.has(k))
.every(([, v]) => v);
const enableAll = () => {
setActiveLayers((prev: ActiveLayers) => {
const next = { ...prev } as ActiveLayers;
for (const k of Object.keys(prev) as Array<keyof ActiveLayers>) {
next[k] = excluded.has(k) ? prev[k] : !allOn;
}
return next;
});
};
if (!allOn) {
withGlobalIncidentsConsent('global_incidents', true, enableAll);
} else {
enableAll();
}
toggleAllLayers();
}}
>
{Object.entries(activeLayers)
.filter(([k]) => !['gibs_imagery', 'highres_satellite', 'sentinel_hub', 'viirs_nightlights'].includes(k))
.every(([, v]) => v) ? (
{isAllToggleableLayersOn ? (
<ToggleRight size={22} />
) : (
<ToggleLeft size={22} />