feat(dashboard): v0.4 C.2 family-tab restructure -- 7 families x per-adapter feed_source toggle

Restructures the environmental config UI into the 7-family taxonomy on the
/environment page (Matt-approved Option C: unified families = config +
live status per adapter). The editable adapter config moves out of the
Config page's "Environmental" tab (now deprecated) onto /environment, where
each adapter sub-tab shows its AdapterPanel (on/off + feed_source + settings)
together with its live status (feed health + active events).

Frontend (dashboard-frontend/src):
- pages/Environment.tsx rewritten: 7 family tabs (Weather, Fire, RF
  Propagation, Roads, Geohazards, Tracking, Mesh Health) -> per-family
  adapter sub-tab strip -> AdapterPanel.
  * AdapterPanel: header row = on/off Toggle + feed_source toggle
    (native|central). When OFF, feed_source + all settings grey out
    (disabled, not hidden).
  * Native-only adapters (ducting, avalanche, roads511 -- no Central stream
    per C.1's ADAPTER_SUBJECTS) show the feed_source toggle with 'central'
    disabled + a 'Central not available for this adapter' tooltip/label.
  * Missing-key adapters (firms, roads511) show an 'API key not configured
    -- contact admin' notice; toggles still operate.
  * Tracking = placeholder ('No adapters yet. ADS-B / AIS / satellite passes
    are planned for v0.5.'). Mesh Health = no env adapters; a disabled
    feed_source toggle with 'central' greyed for future migration.
  * All existing per-adapter settings preserved verbatim (NWS zones/user_agent/
    severity, ducting lat/lon, fires state, avalanche centers/season, USGS
    sites, traffic corridors+key, roads511 base_url/key/endpoints/bbox, FIRMS
    map_key/source/confidence/bbox). ALSO adds a usgs_quake panel
    (tick/min_magnitude/region/bbox) -- usgs_quake (2.14) was never exposed in
    the old GUI. feed_source field (C.1) now surfaced per adapter.
  * Per-adapter live status: reuses /api/env/status feed health + /api/env/active
    events (filtered by source; fires->nifc mapping). Refreshes every 30s.
- pages/Config.tsx: removed the now-duplicate 'Environmental' tab (SECTIONS
  entry + render case + EnvironmentalSection function + unused Thermometer
  import); exported the shared form primitives (Toggle, TextInput, NumberInput,
  SelectInput, ListInput, NumberListInput, US_STATES) for reuse by Environment.tsx.
- Reuses the existing restart_required banner pattern.
- Rebuilt static: meshai/dashboard/static/{index.html, assets/index-9OZ6ZqzI.js,
  index-B_J_Z7c8.css} (vite emptyOutDir replaced the old hashed bundle).

Rule 17 / no backend change: config is wired to the existing schema-driven
GET/PUT /api/config/environmental (the C.1 feed_source + central + usgs_quake
fields ride the generic dataclass coercion). No backend edited this phase.

Verification: (A) `npm run build` clean -- tsc strict + vite, only the
pre-existing >500kB single-chunk advisory (not introduced here). (B) static
committed; prod rebuilt picks it up. (C) GET / returns the new SPA shell
(index-9OZ6ZqzI.js); the bundle contains the new family strings (Geohazards,
RF Propagation, ADS-B, 'Central not available', 'API key not configured').
(D) GET /api/config/environmental returns all adapters with feed_source=native,
usgs_quake present, central{enabled:false} -- toggles bind to real data; all
native, nothing flipped. Rebuilt prod healthy.

*** BLOCKER FOUND (pre-existing, NOT introduced by C.2) -- flagged for C.2.1 ***
The save half of gate D fails: PUT /api/config/environmental returns
{"detail":"could not determine a constructor for the tag '!include' ..."}.
Root cause: the dashboard PUT handler (meshai/dashboard/api/config_routes.py)
calls meshai/config.py::save_config (monolithic; re-parses config.yaml with a
loader lacking the !include constructor) instead of the multi-file-aware
meshai/config_loader.py::save_section that exists for exactly the !include
layout. This breaks ALL GUI config saves in prod (every section, not just
environmental) and predates C.2 -- the old Config 'Environmental' tab had the
same broken save; C.1 did not touch save_config. The C.2 GET/render/toggle-bind
works; only persistence is blocked. Verified disk pristine (idempotent PUT
errored, wrote nothing; env_feeds.yaml md5 unchanged, restored from backup).
FIX (one line, but prod-wide blast radius -> wants its own phase + verification):
config_routes PUT should call config_loader.save_section(section, data, config_dir)
instead of config.save_config(...). Recommend C.2.1 backend fix before C.3.

C.3 (quake-to-central flip) should not proceed until C.2.1 unblocks GUI save,
since flipping feed_source from the GUI is the whole point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-28 03:09:16 +00:00
commit 8eb0c6468c
7 changed files with 891 additions and 1641 deletions

View file

@ -4,7 +4,7 @@ import ChannelPicker from '@/components/ChannelPicker'
import {
Settings, Bot, Wifi, MessageSquare, Database, Brain, Eye,
Terminal, Cpu, Cloud, Radio, BookOpen, Layers, Activity,
Thermometer, LayoutDashboard, Save, RotateCcw, RefreshCw,
LayoutDashboard, Save, RotateCcw, RefreshCw,
Plus, Trash2, ChevronDown, ChevronRight, AlertTriangle,
Check, X, Eye as EyeIcon, EyeOff, ExternalLink
} from 'lucide-react'
@ -233,7 +233,6 @@ const SECTIONS: { key: SectionKey; label: string; icon: typeof Settings }[] = [
{ key: 'knowledge', label: 'Knowledge', icon: BookOpen },
{ key: 'mesh_sources', label: 'Mesh Sources', icon: Layers },
{ key: 'mesh_intelligence', label: 'Intelligence', icon: Activity },
{ key: 'environmental', label: 'Environmental', icon: Thermometer },
{ key: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
]
@ -281,7 +280,7 @@ const AVAILABLE_COMMANDS = [
]
// US States for dropdown
const US_STATES = [
export const US_STATES = [
{ value: 'US-AL', label: 'Alabama' }, { value: 'US-AK', label: 'Alaska' },
{ value: 'US-AZ', label: 'Arizona' }, { value: 'US-AR', label: 'Arkansas' },
{ value: 'US-CA', label: 'California' }, { value: 'US-CO', label: 'Colorado' },
@ -375,7 +374,7 @@ function SectionDescription({ text }: { text: string }) {
}
// Form components
function TextInput({ label, value, onChange, type = 'text', placeholder = '', helper = '', info = '', infoLink = '' }: {
export function TextInput({ label, value, onChange, type = 'text', placeholder = '', helper = '', info = '', infoLink = '' }: {
label: string
value: string
onChange: (v: string) => void
@ -417,7 +416,7 @@ function TextInput({ label, value, onChange, type = 'text', placeholder = '', he
)
}
function NumberInput({ label, value, onChange, min, max, step = 1, helper = '', info = '', infoLink = '' }: {
export function NumberInput({ label, value, onChange, min, max, step = 1, helper = '', info = '', infoLink = '' }: {
label: string
value: number
onChange: (v: number) => void
@ -448,7 +447,7 @@ function NumberInput({ label, value, onChange, min, max, step = 1, helper = '',
)
}
function Toggle({ label, checked, onChange, helper = '', info = '', infoLink = '' }: {
export function Toggle({ label, checked, onChange, helper = '', info = '', infoLink = '' }: {
label: string
checked: boolean
onChange: (v: boolean) => void
@ -482,7 +481,7 @@ function Toggle({ label, checked, onChange, helper = '', info = '', infoLink = '
)
}
function SelectInput({ label, value, onChange, options, helper = '', info = '', infoLink = '' }: {
export function SelectInput({ label, value, onChange, options, helper = '', info = '', infoLink = '' }: {
label: string
value: string
onChange: (v: string) => void
@ -537,7 +536,7 @@ function TextArea({ label, value, onChange, rows = 4, helper = '', info = '', in
)
}
function ListInput({ label, value, onChange, helper = '', info = '', infoLink = '' }: {
export function ListInput({ label, value, onChange, helper = '', info = '', infoLink = '' }: {
label: string
value: string[]
onChange: (v: string[]) => void
@ -575,7 +574,7 @@ function ListInput({ label, value, onChange, helper = '', info = '', infoLink =
)
}
function NumberListInput({ label, value, onChange, helper = '', info = '', infoLink = '' }: {
export function NumberListInput({ label, value, onChange, helper = '', info = '', infoLink = '' }: {
label: string
value: number[]
onChange: (v: number[]) => void
@ -1752,415 +1751,6 @@ function MeshIntelligenceSection({ data, onChange }: { data: MeshIntelligenceCon
)
}
function EnvironmentalSection({ data, onChange }: { data: EnvironmentalConfig; onChange: (d: EnvironmentalConfig) => void }) {
return (
<div className="space-y-6">
<SectionDescription text={SECTION_DESCRIPTIONS.environmental} />
<Toggle
label="Enable Environmental Feeds"
checked={data.enabled}
onChange={(v) => onChange({ ...data, enabled: v })}
helper="Activate live data polling"
/>
{data.enabled && (
<>
<ListInput
label="NWS Zones"
value={data.nws_zones}
onChange={(v) => onChange({ ...data, nws_zones: v })}
helper="Zone IDs like IDZ016, IDZ030"
info="NWS forecast zones covering your mesh area. Find yours at https://www.weather.gov/pimar/PubZone"
infoLink="https://www.weather.gov/pimar/PubZone"
/>
<div className="border border-[#1e2a3a] rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-300">NWS Weather Alerts</span>
<Toggle label="" checked={data.nws.enabled} onChange={(v) => onChange({ ...data, nws: { ...data.nws, enabled: v } })} />
</div>
{data.nws.enabled && (
<>
<TextInput
label="User Agent"
value={data.nws.user_agent}
onChange={(v) => onChange({ ...data, nws: { ...data.nws, user_agent: v } })}
placeholder="(MeshAI, your@email.com)"
helper="Required format: (app_name, contact_email)"
info="Required by NWS. You make it up - just use the format (app_name, your_email). No signup needed."
/>
<div className="grid grid-cols-2 gap-4">
<NumberInput
label="Tick Seconds"
value={data.nws.tick_seconds}
onChange={(v) => onChange({ ...data, nws: { ...data.nws, tick_seconds: v } })}
min={30}
helper="Polling interval"
/>
<SelectInput
label="Min Severity"
value={data.nws.severity_min}
onChange={(v) => onChange({ ...data, nws: { ...data.nws, severity_min: v } })}
options={[
{ value: 'minor', label: 'Minor' },
{ value: 'moderate', label: 'Moderate' },
{ value: 'severe', label: 'Severe' },
{ value: 'extreme', label: 'Extreme' },
]}
helper="Filter out lower severity alerts"
info="Minimum severity level to display. 'Moderate' filters out minor advisories. 'Severe' shows only serious warnings."
/>
</div>
</>
)}
</div>
<div className="border border-[#1e2a3a] rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-slate-300">NOAA Space Weather (SWPC)</span>
<p className="text-xs text-slate-600">Solar indices, geomagnetic storms, HF propagation</p>
</div>
<Toggle label="" checked={data.swpc.enabled} onChange={(v) => onChange({ ...data, swpc: { ...data.swpc, enabled: v } })} />
</div>
</div>
<div className="border border-[#1e2a3a] rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-slate-300">Tropospheric Ducting</span>
<p className="text-xs text-slate-600">VHF/UHF extended range conditions</p>
</div>
<Toggle label="" checked={data.ducting.enabled} onChange={(v) => onChange({ ...data, ducting: { ...data.ducting, enabled: v } })} />
</div>
{data.ducting.enabled && (
<div className="grid grid-cols-3 gap-4">
<NumberInput
label="Tick Seconds"
value={data.ducting.tick_seconds}
onChange={(v) => onChange({ ...data, ducting: { ...data.ducting, tick_seconds: v } })}
min={60}
/>
<NumberInput
label="Latitude"
value={data.ducting.latitude}
onChange={(v) => onChange({ ...data, ducting: { ...data.ducting, latitude: v } })}
step={0.01}
info="Center point of your mesh coverage area. The ducting adapter checks atmospheric conditions at this location."
/>
<NumberInput
label="Longitude"
value={data.ducting.longitude}
onChange={(v) => onChange({ ...data, ducting: { ...data.ducting, longitude: v } })}
step={0.01}
/>
</div>
)}
</div>
<div className="border border-[#1e2a3a] rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-slate-300">NIFC Fire Perimeters</span>
<p className="text-xs text-slate-600">Active wildfires from National Interagency Fire Center</p>
</div>
<Toggle label="" checked={data.fires.enabled} onChange={(v) => onChange({ ...data, fires: { ...data.fires, enabled: v } })} />
</div>
{data.fires.enabled && (
<div className="grid grid-cols-2 gap-4">
<NumberInput
label="Tick Seconds"
value={data.fires.tick_seconds}
onChange={(v) => onChange({ ...data, fires: { ...data.fires, tick_seconds: v } })}
min={60}
/>
<SelectInput
label="State"
value={data.fires.state}
onChange={(v) => onChange({ ...data, fires: { ...data.fires, state: v } })}
options={US_STATES}
helper="Filter fires by state"
info="Two-letter state code for NIFC wildfire filtering."
/>
</div>
)}
</div>
<div className="border border-[#1e2a3a] rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-slate-300">Avalanche Advisories</span>
<p className="text-xs text-slate-600">Backcountry avalanche danger ratings</p>
</div>
<Toggle label="" checked={data.avalanche.enabled} onChange={(v) => onChange({ ...data, avalanche: { ...data.avalanche, enabled: v } })} />
</div>
{data.avalanche.enabled && (
<>
<NumberInput
label="Tick Seconds"
value={data.avalanche.tick_seconds}
onChange={(v) => onChange({ ...data, avalanche: { ...data.avalanche, tick_seconds: v } })}
min={60}
/>
<ListInput
label="Center IDs"
value={data.avalanche.center_ids}
onChange={(v) => onChange({ ...data, avalanche: { ...data.avalanche, center_ids: v } })}
helper="e.g., SNFAC, IPAC, FAC"
info="Find your local center at https://avalanche.org/avalanche-centers/"
infoLink="https://avalanche.org/avalanche-centers/"
/>
<NumberListInput
label="Season Months"
value={data.avalanche.season_months}
onChange={(v) => onChange({ ...data, avalanche: { ...data.avalanche, season_months: v } })}
helper="e.g., 12, 1, 2, 3, 4"
info="Months when avalanche forecasts are active. Default Dec-Apr. Adjust for your region's season."
/>
</>
)}
</div>
<div className="border border-[#1e2a3a] rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-slate-300">USGS Stream Gauges</span>
<p className="text-xs text-slate-600">River and stream water levels</p>
</div>
<Toggle label="" checked={data.usgs?.enabled || false} onChange={(v) => onChange({ ...data, usgs: { ...data.usgs, enabled: v, tick_seconds: data.usgs?.tick_seconds || 900, sites: data.usgs?.sites || [] } })} />
</div>
{data.usgs?.enabled && (
<>
<NumberInput
label="Tick Seconds"
value={data.usgs.tick_seconds}
onChange={(v) => onChange({ ...data, usgs: { ...data.usgs, tick_seconds: v } })}
min={900}
helper="Minimum 15 min (900s)"
/>
<ListInput
label="Site IDs"
value={data.usgs.sites}
onChange={(v) => onChange({ ...data, usgs: { ...data.usgs, sites: v } })}
helper="USGS gauge site numbers"
info="Find site IDs at waterdata.usgs.gov/nwis"
infoLink="https://waterdata.usgs.gov/nwis"
/>
</>
)}
</div>
<div className="border border-[#1e2a3a] rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-slate-300">TomTom Traffic</span>
<p className="text-xs text-slate-600">Traffic flow on monitored corridors</p>
</div>
<Toggle label="" checked={data.traffic?.enabled || false} onChange={(v) => onChange({ ...data, traffic: { ...data.traffic, enabled: v, tick_seconds: data.traffic?.tick_seconds || 300, api_key: data.traffic?.api_key || '', corridors: data.traffic?.corridors || [] } })} />
</div>
{data.traffic?.enabled && (
<>
<TextInput
label="API Key"
value={data.traffic.api_key}
onChange={(v) => onChange({ ...data, traffic: { ...data.traffic, api_key: v } })}
type="password"
helper="Get key at developer.tomtom.com"
infoLink="https://developer.tomtom.com"
/>
<NumberInput
label="Tick Seconds"
value={data.traffic.tick_seconds}
onChange={(v) => onChange({ ...data, traffic: { ...data.traffic, tick_seconds: v } })}
min={60}
/>
<div className="text-xs text-slate-500 mt-2">Corridors (each with name, lat, lon):</div>
{(data.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 newCorridors = [...data.traffic.corridors]
newCorridors[i] = { ...c, name: v }
onChange({ ...data, traffic: { ...data.traffic, corridors: newCorridors } })
}} />
<NumberInput label="Lat" value={c.lat} onChange={(v) => {
const newCorridors = [...data.traffic.corridors]
newCorridors[i] = { ...c, lat: v }
onChange({ ...data, traffic: { ...data.traffic, corridors: newCorridors } })
}} step={0.01} />
<NumberInput label="Lon" value={c.lon} onChange={(v) => {
const newCorridors = [...data.traffic.corridors]
newCorridors[i] = { ...c, lon: v }
onChange({ ...data, traffic: { ...data.traffic, corridors: newCorridors } })
}} step={0.01} />
<button
onClick={() => onChange({ ...data, traffic: { ...data.traffic, corridors: data.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 rounded"
>Remove</button>
</div>
))}
<button
onClick={() => onChange({ ...data, traffic: { ...data.traffic, corridors: [...(data.traffic.corridors || []), { name: '', lat: 0, lon: 0 }] } })}
className="text-xs text-accent hover:underline"
>+ Add Corridor</button>
</>
)}
</div>
<div className="border border-[#1e2a3a] rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-slate-300">511 Road Conditions</span>
<p className="text-xs text-slate-600">State DOT road events and closures</p>
</div>
<Toggle label="" checked={data.roads511?.enabled || false} onChange={(v) => onChange({ ...data, roads511: { ...data.roads511, enabled: v, tick_seconds: data.roads511?.tick_seconds || 300, api_key: data.roads511?.api_key || '', base_url: data.roads511?.base_url || '', endpoints: data.roads511?.endpoints || ['/get/event'], bbox: data.roads511?.bbox || [] } })} />
</div>
{data.roads511?.enabled && (
<>
<TextInput
label="Base URL"
value={data.roads511.base_url}
onChange={(v) => onChange({ ...data, roads511: { ...data.roads511, base_url: v } })}
placeholder="https://511.yourstate.gov/api/v2"
helper="State 511 API endpoint"
/>
<TextInput
label="API Key"
value={data.roads511.api_key}
onChange={(v) => onChange({ ...data, roads511: { ...data.roads511, api_key: v } })}
type="password"
helper="Leave empty if not required"
/>
<NumberInput
label="Tick Seconds"
value={data.roads511.tick_seconds}
onChange={(v) => onChange({ ...data, roads511: { ...data.roads511, tick_seconds: v } })}
min={60}
/>
<ListInput
label="Endpoints"
value={data.roads511.endpoints}
onChange={(v) => onChange({ ...data, roads511: { ...data.roads511, endpoints: v } })}
helper="e.g., /get/event, /get/mountainpasses"
/>
<div className="grid grid-cols-4 gap-2">
<NumberInput label="West" value={data.roads511.bbox?.[0] || 0} onChange={(v) => {
const bbox = [...(data.roads511.bbox || [0, 0, 0, 0])]
bbox[0] = v
onChange({ ...data, roads511: { ...data.roads511, bbox } })
}} step={0.01} />
<NumberInput label="South" value={data.roads511.bbox?.[1] || 0} onChange={(v) => {
const bbox = [...(data.roads511.bbox || [0, 0, 0, 0])]
bbox[1] = v
onChange({ ...data, roads511: { ...data.roads511, bbox } })
}} step={0.01} />
<NumberInput label="East" value={data.roads511.bbox?.[2] || 0} onChange={(v) => {
const bbox = [...(data.roads511.bbox || [0, 0, 0, 0])]
bbox[2] = v
onChange({ ...data, roads511: { ...data.roads511, bbox } })
}} step={0.01} />
<NumberInput label="North" value={data.roads511.bbox?.[3] || 0} onChange={(v) => {
const bbox = [...(data.roads511.bbox || [0, 0, 0, 0])]
bbox[3] = v
onChange({ ...data, roads511: { ...data.roads511, bbox } })
}} step={0.01} />
</div>
<div className="text-xs text-slate-500">Bounding box filter (leave all 0 to disable)</div>
</>
)}
</div>
<div className="border border-[#1e2a3a] rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-slate-300">NASA FIRMS Satellite Fire Detection</span>
<p className="text-xs text-slate-600">Near real-time thermal anomalies from satellites</p>
</div>
<Toggle label="" checked={data.firms?.enabled || false} onChange={(v) => onChange({ ...data, firms: { ...data.firms, enabled: v, tick_seconds: data.firms?.tick_seconds || 1800, map_key: data.firms?.map_key || '', source: data.firms?.source || 'VIIRS_SNPP_NRT', bbox: data.firms?.bbox || [], day_range: data.firms?.day_range || 1, confidence_min: data.firms?.confidence_min || 'nominal', proximity_km: data.firms?.proximity_km || 10 } })} />
</div>
{data.firms?.enabled && (
<>
<TextInput
label="MAP Key"
value={data.firms.map_key}
onChange={(v) => onChange({ ...data, firms: { ...data.firms, map_key: v } })}
type="password"
helper="Get key at firms.modaps.eosdis.nasa.gov/api/area/"
infoLink="https://firms.modaps.eosdis.nasa.gov/api/area/"
/>
<NumberInput
label="Tick Seconds"
value={data.firms.tick_seconds}
onChange={(v) => onChange({ ...data, firms: { ...data.firms, tick_seconds: v } })}
min={300}
helper="Minimum 5 min (300s)"
/>
<SelectInput
label="Satellite Source"
value={data.firms.source}
onChange={(v) => onChange({ ...data, firms: { ...data.firms, source: v } })}
options={[
{ value: 'VIIRS_SNPP_NRT', label: 'VIIRS SNPP (Near Real-Time)' },
{ value: 'VIIRS_NOAA20_NRT', label: 'VIIRS NOAA-20 (Near Real-Time)' },
{ value: 'MODIS_NRT', label: 'MODIS (Near Real-Time)' },
]}
/>
<NumberInput
label="Day Range"
value={data.firms.day_range}
onChange={(v) => onChange({ ...data, firms: { ...data.firms, day_range: v } })}
min={1}
max={10}
helper="1-10 days of data"
/>
<SelectInput
label="Minimum Confidence"
value={data.firms.confidence_min}
onChange={(v) => onChange({ ...data, firms: { ...data.firms, confidence_min: v } })}
options={[
{ value: 'low', label: 'Low' },
{ value: 'nominal', label: 'Nominal' },
{ value: 'high', label: 'High' },
]}
/>
<NumberInput
label="Proximity (km)"
value={data.firms.proximity_km}
onChange={(v) => onChange({ ...data, firms: { ...data.firms, proximity_km: v } })}
step={0.5}
helper="Distance to match known fires"
/>
<div className="grid grid-cols-4 gap-2">
<NumberInput label="West" value={data.firms.bbox?.[0] || 0} onChange={(v) => {
const bbox = [...(data.firms.bbox || [0, 0, 0, 0])]
bbox[0] = v
onChange({ ...data, firms: { ...data.firms, bbox } })
}} step={0.01} />
<NumberInput label="South" value={data.firms.bbox?.[1] || 0} onChange={(v) => {
const bbox = [...(data.firms.bbox || [0, 0, 0, 0])]
bbox[1] = v
onChange({ ...data, firms: { ...data.firms, bbox } })
}} step={0.01} />
<NumberInput label="East" value={data.firms.bbox?.[2] || 0} onChange={(v) => {
const bbox = [...(data.firms.bbox || [0, 0, 0, 0])]
bbox[2] = v
onChange({ ...data, firms: { ...data.firms, bbox } })
}} step={0.01} />
<NumberInput label="North" value={data.firms.bbox?.[3] || 0} onChange={(v) => {
const bbox = [...(data.firms.bbox || [0, 0, 0, 0])]
bbox[3] = v
onChange({ ...data, firms: { ...data.firms, bbox } })
}} step={0.01} />
</div>
<div className="text-xs text-slate-500">Bounding box for monitoring area (required)</div>
</>
)}
</div>
</>
)}
</div>
)
}
function DashboardSection({ data, onChange }: { data: DashboardConfig; onChange: (d: DashboardConfig) => void }) {
return (
<div className="space-y-4">
@ -2324,7 +1914,6 @@ export default function Config() {
case 'knowledge': return <KnowledgeSection data={config.knowledge} onChange={(d) => updateSection('knowledge', d)} />
case 'mesh_sources': return <MeshSourcesSection data={config.mesh_sources} onChange={(d) => updateSection('mesh_sources', d)} />
case 'mesh_intelligence': return <MeshIntelligenceSection data={config.mesh_intelligence} onChange={(d) => updateSection('mesh_intelligence', d)} />
case 'environmental': return <EnvironmentalSection data={config.environmental} onChange={(d) => updateSection('environmental', d)} />
case 'dashboard': return <DashboardSection data={config.dashboard} onChange={(d) => updateSection('dashboard', d)} />
default: return null
}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-Bildyb1E.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-QhNRb-ap.css">
<script type="module" crossorigin src="/assets/index-9OZ6ZqzI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B_J_Z7c8.css">
</head>
<body>
<div id="root"></div>