feat(coverage): Leaflet Coverage page — draw one bbox that scopes every adapter (#65)

Adds a GENERAL > Coverage page: a Leaflet map to draw/set the universal
coverage bbox (with numeric W/S/E/N inputs), an enabled toggle, and per-
adapter override toggles (excluded_adapters). Environment's per-adapter
geographic-scope fields now show only when that adapter is overridden;
otherwise they point to the Coverage map. Saves via PUT /api/config/coverage.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-06 10:45:17 -06:00 committed by GitHub
commit 9ab2270884
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 650 additions and 53 deletions

View file

@ -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 */}
<Route path="/places" element={<Places />} />
<Route path="/coverage" element={<Coverage />} />
{/* De-navved routes still work */}
<Route path="/gauge-sites" element={<GaugeSites />} />

View file

@ -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 },
],
},
{

View file

@ -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 ? (
<CircleMarker
center={firstCorner}
radius={6}
pathOptions={{ color: '#f59e0b', fillColor: '#f59e0b', fillOpacity: 1 }}
/>
) : null
}
export default function Coverage() {
const [config, setConfig] = useState<CoverageConfig | null>(null)
const [original, setOriginal] = useState<string>('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const { setDirty } = useDirty()
const [clickMode, setClickMode] = useState<ClickMode>('idle')
const [firstCorner, setFirstCorner] = useState<LatLngTuple | null>(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 (
<div className="flex items-center justify-center h-64 text-[#777]">
Loading coverage config
</div>
)
}
if (!config) {
return (
<div className="flex items-center justify-center h-64 text-red-400">
{error || 'No config'}
</div>
)
}
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 (
<div className="space-y-6 max-w-4xl">
{/* Page description + action bar */}
<div className="flex items-start justify-between gap-4">
<p className="text-sm text-[#777]">
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{' '}
<a href="/environment" className="text-accent hover:underline">
Data Feeds
</a>{' '}
page.
</p>
{hasChanges && (
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={discard}
className="flex items-center gap-1 px-3 py-1.5 text-sm text-[#777] hover:text-white border border-border"
>
<RotateCcw size={14} /> Discard
</button>
<button
onClick={save}
disabled={saving}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white disabled:opacity-50"
>
<Save size={14} /> {saving ? 'Saving…' : 'Save'}
</button>
</div>
)}
</div>
{error && <div className="text-sm text-red-400 bg-red-500/10 p-3">{error}</div>}
{success && <div className="text-sm text-green-400 bg-green-500/10 p-3">{success}</div>}
{/* Master enable toggle */}
<div className="border border-border p-4 flex items-center justify-between">
<div>
<span className="text-sm font-medium text-[#e0e0e0]">
Use coverage bbox to scope all adapters
</span>
<p className="text-xs text-[#666] mt-0.5">
When disabled, every adapter uses its own geographic config regardless of the
bbox below.
</p>
</div>
<button
type="button"
onClick={() => setConfig({ ...config, enabled: !config.enabled })}
className={`relative w-10 h-5 rounded-full transition-colors ${
config.enabled ? 'bg-accent' : 'bg-[#333]'
}`}
>
<span
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
config.enabled ? 'translate-x-5' : ''
}`}
/>
</button>
</div>
{/* Leaflet map */}
<div className="border border-border overflow-hidden">
{/* Map toolbar */}
<div className="bg-bg-card border-b border-border px-4 py-2 flex items-center justify-between gap-4">
<span className="text-xs text-[#777] min-w-0 truncate font-mono">{drawHint}</span>
<div className="flex items-center gap-2 flex-shrink-0">
{clickMode !== 'idle' && (
<button
onClick={() => {
setClickMode('idle')
setFirstCorner(null)
}}
className="px-2 py-1 text-xs text-[#777] hover:text-white border border-border"
>
Cancel
</button>
)}
<button
onClick={() => setClickMode('awaiting-first')}
disabled={clickMode !== 'idle'}
className="flex items-center gap-1 px-2 py-1 text-xs bg-accent/20 hover:bg-accent/30 text-accent border border-accent/30 disabled:opacity-40"
>
<MousePointer size={12} />
{bbox ? 'Redraw' : 'Draw bbox'}
</button>
</div>
</div>
<MapContainer
center={defaultCenter}
zoom={4}
style={{ width: '100%', height: '400px' }}
className="z-0"
>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, &copy; <a href="https://carto.com/attributions">CARTO</a>'
/>
<InitialFit bounds={leafletBounds} />
{leafletBounds && (
<Rectangle
bounds={leafletBounds}
pathOptions={{
color: '#f59e0b',
fillColor: '#f59e0b',
fillOpacity: 0.08,
weight: 2,
}}
/>
)}
<MapClickHandler
mode={clickMode}
firstCorner={firstCorner}
onFirstClick={handleFirstClick}
onSecondClick={handleSecondClick}
/>
</MapContainer>
</div>
{/* Numeric W/S/E/N inputs — always visible, sync with map */}
<div className="border border-border p-4 space-y-3">
<div className="text-xs font-sans font-medium uppercase tracking-widest text-[#666]">
Bounding Box Coordinates
</div>
<div className="grid grid-cols-4 gap-3">
{bboxFields.map(({ label, placeholder }, i) => (
<div key={label}>
<label className="text-xs text-[#777] mb-1 block">{label}</label>
<input
type="number"
step="0.01"
value={bbox !== null ? bbox[i] : ''}
placeholder={placeholder}
onChange={(e) => handleBboxInput(i, e.target.value)}
className="w-full bg-[#0d0d0d] border border-border px-3 py-2 text-sm font-mono"
/>
</div>
))}
</div>
<p className="text-xs text-[#666]">
Decimal degrees. W/E = longitude; S/N = latitude. Inputs stay in sync with
the map rectangle above.
</p>
</div>
{/* Per-adapter override toggles */}
<div className="border border-border p-4 space-y-4">
<div>
<div className="text-xs font-sans font-medium uppercase tracking-widest text-[#666] mb-1">
Adapter Overrides
</div>
<p className="text-xs text-[#777]">
Toggle "Use own config" to have that adapter ignore the coverage bbox.
Its geographic settings (state, bbox, corridors, observers) then become
active on the{' '}
<a href="/environment" className="text-accent hover:underline">
Data Feeds
</a>{' '}
page.
</p>
</div>
<div className="divide-y divide-border">
{COVERAGE_ADAPTERS.map(({ key, label }) => {
const isExcluded = config.excluded_adapters?.includes(key) ?? false
return (
<div key={key} className="flex items-center justify-between py-2.5">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm text-[#e0e0e0]">{label}</span>
{isExcluded ? (
<span className="text-[10px] text-accent/70 uppercase tracking-wide">
own config
</span>
) : (
<span className="text-[10px] text-[#555] uppercase tracking-wide">
coverage bbox
</span>
)}
</div>
<div className="flex items-center gap-3 flex-shrink-0">
{isExcluded && (
<a
href="/environment"
className="text-xs text-accent hover:underline"
>
Configure
</a>
)}
<label className="flex items-center gap-2 cursor-pointer select-none">
<span className="text-xs text-[#666] whitespace-nowrap">
Use own config
</span>
<button
type="button"
onClick={() => toggleExcluded(key)}
className={`relative w-8 h-4 rounded-full transition-colors ${
isExcluded ? 'bg-accent' : 'bg-[#333]'
}`}
>
<span
className={`absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white transition-transform ${
isExcluded ? 'translate-x-4' : ''
}`}
/>
</button>
</label>
</div>
</div>
)
})}
</div>
</div>
{/* Bottom save bar */}
{hasChanges && (
<div className="flex justify-end gap-2 pb-2">
<button
onClick={discard}
className="flex items-center gap-1 px-3 py-1.5 text-sm text-[#777] hover:text-white border border-border"
>
<RotateCcw size={14} /> Discard
</button>
<button
onClick={save}
disabled={saving}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white disabled:opacity-50"
>
<Save size={14} /> {saving ? 'Saving…' : 'Save'}
</button>
</div>
)}
</div>
)
}

View file

@ -399,6 +399,11 @@ export default function Environment() {
const [floodThreshText, setFloodThreshText] = useState<string | null>(null)
const [floodThreshErr, setFloodThreshErr] = useState<string | null>(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<string[]>([])
// ── Notification family gating state ──────────────────────────────────────
const [notifConfig, setNotifConfig] = useState<NotificationsConfig | null>(null)
const [notifOriginal, setNotifOriginal] = useState<string>('')
@ -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<EnvConfig>) => 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<Record<AdapterKey, string>> = {
@ -967,8 +992,16 @@ const save = async () => {
const renderSettings = (key: AdapterKey) => {
switch (key) {
case 'nws': return (<>
<ListInput label="NWS Zones" value={env.nws_zones} onChange={(v) => up({ nws_zones: v })} helper="Zone IDs like IDZ016, IDZ030" infoLink="https://www.weather.gov/pimar/PubZone" />
<ListInput label="NWS Areas" value={env.nws.areas ?? []} onChange={(v) => up({ nws: { ...env.nws, areas: v } })} helper="State codes NWS pulls, e.g. ID" />
{scopedByCoverage('nws') ? (
<div className="text-xs text-[#666] bg-bg-hover px-3 py-2 border border-border/50">
Geographic scope (zones, areas) is set by the{' '}
<a href="/coverage" className="text-accent hover:underline">Coverage map</a>.
Enable "Use own config" for NWS on that page to edit zones here.
</div>
) : (<>
<ListInput label="NWS Zones" value={env.nws_zones} onChange={(v) => up({ nws_zones: v })} helper="Zone IDs like IDZ016, IDZ030" infoLink="https://www.weather.gov/pimar/PubZone" />
<ListInput label="NWS Areas" value={env.nws.areas ?? []} onChange={(v) => up({ nws: { ...env.nws, areas: v } })} helper="State codes NWS pulls, e.g. ID" />
</>)}
{env.nws.feed_source !== 'central' && (
<>
<TextInput label="User Agent" value={env.nws.user_agent} onChange={(v) => 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 () => {
</div>
)
case 'ducting': return (
<div className="grid grid-cols-3 gap-4">
<div className="space-y-3">
<NumberInput label="Tick Seconds" value={env.ducting.tick_seconds} onChange={(v) => up({ ducting: { ...env.ducting, tick_seconds: v } })} min={60} />
<NumberInput label="Latitude" value={env.ducting.latitude} onChange={(v) => up({ ducting: { ...env.ducting, latitude: v } })} step={0.01} />
<NumberInput label="Longitude" value={env.ducting.longitude} onChange={(v) => up({ ducting: { ...env.ducting, longitude: v } })} step={0.01} />
{scopedByCoverage('ducting') ? (
<div className="text-xs text-[#666] bg-bg-hover px-3 py-2 border border-border/50">
Lat/lon is set by the{' '}
<a href="/coverage" className="text-accent hover:underline">Coverage map</a>.
</div>
) : (
<div className="grid grid-cols-2 gap-4">
<NumberInput label="Latitude" value={env.ducting.latitude} onChange={(v) => up({ ducting: { ...env.ducting, latitude: v } })} step={0.01} />
<NumberInput label="Longitude" value={env.ducting.longitude} onChange={(v) => up({ ducting: { ...env.ducting, longitude: v } })} step={0.01} />
</div>
)}
</div>)
case 'fires': return (
<div className="space-y-6">
{env.fires.feed_source !== 'central' && (
<div className="grid grid-cols-2 gap-4">
<NumberInput label="Tick Seconds" value={env.fires.tick_seconds} onChange={(v) => up({ fires: { ...env.fires, tick_seconds: v } })} min={60} />
<SelectInput label="State" value={env.fires.state} onChange={(v) => up({ fires: { ...env.fires, state: v } })} options={US_STATES} />
{scopedByCoverage('fires') ? (
<div className="text-xs text-[#666] bg-bg-hover px-3 py-2 border border-border/50 self-end">
State scoped by{' '}
<a href="/coverage" className="text-accent hover:underline">Coverage map</a>.
</div>
) : (
<SelectInput label="State" value={env.fires.state} onChange={(v) => up({ fires: { ...env.fires, state: v } })} options={US_STATES} />
)}
</div>
)}
<div>
@ -1126,9 +1175,16 @@ const save = async () => {
helper="e.g., 12, 1, 2, 3, 4" />
</div>
)}
<ListInput label="Center IDs" value={env.avalanche.center_ids}
onChange={(v) => up({ avalanche: { ...env.avalanche, center_ids: v } })}
helper="e.g., SNFAC" infoLink="https://avalanche.org/avalanche-centers/" />
{scopedByCoverage('avalanche') ? (
<div className="text-xs text-[#666] bg-bg-hover px-3 py-2 border border-border/50">
Avalanche center IDs are set by the{' '}
<a href="/coverage" className="text-accent hover:underline">Coverage map</a>.
</div>
) : (
<ListInput label="Center IDs" value={env.avalanche.center_ids}
onChange={(v) => up({ avalanche: { ...env.avalanche, center_ids: v } })}
helper="e.g., SNFAC" infoLink="https://avalanche.org/avalanche-centers/" />
)}
<div>
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">
Broadcast Settings
@ -1148,7 +1204,14 @@ const save = async () => {
)
case 'usgs': return (<>
<NumberInput label="Tick Seconds" value={env.usgs.tick_seconds} onChange={(v) => 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." />
<ListInput label="Site IDs" value={env.usgs.sites} onChange={(v) => up({ usgs: { ...env.usgs, sites: v } })} helper="USGS gauge site numbers" infoLink="https://waterdata.usgs.gov/nwis" />
{scopedByCoverage('usgs') ? (
<div className="text-xs text-[#666] bg-bg-hover px-3 py-2 border border-border/50">
Gauge site IDs are set by the{' '}
<a href="/coverage" className="text-accent hover:underline">Coverage map</a>.
</div>
) : (
<ListInput label="Site IDs" value={env.usgs.sites} onChange={(v) => up({ usgs: { ...env.usgs, sites: v } })} helper="USGS gauge site numbers" infoLink="https://waterdata.usgs.gov/nwis" />
)}
<div>
<label className="text-xs font-sans text-[#777] mb-1 block">Flood Thresholds (advanced JSON)</label>
<textarea
@ -1195,9 +1258,16 @@ const save = async () => {
onChange={(v) => up({ usgs_quake: { ...env.usgs_quake, min_magnitude: v } })}
step={0.1} min={0} helper="Native quake magnitude floor" />
</div>
<NumberListInput label="Bounding Box [W, S, E, N]" value={env.usgs_quake.bbox ?? []}
onChange={(v) => up({ usgs_quake: { ...env.usgs_quake, bbox: v } })}
helper="Four values: west, south, east, north" />
{scopedByCoverage('usgs_quake') ? (
<div className="text-xs text-[#666] bg-bg-hover px-3 py-2 border border-border/50">
Bounding box is set by the{' '}
<a href="/coverage" className="text-accent hover:underline">Coverage map</a>.
</div>
) : (
<NumberListInput label="Bounding Box [W, S, E, N]" value={env.usgs_quake.bbox ?? []}
onChange={(v) => up({ usgs_quake: { ...env.usgs_quake, bbox: v } })}
helper="Four values: west, south, east, north" />
)}
</div>
</div>
<div>
@ -1249,16 +1319,23 @@ const save = async () => {
case 'traffic': return (<>
<ManagedSecret envVar="TOMTOM_API_KEY" label="API Key" helper="developer.tomtom.com" />
<NumberInput label="Tick Seconds" value={env.traffic.tick_seconds} onChange={(v) => up({ traffic: { ...env.traffic, tick_seconds: v } })} min={60} />
<div className="text-xs text-[#666] mt-2">Corridors:</div>
{(env.traffic.corridors || []).map((c, i) => (
<div key={i} className="grid grid-cols-4 gap-2 items-end">
<TextInput label="Name" value={c.name} onChange={(v) => { const n = [...env.traffic.corridors]; n[i] = { ...c, name: v }; up({ traffic: { ...env.traffic, corridors: n } }) }} />
<NumberInput label="Lat" value={c.lat} onChange={(v) => { const n = [...env.traffic.corridors]; n[i] = { ...c, lat: v }; up({ traffic: { ...env.traffic, corridors: n } }) }} step={0.01} />
<NumberInput label="Lon" value={c.lon} onChange={(v) => { const n = [...env.traffic.corridors]; n[i] = { ...c, lon: v }; up({ traffic: { ...env.traffic, corridors: n } }) }} step={0.01} />
<button onClick={() => up({ traffic: { ...env.traffic, corridors: env.traffic.corridors.filter((_, j) => j !== i) } })} className="px-2 py-2 text-xs text-red-400 hover:text-red-300 border border-red-400/30">Remove</button>
{scopedByCoverage('traffic') ? (
<div className="text-xs text-[#666] bg-bg-hover px-3 py-2 border border-border/50">
Traffic corridors are set by the{' '}
<a href="/coverage" className="text-accent hover:underline">Coverage map</a>.
</div>
))}
<button onClick={() => up({ traffic: { ...env.traffic, corridors: [...(env.traffic.corridors || []), { name: '', lat: 0, lon: 0 }] } })} className="text-xs text-accent hover:underline">+ Add Corridor</button>
) : (<>
<div className="text-xs text-[#666] mt-2">Corridors:</div>
{(env.traffic.corridors || []).map((c, i) => (
<div key={i} className="grid grid-cols-4 gap-2 items-end">
<TextInput label="Name" value={c.name} onChange={(v) => { const n = [...env.traffic.corridors]; n[i] = { ...c, name: v }; up({ traffic: { ...env.traffic, corridors: n } }) }} />
<NumberInput label="Lat" value={c.lat} onChange={(v) => { const n = [...env.traffic.corridors]; n[i] = { ...c, lat: v }; up({ traffic: { ...env.traffic, corridors: n } }) }} step={0.01} />
<NumberInput label="Lon" value={c.lon} onChange={(v) => { const n = [...env.traffic.corridors]; n[i] = { ...c, lon: v }; up({ traffic: { ...env.traffic, corridors: n } }) }} step={0.01} />
<button onClick={() => up({ traffic: { ...env.traffic, corridors: env.traffic.corridors.filter((_, j) => j !== i) } })} className="px-2 py-2 text-xs text-red-400 hover:text-red-300 border border-red-400/30">Remove</button>
</div>
))}
<button onClick={() => up({ traffic: { ...env.traffic, corridors: [...(env.traffic.corridors || []), { name: '', lat: 0, lon: 0 }] } })} className="text-xs text-accent hover:underline">+ Add Corridor</button>
</>)}
<div className="border-t border-border pt-4 mt-4">
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">Broadcast Filters</div>
<div className="grid grid-cols-2 gap-4">
@ -1294,11 +1371,18 @@ const save = async () => {
<ManagedSecret envVar="ROADS511_API_KEY" label="API Key" helper="Leave unset if 511 needs no key" />
<NumberInput label="Tick Seconds" value={env.roads511.tick_seconds} onChange={(v) => up({ roads511: { ...env.roads511, tick_seconds: v } })} min={60} />
<ListInput label="Endpoints" value={env.roads511.endpoints} onChange={(v) => up({ roads511: { ...env.roads511, endpoints: v } })} helper="e.g., /get/event" />
<div className="grid grid-cols-4 gap-2">
{(['West', 'South', 'East', 'North'] as const).map((lbl, i) => (
<NumberInput key={lbl} label={lbl} value={env.roads511.bbox?.[i] ?? 0} onChange={(v) => { const b = [...(env.roads511.bbox || [0, 0, 0, 0])]; b[i] = v; up({ roads511: { ...env.roads511, bbox: b } }) }} step={0.01} />
))}
</div>
{scopedByCoverage('roads511') ? (
<div className="text-xs text-[#666] bg-bg-hover px-3 py-2 border border-border/50">
Bounding box is set by the{' '}
<a href="/coverage" className="text-accent hover:underline">Coverage map</a>.
</div>
) : (
<div className="grid grid-cols-4 gap-2">
{(['West', 'South', 'East', 'North'] as const).map((lbl, i) => (
<NumberInput key={lbl} label={lbl} value={env.roads511.bbox?.[i] ?? 0} onChange={(v) => { const b = [...(env.roads511.bbox || [0, 0, 0, 0])]; b[i] = v; up({ roads511: { ...env.roads511, bbox: b } }) }} step={0.01} />
))}
</div>
)}
<div className="border-t border-border pt-4 mt-4">
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">Broadcast Filters</div>
<div className="grid grid-cols-2 gap-4">
@ -1351,14 +1435,21 @@ const save = async () => {
<ManagedSecret envVar="WZDX_API_KEY" label="API Key" helper="Leave unset if not required" />
<NumberInput label="Tick Seconds" value={env.wzdx?.tick_seconds ?? 300} onChange={(v) => up({ wzdx: { ...env.wzdx!, tick_seconds: v } })} min={60} />
<ListInput label="Endpoints" value={env.wzdx?.endpoints ?? ['/get/event']} onChange={(v) => up({ wzdx: { ...env.wzdx!, endpoints: v } })} helper="e.g., /get/event" />
<div className="grid grid-cols-4 gap-2">
{(['West', 'South', 'East', 'North'] as const).map((lbl, i) => (
<NumberInput key={lbl} label={lbl} value={env.wzdx?.bbox?.[i] ?? 0} onChange={(v) => { const b = [...(env.wzdx?.bbox || [0, 0, 0, 0])]; b[i] = v; up({ wzdx: { ...env.wzdx!, bbox: b } }) }} step={0.01} />
))}
</div>
<div className="text-xs text-[#666]">Bounding box [W,S,E,N] geographic filter</div>
<ListInput label="States" value={env.wzdx?.states ?? []} onChange={(v) => up({ wzdx: { ...env.wzdx!, states: v } })}
helper="2-letter state codes to include from the WZDx Feed Registry, e.g. ID, OR" />
{scopedByCoverage('wzdx') ? (
<div className="text-xs text-[#666] bg-bg-hover px-3 py-2 border border-border/50">
Bounding box and states are set by the{' '}
<a href="/coverage" className="text-accent hover:underline">Coverage map</a>.
</div>
) : (<>
<div className="grid grid-cols-4 gap-2">
{(['West', 'South', 'East', 'North'] as const).map((lbl, i) => (
<NumberInput key={lbl} label={lbl} value={env.wzdx?.bbox?.[i] ?? 0} onChange={(v) => { const b = [...(env.wzdx?.bbox || [0, 0, 0, 0])]; b[i] = v; up({ wzdx: { ...env.wzdx!, bbox: b } }) }} step={0.01} />
))}
</div>
<div className="text-xs text-[#666]">Bounding box [W,S,E,N] geographic filter</div>
<ListInput label="States" value={env.wzdx?.states ?? []} onChange={(v) => up({ wzdx: { ...env.wzdx!, states: v } })}
helper="2-letter state codes to include from the WZDx Feed Registry, e.g. ID, OR" />
</>)}
<TextInput label="Registry URL" value={env.wzdx?.registry_url ?? ''} onChange={(v) => up({ wzdx: { ...env.wzdx!, registry_url: v } })}
placeholder="https://datahub.transportation.gov/resource/69qe-yiui.json?$limit=200"
helper="FHWA WZDx Feed Registry (Socrata) URL — lists every state DOT feed" />
@ -1416,11 +1507,18 @@ const save = async () => {
<SelectInput label="Min Confidence" value={env.firms.confidence_min} onChange={(v) => up({ firms: { ...env.firms, confidence_min: v } })} options={[{ value: 'low', label: 'Low' }, { value: 'nominal', label: 'Nominal' }, { value: 'high', label: 'High' }]} />
<NumberInput label="Proximity (km)" value={env.firms.proximity_km} onChange={(v) => up({ firms: { ...env.firms, proximity_km: v } })} step={0.5} />
</div>
<div className="grid grid-cols-4 gap-2">
{(['West', 'South', 'East', 'North'] as const).map((lbl, i) => (
<NumberInput key={lbl} label={lbl} value={env.firms.bbox?.[i] ?? 0} onChange={(v) => { const b = [...(env.firms.bbox || [0, 0, 0, 0])]; b[i] = v; up({ firms: { ...env.firms, bbox: b } }) }} step={0.01} />
))}
</div>
{scopedByCoverage('firms') ? (
<div className="text-xs text-[#666] bg-bg-hover px-3 py-2 border border-border/50">
Bounding box is set by the{' '}
<a href="/coverage" className="text-accent hover:underline">Coverage map</a>.
</div>
) : (
<div className="grid grid-cols-4 gap-2">
{(['West', 'South', 'East', 'North'] as const).map((lbl, i) => (
<NumberInput key={lbl} label={lbl} value={env.firms.bbox?.[i] ?? 0} onChange={(v) => { const b = [...(env.firms.bbox || [0, 0, 0, 0])]; b[i] = v; up({ firms: { ...env.firms, bbox: b } }) }} step={0.01} />
))}
</div>
)}
</>)
case 'satpass': {
// Armed state keys off the NATIVE enable (environmental.satpass.enabled),
@ -1493,19 +1591,26 @@ const save = async () => {
{/* Observers — list of {slug, name, lat, lon, alt_m}; seeds observer_locations */}
<div>
<div className="text-xs text-[#777] mb-2">Observers (ground stations the predictor computes passes for)</div>
<div className="space-y-2">
{(env.satpass.observers || []).map((o, i) => (
<div key={i} className="grid grid-cols-6 gap-2 items-end">
<TextInput label="Slug" value={o.slug} onChange={(v) => { const n = [...env.satpass.observers]; n[i] = { ...o, slug: v }; up({ satpass: { ...env.satpass, observers: n } }) }} placeholder="tvly" />
<TextInput label="Name" value={o.name} onChange={(v) => { const n = [...env.satpass.observers]; n[i] = { ...o, name: v }; up({ satpass: { ...env.satpass, observers: n } }) }} placeholder="Treasure Valley" />
<NumberInput label="Lat" value={o.lat} onChange={(v) => { const n = [...env.satpass.observers]; n[i] = { ...o, lat: v }; up({ satpass: { ...env.satpass, observers: n } }) }} step={0.0001} />
<NumberInput label="Lon" value={o.lon} onChange={(v) => { const n = [...env.satpass.observers]; n[i] = { ...o, lon: v }; up({ satpass: { ...env.satpass, observers: n } }) }} step={0.0001} />
<NumberInput label="Alt (m)" value={o.alt_m} onChange={(v) => { const n = [...env.satpass.observers]; n[i] = { ...o, alt_m: v }; up({ satpass: { ...env.satpass, observers: n } }) }} step={1} />
<button onClick={() => up({ satpass: { ...env.satpass, observers: env.satpass.observers.filter((_, j) => j !== i) } })} className="px-2 py-2 text-xs text-red-400 hover:text-red-300 border border-red-400/30">Remove</button>
</div>
))}
</div>
<button onClick={() => up({ satpass: { ...env.satpass, observers: [...(env.satpass.observers || []), { slug: '', name: '', lat: 0, lon: 0, alt_m: 0 }] } })} className="text-xs text-accent hover:underline mt-2">+ Add Observer</button>
{scopedByCoverage('satpass') ? (
<div className="text-xs text-[#666] bg-bg-hover px-3 py-2 border border-border/50">
Observer locations are set by the{' '}
<a href="/coverage" className="text-accent hover:underline">Coverage map</a>.
</div>
) : (<>
<div className="space-y-2">
{(env.satpass.observers || []).map((o, i) => (
<div key={i} className="grid grid-cols-6 gap-2 items-end">
<TextInput label="Slug" value={o.slug} onChange={(v) => { const n = [...env.satpass.observers]; n[i] = { ...o, slug: v }; up({ satpass: { ...env.satpass, observers: n } }) }} placeholder="tvly" />
<TextInput label="Name" value={o.name} onChange={(v) => { const n = [...env.satpass.observers]; n[i] = { ...o, name: v }; up({ satpass: { ...env.satpass, observers: n } }) }} placeholder="Treasure Valley" />
<NumberInput label="Lat" value={o.lat} onChange={(v) => { const n = [...env.satpass.observers]; n[i] = { ...o, lat: v }; up({ satpass: { ...env.satpass, observers: n } }) }} step={0.0001} />
<NumberInput label="Lon" value={o.lon} onChange={(v) => { const n = [...env.satpass.observers]; n[i] = { ...o, lon: v }; up({ satpass: { ...env.satpass, observers: n } }) }} step={0.0001} />
<NumberInput label="Alt (m)" value={o.alt_m} onChange={(v) => { const n = [...env.satpass.observers]; n[i] = { ...o, alt_m: v }; up({ satpass: { ...env.satpass, observers: n } }) }} step={1} />
<button onClick={() => up({ satpass: { ...env.satpass, observers: env.satpass.observers.filter((_, j) => j !== i) } })} className="px-2 py-2 text-xs text-red-400 hover:text-red-300 border border-red-400/30">Remove</button>
</div>
))}
</div>
<button onClick={() => up({ satpass: { ...env.satpass, observers: [...(env.satpass.observers || []), { slug: '', name: '', lat: 0, lon: 0, alt_m: 0 }] } })} className="text-xs text-accent hover:underline mt-2">+ Add Observer</button>
</>)}
</div>
<ListInput label="TLE Groups" value={env.satpass.tle_groups}

View file

@ -33,6 +33,7 @@ RESTART_REQUIRED_SECTIONS = {
"meshmonitor",
"dashboard",
"environmental",
"coverage",
}
# Valid config section names
@ -56,6 +57,7 @@ VALID_SECTIONS = {
"mesh_intelligence",
"dashboard",
"danger_zones",
"coverage",
}