diff --git a/work/dashboard-frontend/src/App.tsx b/work/dashboard-frontend/src/App.tsx index 7ac1299..a8fdd75 100644 --- a/work/dashboard-frontend/src/App.tsx +++ b/work/dashboard-frontend/src/App.tsx @@ -21,6 +21,7 @@ import MeshCoreContactsCompanion from './pages/MeshCoreContactsCompanion' import ScheduledBroadcasts from './pages/ScheduledBroadcasts' import MeshtasticDangerZones from './pages/MeshtasticDangerZones' import MeshCoreDangerZones from './pages/MeshCoreDangerZones' +import Coverage from './pages/Coverage' import { ToastProvider } from './components/ToastProvider' import { DirtyProvider } from './context/DirtyContext' @@ -42,6 +43,7 @@ function App() { {/* New aggregated pages */} } /> + } /> {/* De-navved routes still work */} } /> diff --git a/work/dashboard-frontend/src/components/Layout.tsx b/work/dashboard-frontend/src/components/Layout.tsx index 59207fc..cdd9885 100644 --- a/work/dashboard-frontend/src/components/Layout.tsx +++ b/work/dashboard-frontend/src/components/Layout.tsx @@ -10,6 +10,7 @@ import { Sliders, Droplets, MapPin, + Map, Wifi, Layers, Network, @@ -51,6 +52,7 @@ const navGroups: NavGroup[] = [ { path: '/environment', label: 'Data Feeds', icon: Cloud }, { path: '/activity', label: 'Activity Log', icon: Activity }, { path: '/places', label: 'Places', icon: MapPin }, + { path: '/coverage', label: 'Coverage', icon: Map }, ], }, { diff --git a/work/dashboard-frontend/src/pages/Coverage.tsx b/work/dashboard-frontend/src/pages/Coverage.tsx new file mode 100644 index 0000000..f75aed3 --- /dev/null +++ b/work/dashboard-frontend/src/pages/Coverage.tsx @@ -0,0 +1,486 @@ +import { useEffect, useState, useCallback, useRef } from 'react' +import { + MapContainer, + TileLayer, + Rectangle, + CircleMarker, + useMapEvents, + useMap, +} from 'react-leaflet' +import type { LatLngTuple } from 'leaflet' +import 'leaflet/dist/leaflet.css' +import { Save, RotateCcw, MousePointer } from 'lucide-react' +import { notifyRestartRequired } from '@/components/RestartBanner' +import { useDirty } from '@/context/DirtyContext' + +// Fix Leaflet default marker icon (mirrors GeoMap.tsx) +import L from 'leaflet' +import markerIcon from 'leaflet/dist/images/marker-icon.png' +import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png' +import markerShadow from 'leaflet/dist/images/marker-shadow.png' +// @ts-expect-error — Leaflet icon fix for Vite +delete L.Icon.Default.prototype._getIconUrl +L.Icon.Default.mergeOptions({ iconUrl: markerIcon, iconRetinaUrl: markerIcon2x, shadowUrl: markerShadow }) + +// [W, S, E, N] = [minLon, minLat, maxLon, maxLat] +type Bbox = [number, number, number, number] +type ClickMode = 'idle' | 'awaiting-first' | 'awaiting-second' + +interface CoverageConfig { + bbox: number[] + enabled: boolean + excluded_adapters: string[] +} + +// Adapters that participate in the coverage bbox system +const COVERAGE_ADAPTERS: { key: string; label: string }[] = [ + { key: 'fires', label: 'NIFC Fire Perimeters' }, + { key: 'nws', label: 'NWS Weather Alerts' }, + { key: 'wzdx', label: 'WZDx Work Zones' }, + { key: 'usgs_quake', label: 'USGS Earthquakes' }, + { key: 'firms', label: 'NASA FIRMS Hotspots' }, + { key: 'roads511', label: '511 Road Conditions' }, + { key: 'usgs', label: 'USGS Stream Gauges' }, + { key: 'avalanche', label: 'Avalanche Advisories' }, + { key: 'traffic', label: 'TomTom Traffic' }, + { key: 'satpass', label: 'Satellite Passes' }, + { key: 'ducting', label: 'Tropospheric Ducting' }, +] + +// Fit map to bounds on the first time bounds is available; never re-fits after +// that so user-driven pan/zoom is preserved. +function InitialFit({ bounds }: { bounds: [[number, number], [number, number]] | null }) { + const map = useMap() + const hasFitted = useRef(false) + useEffect(() => { + if (!hasFitted.current && bounds) { + map.fitBounds(bounds, { padding: [40, 40] }) + hasFitted.current = true + } + }, [map, bounds]) + return null +} + +// Two-click rectangle draw: first click = first corner, second click = opposite +// corner. A CircleMarker shows the pending first corner while awaiting-second. +function MapClickHandler({ + mode, + firstCorner, + onFirstClick, + onSecondClick, +}: { + mode: ClickMode + firstCorner: LatLngTuple | null + onFirstClick: (pt: LatLngTuple) => void + onSecondClick: (pt: LatLngTuple) => void +}) { + useMapEvents({ + click(e) { + const pt: LatLngTuple = [e.latlng.lat, e.latlng.lng] + if (mode === 'awaiting-first') onFirstClick(pt) + else if (mode === 'awaiting-second') onSecondClick(pt) + }, + }) + return firstCorner ? ( + + ) : null +} + +export default function Coverage() { + const [config, setConfig] = useState(null) + const [original, setOriginal] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const { setDirty } = useDirty() + + const [clickMode, setClickMode] = useState('idle') + const [firstCorner, setFirstCorner] = useState(null) + + useEffect(() => { + document.title = 'Coverage — MeshAI' + fetch('/api/config') + .then((r) => { + if (!r.ok) throw new Error('Failed to fetch config') + return r.json() + }) + .then((data) => { + const cov: CoverageConfig = data.coverage ?? { + bbox: [], + enabled: true, + excluded_adapters: [], + } + setConfig(cov) + setOriginal(JSON.stringify(cov)) + }) + .catch((e) => setError(e instanceof Error ? e.message : String(e))) + .finally(() => setLoading(false)) + }, []) + + const hasChanges = config !== null && JSON.stringify(config) !== original + + useEffect(() => { + setDirty(hasChanges) + return () => setDirty(false) + }, [hasChanges, setDirty]) + + // Derive typed Bbox from raw config.bbox + const bbox: Bbox | null = + config?.bbox?.length === 4 + ? [config.bbox[0], config.bbox[1], config.bbox[2], config.bbox[3]] + : null + + // Leaflet expects [[S, W], [N, E]] + const leafletBounds: [[number, number], [number, number]] | null = bbox + ? [[bbox[1], bbox[0]], [bbox[3], bbox[2]]] + : null + + // Default center: continental US + const defaultCenter: LatLngTuple = [39.5, -98.35] + + const updateBbox = useCallback((b: Bbox) => { + setConfig((prev) => (prev ? { ...prev, bbox: b } : prev)) + }, []) + + const handleFirstClick = useCallback((pt: LatLngTuple) => { + setFirstCorner(pt) + setClickMode('awaiting-second') + }, []) + + const handleSecondClick = useCallback( + (pt: LatLngTuple) => { + if (!firstCorner) return + const [lat1, lon1] = firstCorner + const [lat2, lon2] = pt + updateBbox([ + Math.min(lon1, lon2), // W + Math.min(lat1, lat2), // S + Math.max(lon1, lon2), // E + Math.max(lat1, lat2), // N + ]) + setFirstCorner(null) + setClickMode('idle') + }, + [firstCorner, updateBbox], + ) + + const handleBboxInput = (idx: number, val: string) => { + const n = parseFloat(val) + if (isNaN(n)) return + const cur: number[] = bbox ? [...bbox] : [-125, 24, -65, 50] + cur[idx] = n + updateBbox(cur as Bbox) + } + + const toggleExcluded = (key: string) => { + if (!config) return + const cur = config.excluded_adapters ?? [] + setConfig({ + ...config, + excluded_adapters: cur.includes(key) ? cur.filter((k) => k !== key) : [...cur, key], + }) + } + + const discard = () => { + if (original) { + setConfig(JSON.parse(original)) + setClickMode('idle') + setFirstCorner(null) + } + } + + const save = async () => { + if (!config) return + setSaving(true) + setError(null) + setSuccess(null) + try { + const res = await fetch('/api/config/coverage', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }) + const result = await res.json() + if (!res.ok) throw new Error(result.detail || 'Save failed') + setOriginal(JSON.stringify(config)) + setSuccess('Coverage saved') + setTimeout(() => setSuccess(null), 3000) + if (result.restart_required) { + notifyRestartRequired(Array.isArray(result.changed_keys) ? result.changed_keys : []) + } + } catch (e) { + setError(e instanceof Error ? e.message : 'Save failed') + } finally { + setSaving(false) + } + } + + if (loading) { + return ( +
+ Loading coverage config… +
+ ) + } + if (!config) { + return ( +
+ {error || 'No config'} +
+ ) + } + + const drawHint = + clickMode === 'awaiting-first' + ? 'Click the first corner of the bbox on the map…' + : clickMode === 'awaiting-second' + ? 'Click the opposite corner to complete the bbox…' + : bbox + ? `W ${bbox[0].toFixed(3)} S ${bbox[1].toFixed(3)} E ${bbox[2].toFixed(3)} N ${bbox[3].toFixed(3)}` + : 'No bbox set — draw one on the map or enter coordinates below.' + + const bboxFields: { label: string; placeholder: string }[] = [ + { label: 'West', placeholder: 'e.g. -125' }, + { label: 'South', placeholder: 'e.g. 24' }, + { label: 'East', placeholder: 'e.g. -65' }, + { label: 'North', placeholder: 'e.g. 50' }, + ] + + return ( +
+ {/* Page description + action bar */} +
+

+ One bounding box scopes every native adapter's geographic focus. Adapters with + "Use own config" on ignore this bbox and use the geographic settings on the{' '} + + Data Feeds + {' '} + page. +

+ {hasChanges && ( +
+ + +
+ )} +
+ + {error &&
{error}
} + {success &&
{success}
} + + {/* Master enable toggle */} +
+
+ + Use coverage bbox to scope all adapters + +

+ When disabled, every adapter uses its own geographic config regardless of the + bbox below. +

+
+ +
+ + {/* Leaflet map */} +
+ {/* Map toolbar */} +
+ {drawHint} +
+ {clickMode !== 'idle' && ( + + )} + +
+
+ + + + + {leafletBounds && ( + + )} + + +
+ + {/* Numeric W/S/E/N inputs — always visible, sync with map */} +
+
+ Bounding Box Coordinates +
+
+ {bboxFields.map(({ label, placeholder }, i) => ( +
+ + handleBboxInput(i, e.target.value)} + className="w-full bg-[#0d0d0d] border border-border px-3 py-2 text-sm font-mono" + /> +
+ ))} +
+

+ Decimal degrees. W/E = longitude; S/N = latitude. Inputs stay in sync with + the map rectangle above. +

+
+ + {/* Per-adapter override toggles */} +
+
+
+ Adapter Overrides +
+

+ Toggle "Use own config" to have that adapter ignore the coverage bbox. + Its geographic settings (state, bbox, corridors, observers…) then become + active on the{' '} + + Data Feeds + {' '} + page. +

+
+
+ {COVERAGE_ADAPTERS.map(({ key, label }) => { + const isExcluded = config.excluded_adapters?.includes(key) ?? false + return ( +
+
+ {label} + {isExcluded ? ( + + own config + + ) : ( + + coverage bbox + + )} +
+
+ {isExcluded && ( + + Configure + + )} + +
+
+ ) + })} +
+
+ + {/* Bottom save bar */} + {hasChanges && ( +
+ + +
+ )} +
+ ) +} diff --git a/work/dashboard-frontend/src/pages/Environment.tsx b/work/dashboard-frontend/src/pages/Environment.tsx index 8525580..689b482 100644 --- a/work/dashboard-frontend/src/pages/Environment.tsx +++ b/work/dashboard-frontend/src/pages/Environment.tsx @@ -399,6 +399,11 @@ export default function Environment() { const [floodThreshText, setFloodThreshText] = useState(null) const [floodThreshErr, setFloodThreshErr] = useState(null) + // Coverage config (best-effort; used to conditionally hide scope fields that + // are driven by the universal bbox when an adapter is not excluded). + const [coverageEnabled, setCoverageEnabled] = useState(false) + const [coverageExcluded, setCoverageExcluded] = useState([]) + // ── Notification family gating state ────────────────────────────────────── const [notifConfig, setNotifConfig] = useState(null) const [notifOriginal, setNotifOriginal] = useState('') @@ -608,6 +613,21 @@ export default function Environment() { })() }, []) + // Fetch coverage config (best-effort) to conditionally hide scope fields + // that are driven by the universal bbox when the adapter is not excluded. + useEffect(() => { + ;(async () => { + try { + const res = await fetch('/api/config/coverage') + if (res.ok) { + const data = await res.json() + setCoverageEnabled(!!data.enabled) + setCoverageExcluded(Array.isArray(data.excluded_adapters) ? data.excluded_adapters : []) + } + } catch { /* best-effort; degrade gracefully — scope fields always show if fetch fails */ } + })() + }, []) + // Fetch live secret status once on mount (best-effort) for the key banner. useEffect(() => { ;(async () => { @@ -859,6 +879,11 @@ const save = async () => { const up = (patch: Partial) => env && setEnv({ ...env, ...patch }) + // Returns true when the coverage bbox is active for this adapter (enabled and + // not in excluded_adapters). Scope fields should be hidden in this case. + const scopedByCoverage = (key: string) => + coverageEnabled && !coverageExcluded.includes(key) + // Maps from Environment.tsx adapter key → backend adapter-meta name. // Used to read/write include_in_llm_context per adapter panel. const PANEL_META_KEY: Partial> = { @@ -967,8 +992,16 @@ const save = async () => { const renderSettings = (key: AdapterKey) => { switch (key) { case 'nws': return (<> - up({ nws_zones: v })} helper="Zone IDs like IDZ016, IDZ030" infoLink="https://www.weather.gov/pimar/PubZone" /> - up({ nws: { ...env.nws, areas: v } })} helper="State codes NWS pulls, e.g. ID" /> + {scopedByCoverage('nws') ? ( +
+ Geographic scope (zones, areas) is set by the{' '} + Coverage map. + Enable "Use own config" for NWS on that page to edit zones here. +
+ ) : (<> + up({ nws_zones: v })} helper="Zone IDs like IDZ016, IDZ030" infoLink="https://www.weather.gov/pimar/PubZone" /> + up({ nws: { ...env.nws, areas: v } })} helper="State codes NWS pulls, e.g. ID" /> + )} {env.nws.feed_source !== 'central' && ( <> up({ nws: { ...env.nws, user_agent: v } })} placeholder="(MeshAI, you@email.com)" helper="Format: (app_name, contact_email)" /> @@ -1043,17 +1076,33 @@ const save = async () => { ) case 'ducting': return ( -
+
up({ ducting: { ...env.ducting, tick_seconds: v } })} min={60} /> - up({ ducting: { ...env.ducting, latitude: v } })} step={0.01} /> - up({ ducting: { ...env.ducting, longitude: v } })} step={0.01} /> + {scopedByCoverage('ducting') ? ( +
+ Lat/lon is set by the{' '} + Coverage map. +
+ ) : ( +
+ up({ ducting: { ...env.ducting, latitude: v } })} step={0.01} /> + up({ ducting: { ...env.ducting, longitude: v } })} step={0.01} /> +
+ )}
) case 'fires': return (
{env.fires.feed_source !== 'central' && (
up({ fires: { ...env.fires, tick_seconds: v } })} min={60} /> - up({ fires: { ...env.fires, state: v } })} options={US_STATES} /> + {scopedByCoverage('fires') ? ( +
+ State scoped by{' '} + Coverage map. +
+ ) : ( + up({ fires: { ...env.fires, state: v } })} options={US_STATES} /> + )}
)}
@@ -1126,9 +1175,16 @@ const save = async () => { helper="e.g., 12, 1, 2, 3, 4" />
)} - up({ avalanche: { ...env.avalanche, center_ids: v } })} - helper="e.g., SNFAC" infoLink="https://avalanche.org/avalanche-centers/" /> + {scopedByCoverage('avalanche') ? ( +
+ Avalanche center IDs are set by the{' '} + Coverage map. +
+ ) : ( + up({ avalanche: { ...env.avalanche, center_ids: v } })} + helper="e.g., SNFAC" infoLink="https://avalanche.org/avalanche-centers/" /> + )}
Broadcast Settings @@ -1148,7 +1204,14 @@ const save = async () => { ) case 'usgs': return (<> up({ usgs: { ...env.usgs, tick_seconds: v } })} min={900} helper="Minimum 15 min (900s). tick_seconds is the native-mode poll interval; ignored when this adapter is set to feed_source=central." /> - up({ usgs: { ...env.usgs, sites: v } })} helper="USGS gauge site numbers" infoLink="https://waterdata.usgs.gov/nwis" /> + {scopedByCoverage('usgs') ? ( +
+ Gauge site IDs are set by the{' '} + Coverage map. +
+ ) : ( + up({ usgs: { ...env.usgs, sites: v } })} helper="USGS gauge site numbers" infoLink="https://waterdata.usgs.gov/nwis" /> + )}