mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(coverage): Leaflet Coverage page — draw one bbox that scopes every adapter
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: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a3e5a7518f
commit
0b914d23fc
5 changed files with 650 additions and 53 deletions
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
486
work/dashboard-frontend/src/pages/Coverage.tsx
Normal file
486
work/dashboard-frontend/src/pages/Coverage.tsx
Normal 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='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, © <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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue