Merge branch 'feature/mesh-intelligence'

This commit is contained in:
Matt Johnson (via Claude) 2026-06-10 15:44:54 +00:00
commit 768f6e72ac
9 changed files with 610 additions and 126 deletions

View file

@ -98,7 +98,7 @@ export default function Layout({ children }: LayoutProps) {
<div className="w-[3px] h-8 bg-accent flex-shrink-0" />
<div>
<div className="font-sans font-bold text-white text-[15px] leading-tight tracking-tight">MeshAI</div>
<div className="text-xs font-mono text-[#333]">
<div className="text-xs font-mono text-[#666]">
v{status?.version || '...'}
</div>
</div>
@ -117,7 +117,7 @@ export default function Layout({ children }: LayoutProps) {
className={`flex items-center gap-3 px-5 py-3 text-sm font-sans transition-colors relative ${
isActive
? 'text-white'
: 'text-[#444] hover:text-[#888]'
: 'text-[#777] hover:text-[#888]'
}`}
>
{isActive && (
@ -138,14 +138,14 @@ export default function Layout({ children }: LayoutProps) {
status?.connected ? 'bg-green-500' : 'bg-red-500'
}`}
/>
<span className="text-xs font-sans text-[#444]">
<span className="text-xs font-sans text-[#777]">
{status?.connected ? 'Connected' : 'Disconnected'}
</span>
</div>
<div className="text-xs font-mono text-[#333] truncate">
<div className="text-xs font-mono text-[#666] truncate">
{status?.connection_type?.toUpperCase()}: {status?.connection_target}
</div>
<div className="text-xs font-sans text-[#333] mt-1">
<div className="text-xs font-sans text-[#666] mt-1">
Uptime: <span className="font-mono">{status ? formatUptime(status.uptime_seconds) : '...'}</span>
</div>
</div>
@ -166,12 +166,12 @@ export default function Layout({ children }: LayoutProps) {
connected ? 'bg-accent animate-pulse-slow' : 'bg-[#333]'
}`}
/>
<span className="text-xs font-sans text-[#444]">
<span className="text-xs font-sans text-[#777]">
{connected ? 'Live' : 'Offline'}
</span>
</div>
{/* Clock */}
<div className="text-sm font-mono text-[#333]">
<div className="text-sm font-mono text-[#666]">
{timeStr} MT
</div>
</div>

View file

@ -24,7 +24,7 @@ body {
}
::-webkit-scrollbar-thumb {
background: #1e1e1e;
background: #2a2a2a;
border-radius: 0;
}

View file

@ -156,7 +156,7 @@ export default function AdapterConfig() {
if (loading) {
return (
<div className="p-6 flex items-center gap-2 text-[#444]">
<div className="p-6 flex items-center gap-2 text-[#777]">
<Loader2 className="w-5 h-5 animate-spin" /> Loading adapter config
</div>
)
@ -182,11 +182,11 @@ export default function AdapterConfig() {
<div className="flex items-center gap-2 text-white">
<Sliders className="w-5 h-5" />
<h1 className="text-xl font-semibold">Adapter Config</h1>
<span className="text-xs text-[#333] ml-2">
<span className="text-xs text-[#666] ml-2">
{Object.values(config).reduce((n, l) => n + l.length, 0)} settings across {allAdapters.length} adapters
</span>
</div>
<p className="text-xs text-[#444] max-w-3xl">
<p className="text-xs text-[#777] max-w-3xl">
Per-adapter tunables (thresholds, freshness windows, toggles, curation lists).
Changes take effect on the next handler call -- no container restart needed.
Sentence templates, emoji, and translation maps live in code by design see the CODE rule under <a href="/reference#adapter-config" className="text-accent hover:underline">Adapter Config &amp; the CODE Rule</a> in Reference. The <strong>LLM context</strong> toggle on each card gates whether that adapter's data lands in the system prompt when you DM the bot; broadcasts are unaffected.
@ -208,7 +208,7 @@ export default function AdapterConfig() {
<div className="p-4 flex items-start gap-4">
<button
onClick={() => setExpanded((e) => ({ ...e, [adapter]: !e[adapter] }))}
className="text-[#444] hover:text-white"
className="text-[#777] hover:text-white"
aria-label="toggle expand"
>
{isExpanded
@ -218,16 +218,16 @@ export default function AdapterConfig() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold text-white">{m.display_name}</h2>
<code className="text-xs text-[#333]">{adapter}</code>
<code className="text-xs text-[#666]">{adapter}</code>
{rows.length > 0 && (
<span className="text-xs text-[#444] ml-1">({rows.length} settings)</span>
<span className="text-xs text-[#777] ml-1">({rows.length} settings)</span>
)}
{rows.length === 0 && (
<span className="text-xs text-[#333] ml-1 italic">(meta only)</span>
<span className="text-xs text-[#666] ml-1 italic">(meta only)</span>
)}
</div>
{m.description && (
<p className="text-xs text-[#444] mt-1">{m.description}</p>
<p className="text-xs text-[#777] mt-1">{m.description}</p>
)}
</div>
@ -303,13 +303,13 @@ function KeyRow({ row, status, error, onCommit, onReset }: KeyRowProps) {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<code className="text-sm font-mono text-accent">{row.key}</code>
<span className="text-xs text-[#333]">[{row.type}]</span>
<span className="text-xs text-[#666]">[{row.type}]</span>
{!isDefault && (
<span className="text-xs text-accent">edited</span>
)}
</div>
{row.description && (
<p className="text-xs text-[#444] mt-1">{row.description}</p>
<p className="text-xs text-[#777] mt-1">{row.description}</p>
)}
</div>
@ -345,7 +345,7 @@ function KeyRow({ row, status, error, onCommit, onReset }: KeyRowProps) {
<button
onClick={onReset}
disabled={isDefault}
className="text-[#444] hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
className="text-[#777] hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
title="Reset to default"
>
<RotateCcw className="w-4 h-4" />

View file

@ -73,7 +73,7 @@ function PillarBar({ label, value }: { label: string; value: number }) {
return (
<div className="flex items-center gap-2">
<div className="w-24 text-xs font-sans text-[#444] truncate">{label}</div>
<div className="w-24 text-xs font-sans text-[#777] truncate">{label}</div>
<div className="flex-1 h-2 bg-border overflow-hidden">
<div className={`h-full ${getColor(value)} transition-all duration-300`} style={{ width: `${value}%` }} />
</div>
@ -94,7 +94,7 @@ function AlertItem({ alert }: { alert: Alert }) {
return { bg: 'bg-accent/5', border: 'border-accent', icon: AlertTriangle, iconColor: 'text-accent' }
case 'routine':
default:
return { bg: 'bg-[#161616]', border: 'border-[#333]', icon: Info, iconColor: 'text-[#444]' }
return { bg: 'bg-[#161616]', border: 'border-[#333]', icon: Info, iconColor: 'text-[#777]' }
}
}
@ -106,7 +106,7 @@ function AlertItem({ alert }: { alert: Alert }) {
<Icon size={16} className={styles.iconColor} />
<div className="flex-1 min-w-0">
<div className="text-sm font-sans font-medium text-white">{alert.message}</div>
<div className="text-[10px] font-mono text-[#333] mt-1">{alert.timestamp || 'Just now'}</div>
<div className="text-[10px] font-mono text-[#666] mt-1">{alert.timestamp || 'Just now'}</div>
</div>
</div>
)
@ -124,7 +124,7 @@ function SourceCard({ source }: { source: SourceHealth }) {
<div className={`w-2 h-2 rounded-full ${getStatusColor()}`} />
<div className="flex-1 min-w-0">
<div className="text-sm font-sans font-medium text-white truncate">{source.name}</div>
<div className="text-[10px] font-sans text-[#333]">{source.node_count} nodes · {source.type}</div>
<div className="text-[10px] font-sans text-[#666]">{source.node_count} nodes · {source.type}</div>
</div>
</div>
)
@ -138,10 +138,10 @@ function StatCard({ icon: Icon, label, value, subvalue, accent }: { icon: typeof
>
<div className="flex items-center gap-2 mb-2">
<Icon size={14} style={{ color: accent || '#333' }} />
<span className="text-[9px] font-sans uppercase tracking-widest text-[#333]">{label}</span>
<span className="text-[9px] font-sans uppercase tracking-widest text-[#666]">{label}</span>
</div>
<div className="font-mono text-xl" style={{ color: accent || '#e0e0e0' }}>{value}</div>
{subvalue && <div className="text-[9px] font-sans mt-1 text-[#333]">{subvalue}</div>}
{subvalue && <div className="text-[9px] font-sans mt-1 text-[#666]">{subvalue}</div>}
</div>
)
}
@ -166,7 +166,7 @@ function BandConditionsCard({ bandConditions }: { bandConditions: BandConditions
case 'Good': return 'text-green-500'
case 'Fair': return 'text-accent'
case 'Poor': return 'text-red-500'
default: return 'text-[#333]'
default: return 'text-[#666]'
}
}
@ -178,13 +178,13 @@ function BandConditionsCard({ bandConditions }: { bandConditions: BandConditions
if (!bandConditions?.enabled || !bandConditions?.ratings) {
return (
<div className="bg-bg-card border border-border p-4 flex flex-col h-full">
<h2 className="text-[10px] font-sans uppercase tracking-widest text-[#333] mb-3 flex items-center gap-2">
<h2 className="text-[10px] font-sans uppercase tracking-widest text-[#666] mb-3 flex items-center gap-2">
<Zap size={14} />
RF Propagation
</h2>
<div className="flex-1 flex items-center justify-center">
<div className="text-center py-8">
<div className="font-sans text-[#333]">No band conditions data</div>
<div className="font-sans text-[#666]">No band conditions data</div>
</div>
</div>
</div>
@ -195,7 +195,7 @@ function BandConditionsCard({ bandConditions }: { bandConditions: BandConditions
return (
<div className="bg-bg-card border border-border p-4 flex flex-col h-full">
<h2 className="text-[10px] font-sans uppercase tracking-widest text-[#333] mb-3 flex items-center gap-2">
<h2 className="text-[10px] font-sans uppercase tracking-widest text-[#666] mb-3 flex items-center gap-2">
<Zap size={14} />
RF Propagation
</h2>
@ -203,11 +203,11 @@ function BandConditionsCard({ bandConditions }: { bandConditions: BandConditions
{/* Slot label */}
<div className="text-center mb-3">
<span className="text-lg">{getSlotEmoji(bandConditions.slot_label)}</span>
<span className="text-sm font-sans text-[#444] ml-2">{bandConditions.slot_label}</span>
<span className="text-sm font-sans text-[#777] ml-2">{bandConditions.slot_label}</span>
</div>
{/* Band conditions header */}
<div className="text-[10px] font-sans uppercase tracking-widest text-[#333] mb-2 flex items-center gap-1">
<div className="text-[10px] font-sans uppercase tracking-widest text-[#666] mb-2 flex items-center gap-1">
📡 Band Conditions
</div>
@ -217,7 +217,7 @@ function BandConditionsCard({ bandConditions }: { bandConditions: BandConditions
const rating = bandConditions.ratings?.[band]
return (
<div key={band} className="flex items-center justify-between px-2 py-1.5 bg-bg-hover">
<span className="text-sm font-mono text-[#444]">{band}</span>
<span className="text-sm font-mono text-[#777]">{band}</span>
<span className="text-sm flex items-center gap-2">
<span className={`inline-block w-2 h-2 rounded-full ${getRatingColor(rating)}`} />
<span className={`font-sans ${getRatingTextColor(rating)}`}>{rating || '—'}</span>
@ -228,7 +228,7 @@ function BandConditionsCard({ bandConditions }: { bandConditions: BandConditions
</div>
{/* Footer: source and time */}
<div className="mt-auto pt-3 border-t border-border text-[10px] font-sans text-[#333]">
<div className="mt-auto pt-3 border-t border-border text-[10px] font-sans text-[#666]">
{bandConditions.source && (
<span>{bandConditions.source === 'swpc_local' ? 'SWPC' : 'HamQSL'}</span>
)}
@ -306,12 +306,12 @@ function HepburnTropoCard() {
return (
<div className="bg-bg-card border border-border p-4 flex flex-col">
<div className="flex items-center justify-between mb-3">
<h2 className="text-[10px] font-sans uppercase tracking-widest text-[#333] flex items-center gap-2">
<h2 className="text-[10px] font-sans uppercase tracking-widest text-[#666] flex items-center gap-2">
<Radio size={14} />
Tropo Forecast (Hepburn)
</h2>
<div className="flex items-center gap-2">
{saving && <span className="text-xs font-sans text-[#333]">saving...</span>}
{saving && <span className="text-xs font-sans text-[#666]">saving...</span>}
<select
value={region}
onChange={e => handleRegionChange(e.target.value)}
@ -324,10 +324,10 @@ function HepburnTropoCard() {
</div>
</div>
<div className="text-xs font-sans text-[#333] mb-2">{regionLabel} 6-day forecast</div>
<div className="text-xs font-sans text-[#666] mb-2">{regionLabel} 6-day forecast</div>
{imgError ? (
<div className="flex items-center justify-center h-48 text-[#333] text-sm font-sans">
<div className="flex items-center justify-center h-48 text-[#666] text-sm font-sans">
Failed to load forecast image
</div>
) : (
@ -339,7 +339,7 @@ function HepburnTropoCard() {
/>
)}
<div className="text-[10px] font-sans text-[#333] mt-2">
<div className="text-[10px] font-sans text-[#666] mt-2">
Source: <a href="https://www.dxinfocentre.com/tropo.html" target="_blank" rel="noopener noreferrer" className="text-sky-400 hover:text-sky-300">dxinfocentre.com</a>
</div>
</div>
@ -353,16 +353,16 @@ const SOURCE_ICONS: Record<string, { icon: typeof Cloud; color: string; label: s
ducting: { icon: Radio, color: 'text-sky-500', label: 'Tropo' },
nifc: { icon: Flame, color: 'text-red-500', label: 'NIFC' },
firms: { icon: Satellite, color: 'text-red-400', label: 'FIRMS' },
avalanche: { icon: Mountain, color: 'text-[#444]', label: 'Avy' },
avalanche: { icon: Mountain, color: 'text-[#777]', label: 'Avy' },
usgs: { icon: Droplets, color: 'text-sky-400', label: 'USGS' },
traffic: { icon: Car, color: 'text-[#444]', label: 'Traffic' },
traffic: { icon: Car, color: 'text-[#777]', label: 'Traffic' },
roads: { icon: Construction, color: 'text-accent-dim', label: '511' },
}
// Severity badge colors (3-level system + legacy support)
const SEVERITY_COLORS: Record<string, string> = {
// New 3-level system
routine: 'bg-[#1e1e1e] text-[#444] border-[#222]',
routine: 'bg-[#1e1e1e] text-[#777] border-[#222]',
priority: 'bg-accent/5 text-accent border-accent/30',
immediate: 'bg-red-500/5 text-red-500 border-red-500/30',
// NWS native (for raw event display)
@ -378,7 +378,7 @@ const SEVERITY_COLORS: Record<string, string> = {
}
function EventFeedItem({ event, isLocal }: { event: EnvEvent; isLocal?: boolean }) {
const sourceConfig = SOURCE_ICONS[event.source] || { icon: Info, color: 'text-[#444]', label: event.source }
const sourceConfig = SOURCE_ICONS[event.source] || { icon: Info, color: 'text-[#777]', label: event.source }
const Icon = sourceConfig.icon
const severityStyle = SEVERITY_COLORS[event.severity?.toLowerCase()] || SEVERITY_COLORS.info
@ -425,12 +425,12 @@ function EventFeedItem({ event, isLocal }: { event: EnvEvent; isLocal?: boolean
LOCAL
</span>
)}
<span className="text-[10px] font-sans text-[#333]">{sourceConfig.label}</span>
<span className="text-[10px] font-mono text-[#333] ml-auto">{formatTime(event.fetched_at)}</span>
<span className="text-[10px] font-sans text-[#666]">{sourceConfig.label}</span>
<span className="text-[10px] font-mono text-[#666] ml-auto">{formatTime(event.fetched_at)}</span>
</div>
<div className={`text-sm font-sans font-medium truncate ${isLocal ? 'text-white' : 'text-[#e0e0e0]'}`}>{title}</div>
{subtitle && (
<div className="text-[10px] font-sans text-[#333] truncate mt-0.5">{subtitle}</div>
<div className="text-[10px] font-sans text-[#666] truncate mt-0.5">{subtitle}</div>
)}
</div>
</div>
@ -494,15 +494,15 @@ function LiveEventFeed({ events, envStatus, embedded }: { events: EnvEvent[]; en
<div className="flex-1 flex items-center justify-center">
<div className="text-center py-8">
<CheckCircle size={24} className="text-green-500 mx-auto mb-2" />
<div className="font-sans text-[#444]">No active events</div>
<div className="text-[10px] font-sans text-[#333]">All clear</div>
<div className="font-sans text-[#777]">No active events</div>
<div className="text-[10px] font-sans text-[#666]">All clear</div>
</div>
</div>
)}
{/* Feed health summary */}
{feedSummary && (
<div className={`text-[10px] font-sans mt-3 pt-3 border-t border-border ${feedSummary.errors.length > 0 ? 'text-red-500' : 'text-[#333]'}`}>
<div className={`text-[10px] font-sans mt-3 pt-3 border-t border-border ${feedSummary.errors.length > 0 ? 'text-red-500' : 'text-[#666]'}`}>
<span className="font-mono">{feedSummary.active}</span> of <span className="font-mono">{feedSummary.total}</span> feeds active
{feedSummary.secAgo !== null && <> · Last update <span className="font-mono">{feedSummary.secAgo}s</span> ago</>}
{feedSummary.errors.length > 0 && (
@ -517,7 +517,7 @@ function LiveEventFeed({ events, envStatus, embedded }: { events: EnvEvent[]; en
return (
<div className="bg-bg-card border border-border p-4 flex flex-col h-full">
<h2 className="text-[10px] font-sans uppercase tracking-widest text-[#333] mb-3 flex items-center gap-2">
<h2 className="text-[10px] font-sans uppercase tracking-widest text-[#666] mb-3 flex items-center gap-2">
<Activity size={14} />
Live Event Feed
</h2>
@ -587,7 +587,7 @@ export default function Dashboard() {
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="font-sans text-[#444]">Loading...</div>
<div className="font-sans text-[#777]">Loading...</div>
</div>
)
}
@ -606,7 +606,7 @@ export default function Dashboard() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Mesh Health */}
<div className="bg-bg-card border border-border p-4">
<h2 className="text-[10px] font-sans uppercase tracking-widest text-[#333] mb-3">Mesh Health</h2>
<h2 className="text-[10px] font-sans uppercase tracking-widest text-[#666] mb-3">Mesh Health</h2>
{health && (
<>
<HealthGauge health={health} />
@ -631,7 +631,7 @@ export default function Dashboard() {
className={`py-2.5 -mb-px text-[10px] font-sans uppercase tracking-widest transition-colors border-b ${
alertTab === 'alerts'
? 'border-accent text-white'
: 'border-transparent text-[#444]'
: 'border-transparent text-[#777]'
}`}
>
Active Alerts
@ -641,7 +641,7 @@ export default function Dashboard() {
className={`py-2.5 -mb-px text-[10px] font-sans uppercase tracking-widest transition-colors border-b ${
alertTab === 'feed'
? 'border-accent text-white'
: 'border-transparent text-[#444]'
: 'border-transparent text-[#777]'
}`}
>
Event Feed
@ -679,11 +679,11 @@ export default function Dashboard() {
<Icon size={16} className={sevStyle.iconColor} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="px-1.5 py-0.5 text-[10px] font-sans uppercase tracking-wide bg-[#1e1e1e] text-[#444] border border-[#222]">ENV</span>
<span className="text-[10px] font-sans text-[#333]">{ev.severity}</span>
<span className="px-1.5 py-0.5 text-[10px] font-sans uppercase tracking-wide bg-[#1e1e1e] text-[#777] border border-[#222]">ENV</span>
<span className="text-[10px] font-sans text-[#666]">{ev.severity}</span>
</div>
<div className="text-sm font-sans font-medium text-white mt-1">{ev.headline}</div>
<div className="text-[10px] font-mono text-[#333] mt-1">{ev.source} · {new Date(ev.fetched_at * 1000).toLocaleTimeString()}</div>
<div className="text-[10px] font-mono text-[#666] mt-1">{ev.source} · {new Date(ev.fetched_at * 1000).toLocaleTimeString()}</div>
</div>
</div>
)
@ -692,7 +692,7 @@ export default function Dashboard() {
)
}
return (
<div className="flex items-center gap-2 text-[#444] py-4">
<div className="flex items-center gap-2 text-[#777] py-4">
<CheckCircle size={16} className="text-green-500" />
<span className="font-sans">No active alerts</span>
</div>
@ -718,7 +718,7 @@ export default function Dashboard() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Mesh Sources */}
<div className="bg-bg-card border border-border p-4">
<h2 className="text-[10px] font-sans uppercase tracking-widest text-[#333] mb-3">Mesh Sources (<span className="font-mono">{sources.length}</span>)</h2>
<h2 className="text-[10px] font-sans uppercase tracking-widest text-[#666] mb-3">Mesh Sources (<span className="font-mono">{sources.length}</span>)</h2>
{sources.length > 0 ? (
<div className="space-y-1">
{sources.map((source, i) => (
@ -726,7 +726,7 @@ export default function Dashboard() {
))}
</div>
) : (
<div className="font-sans text-[#333] py-4">No sources configured</div>
<div className="font-sans text-[#666] py-4">No sources configured</div>
)}
</div>

View file

@ -100,9 +100,9 @@ function FeedStatusCard({ feed }: { feed: FeedHealth }) {
<div className={`w-2 h-2 rounded-full ${color}`} />
<span className="text-sm font-medium text-white uppercase">{feed.source}</span>
</div>
<span className="text-xs text-[#444]">{text}</span>
<span className="text-xs text-[#777]">{text}</span>
</div>
<div className="text-xs font-mono text-[#333] space-y-1">
<div className="text-xs font-mono text-[#666] space-y-1">
<div>Events: {feed.event_count}</div>
<div>Last fetch: {lastFetch}</div>
{feed.last_error && <div className="text-accent truncate">{feed.last_error}</div>}
@ -146,14 +146,14 @@ function FeedSourceToggle({ value, onChange, disabled, centralDisabled }: {
type="button"
disabled={disabled}
onClick={() => onChange('native')}
className={`${base} ${value === 'native' ? 'bg-accent text-white' : 'text-[#444] hover:text-white'}`}
className={`${base} ${value === 'native' ? 'bg-accent text-white' : 'text-[#777] hover:text-white'}`}
>native</button>
<button
type="button"
disabled={disabled || centralDisabled}
title={centralDisabled ? 'Central not available for this adapter' : ''}
onClick={() => { if (!centralDisabled) onChange('central') }}
className={`${base} ${centralDisabled ? 'text-[#333] cursor-not-allowed' : value === 'central' ? 'bg-accent text-white' : 'text-[#444] hover:text-white'}`}
className={`${base} ${centralDisabled ? 'text-[#666] cursor-not-allowed' : value === 'central' ? 'bg-accent text-white' : 'text-[#777] hover:text-white'}`}
>central</button>
</div>
)
@ -173,11 +173,11 @@ function AdapterPanel({ title, subtitle, enabled, onEnabled, feedSource, onFeedS
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-[#e0e0e0]">{title}</span>
{subtitle && <p className="text-xs text-[#333]">{subtitle}</p>}
{subtitle && <p className="text-xs text-[#666]">{subtitle}</p>}
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<span className="text-[10px] uppercase tracking-wide text-[#333]">source</span>
<span className="text-[10px] uppercase tracking-wide text-[#666]">source</span>
<FeedSourceToggle value={feedSource} onChange={onFeedSource} disabled={!enabled} centralDisabled={centralDisabled} />
</div>
<Toggle label="" checked={enabled} onChange={onEnabled} />
@ -189,15 +189,15 @@ function AdapterPanel({ title, subtitle, enabled, onEnabled, feedSource, onFeedS
</div>
)}
{nativeOnly && (
<div className="text-[11px] text-[#333]">Central not available for this adapter native only</div>
<div className="text-[11px] text-[#666]">Central not available for this adapter native only</div>
)}
<div className={enabled ? 'space-y-3' : 'space-y-3 opacity-40 pointer-events-none select-none'}>
{children}
</div>
{(health || (events && events.length > 0)) && (
<div className="pt-2 border-t border-border space-y-3">
<div className="text-[10px] uppercase tracking-wide text-[#333]">Live status</div>
{health ? <FeedStatusCard feed={health} /> : <div className="text-xs text-[#333]">No status reported.</div>}
<div className="text-[10px] uppercase tracking-wide text-[#666]">Live status</div>
{health ? <FeedStatusCard feed={health} /> : <div className="text-xs text-[#666]">No status reported.</div>}
{events && events.length > 0 && (
<div className="space-y-2">
{events.slice(0, 5).map((e, i) => <EventCard key={i} event={e} />)}
@ -636,7 +636,7 @@ const save = async () => {
const up = (patch: Partial<EnvConfig>) => env && setEnv({ ...env, ...patch })
if (loading) return <div className="flex items-center justify-center h-64 text-[#444]">Loading environmental config</div>
if (loading) return <div className="flex items-center justify-center h-64 text-[#777]">Loading environmental config</div>
if (!env) return <div className="flex items-center justify-center h-64 text-red-400">{error || 'No config'}</div>
const healthFor = (key: AdapterKey): FeedHealth | undefined =>
@ -664,9 +664,9 @@ const save = async () => {
)}
{env.nws.feed_source === 'central' && (
<div className="border-t border-border pt-4 mt-4">
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#333] mb-3">Broadcast Filters</div>
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">Broadcast Filters</div>
<div className="mb-3">
<div className="text-xs font-sans text-[#444] mb-2">Severities to broadcast</div>
<div className="text-xs font-sans text-[#777] mb-2">Severities to broadcast</div>
<div className="flex gap-6">
{['Extreme', 'Severe', 'Moderate', 'Minor'].map((sev) => (
<label key={sev} className="flex items-center gap-2 cursor-pointer">
@ -690,7 +690,7 @@ const save = async () => {
case 'swpc': return (
<div className="space-y-6">
<div>
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#333] mb-3">
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">
Broadcast Thresholds
</div>
<div className="grid grid-cols-3 gap-4">
@ -741,7 +741,7 @@ const save = async () => {
</div>
)}
<div>
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#333] mb-3">Incident Types</div>
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">Incident Types</div>
<div className="flex gap-6">
{[['WF', 'Wildfire'], ['RX', 'Prescribed Burn'], ['OTHER', 'Other']].map(([val, label]) => (
<label key={val} className="flex items-center gap-2 cursor-pointer">
@ -754,7 +754,7 @@ const save = async () => {
</div>
</div>
<div>
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#333] mb-3">Broadcast Triggers</div>
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">Broadcast Triggers</div>
<div className="space-y-2">
<label className="flex items-center justify-between">
<span className="text-sm font-sans text-[#e0e0e0]">Broadcast on acres increase</span>
@ -788,7 +788,7 @@ const save = async () => {
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-[#333] mb-3">
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">
Broadcast Settings
</div>
<div className="grid grid-cols-2 gap-4">
@ -820,7 +820,7 @@ const save = async () => {
</div>
)}
<div>
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#333] mb-3">
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">
Magnitude Thresholds
</div>
<div className="grid grid-cols-2 gap-4">
@ -839,10 +839,10 @@ const save = async () => {
</div>
</div>
<div>
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#333] mb-3">
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">
PAGER Alert Levels
</div>
<div className="text-xs text-[#333] mb-2">
<div className="text-xs text-[#666] mb-2">
Broadcast at any magnitude when USGS PAGER alert reaches these levels
</div>
<div className="flex gap-6">
@ -868,7 +868,7 @@ const save = async () => {
case 'traffic': return (<>
<TextInput label="API Key" value={env.traffic.api_key} onChange={(v) => up({ traffic: { ...env.traffic, api_key: v } })} type="password" 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-[#333] mt-2">Corridors:</div>
<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 } }) }} />
@ -879,10 +879,10 @@ const save = async () => {
))}
<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-[#333] mb-3">Broadcast Filters</div>
<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">
<div>
<label className="text-xs font-sans text-[#444] mb-1 block">Minimum Magnitude</label>
<label className="text-xs font-sans text-[#777] mb-1 block">Minimum Magnitude</label>
<select
value={tomtomConfig.min_magnitude}
onChange={(e) => setTomtomConfig({...tomtomConfig, min_magnitude: parseInt(e.target.value)})}
@ -893,7 +893,7 @@ const save = async () => {
<option value={3}>3 Major (orange+)</option>
<option value={4}>4 Severe (red only)</option>
</select>
<p className="text-xs text-[#333] mt-1">Drop TomTom incidents below this severity level</p>
<p className="text-xs text-[#666] mt-1">Drop TomTom incidents below this severity level</p>
</div>
</div>
<div className="mt-3 space-y-2">
@ -919,10 +919,10 @@ const save = async () => {
))}
</div>
<div className="border-t border-border pt-4 mt-4">
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#333] mb-3">Broadcast Filters</div>
<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">
<div>
<label className="text-xs font-sans text-[#444] mb-1 block">Minimum Severity</label>
<label className="text-xs font-sans text-[#777] mb-1 block">Minimum Severity</label>
<select
value={roads511Config.min_severity}
onChange={(e) => setRoads511Config({...roads511Config, min_severity: e.target.value})}
@ -932,11 +932,11 @@ const save = async () => {
<option value="Minor">Minor+</option>
<option value="Major">Major only</option>
</select>
<p className="text-xs text-[#333] mt-1">Drop ITD 511 events below this severity</p>
<p className="text-xs text-[#666] mt-1">Drop ITD 511 events below this severity</p>
</div>
</div>
<div className="mt-4">
<div className="text-xs font-sans text-[#444] mb-2">Categories</div>
<div className="text-xs font-sans text-[#777] mb-2">Categories</div>
<div className="flex gap-6">
{([['incident', 'Incident'], ['closure', 'Closure'], ['special_event', 'Special Event']] as const).map(([val, label]) => (
<label key={val} className="flex items-center gap-2 cursor-pointer">
@ -949,7 +949,7 @@ const save = async () => {
</div>
</div>
<div className="mt-4">
<div className="text-xs font-sans text-[#444] mb-2">Sub-types</div>
<div className="text-xs font-sans text-[#777] mb-2">Sub-types</div>
<div className="grid grid-cols-2 gap-2">
{([['accident', 'Crash'], ['road_closed', 'Road Closed'], ['lane_closed', 'Lane Closure'], ['vehicle_on_fire', 'Vehicle Fire'], ['flooding', 'Flooding'], ['debris', 'Debris'], ['road_works', 'Road Works'], ['disabled_vehicle', 'Disabled Vehicle']] as const).map(([val, label]) => (
<label key={val} className="flex items-center gap-2 cursor-pointer">
@ -975,11 +975,11 @@ const save = async () => {
<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-[#333]">Bounding box [W,S,E,N] geographic filter</div>
<div className="text-xs text-[#666]">Bounding box [W,S,E,N] geographic filter</div>
</>
)}
<div className="border-t border-border pt-4 mt-4">
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#333] mb-3">Broadcast Settings</div>
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">Broadcast Settings</div>
<label className="flex items-center justify-between">
<span className="text-sm font-sans text-[#e0e0e0]">Broadcast work zone events</span>
<input type="checkbox" checked={wzdxConfig.broadcast}
@ -989,7 +989,7 @@ const save = async () => {
{wzdxConfig.broadcast ? (
<div className="space-y-3 mt-3">
<div>
<label className="text-xs font-sans text-[#444] mb-1 block">Min Severity</label>
<label className="text-xs font-sans text-[#777] mb-1 block">Min Severity</label>
<select
value={wzdxConfig.min_severity}
onChange={(e) => setWzdxConfig({...wzdxConfig, min_severity: e.target.value})}
@ -1001,7 +1001,7 @@ const save = async () => {
</select>
</div>
<div>
<div className="text-xs font-sans text-[#444] mb-2">Sub-types</div>
<div className="text-xs font-sans text-[#777] mb-2">Sub-types</div>
<div className="flex gap-6">
{([['road_works', 'Road Works'], ['lane_closed', 'Lane Closure'], ['road_closed', 'Road Closed']] as const).map(([val, label]) => (
<label key={val} className="flex items-center gap-2 cursor-pointer">
@ -1015,7 +1015,7 @@ const save = async () => {
</div>
</div>
) : (
<p className="text-xs text-[#333] mt-2">Work zone events stored for LLM context only {'\u2014'} no mesh broadcasts.</p>
<p className="text-xs text-[#666] mt-2">Work zone events stored for LLM context only {'\u2014'} no mesh broadcasts.</p>
)}
</div>
</>)
@ -1052,7 +1052,7 @@ const save = async () => {
<Toggle label="Feeds Enabled" checked={env.enabled} onChange={(v) => up({ enabled: v })} />
{hasChanges && (
<>
<button onClick={discard} className="flex items-center gap-1 px-3 py-1.5 text-sm text-[#444] hover:text-white border border-border">
<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">
@ -1077,7 +1077,7 @@ const save = async () => {
<div className="flex gap-1 border-b border-border overflow-x-auto">
{FAMILIES.map(({ key, label, icon: Icon }) => (
<button key={key} onClick={() => { setFamily(key); const f = FAMILIES.find((x) => x.key === key)!; setAdapter(f.adapters[0] ?? null) }}
className={`flex items-center gap-2 px-4 py-2 text-sm whitespace-nowrap border-b-2 -mb-px transition-colors ${family === key ? 'border-accent text-accent' : 'border-transparent text-[#444] hover:text-white'}`}>
className={`flex items-center gap-2 px-4 py-2 text-sm whitespace-nowrap border-b-2 -mb-px transition-colors ${family === key ? 'border-accent text-accent' : 'border-transparent text-[#777] hover:text-white'}`}>
<Icon size={15} /> {label}
</button>
))}
@ -1089,7 +1089,7 @@ const save = async () => {
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-[#e0e0e0]">Central Connection</span>
<p className="text-xs text-[#333]">NATS JetStream source for any adapter set to "central"</p>
<p className="text-xs text-[#666]">NATS JetStream source for any adapter set to "central"</p>
</div>
<Toggle label="" checked={!!env.central.enabled}
onChange={(v) => up({ central: { ...env.central!, enabled: v } })} />
@ -1113,8 +1113,8 @@ const save = async () => {
{/* Tracking placeholder */}
{family === 'tracking' && (
<div className="flex flex-col items-center justify-center h-[40vh] text-center">
<Satellite size={32} className="text-[#333] mb-4" />
<p className="text-[#333] max-w-md">No adapters yet. ADS-B / AIS / satellite passes are planned for v0.5.</p>
<Satellite size={32} className="text-[#666] mb-4" />
<p className="text-[#666] max-w-md">No adapters yet. ADS-B / AIS / satellite passes are planned for v0.5.</p>
</div>
)}
@ -1124,14 +1124,14 @@ const save = async () => {
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-[#e0e0e0]">Mesh Health</span>
<p className="text-xs text-[#333]">Node/infra telemetry sourced from the mesh, not an environmental feed.</p>
<p className="text-xs text-[#666]">Node/infra telemetry sourced from the mesh, not an environmental feed.</p>
</div>
<div className="flex items-center gap-1">
<span className="text-[10px] uppercase tracking-wide text-[#333]">source</span>
<span className="text-[10px] uppercase tracking-wide text-[#666]">source</span>
<FeedSourceToggle value="native" onChange={() => {}} disabled={false} centralDisabled={true} />
</div>
</div>
<div className="text-[11px] text-[#333]">Central not available reserved for a future migration.</div>
<div className="text-[11px] text-[#666]">Central not available reserved for a future migration.</div>
</div>
)}
@ -1142,7 +1142,7 @@ const save = async () => {
<div className="flex gap-1">
{fam.adapters.map((k) => (
<button key={k} onClick={() => setAdapter(k)}
className={`px-3 py-1.5 text-sm ${activeAdapter === k ? 'bg-bg-hover text-white' : 'text-[#444] hover:text-white'}`}>
className={`px-3 py-1.5 text-sm ${activeAdapter === k ? 'bg-bg-hover text-white' : 'text-[#777] hover:text-white'}`}>
{META[k].label}
</button>
))}

View file

@ -0,0 +1,484 @@
import { useEffect, useState, type ReactNode } from 'react'
import {
Cloud, Flame, Radio, Car, Mountain, Satellite, Activity,
Save, RotateCcw, RefreshCw, AlertCircle, AlertTriangle, Info,
} from 'lucide-react'
import {
Toggle, TextInput, NumberInput, SelectInput, ListInput, NumberListInput,
US_STATES,
} from './Config'
import {
fetchEnvStatus, fetchEnvActive,
type EnvStatus, type EnvEvent,
} from '@/lib/api'
type FeedSource = 'native' | 'central'
interface EnvConfig {
enabled: boolean
nws_zones: string[]
nws: { enabled: boolean; user_agent: string; tick_seconds: number; severity_min: string; feed_source?: FeedSource }
swpc: { enabled: boolean; feed_source?: FeedSource }
ducting: { enabled: boolean; tick_seconds: number; latitude: number; longitude: number; feed_source?: FeedSource }
fires: { enabled: boolean; tick_seconds: number; state: string; feed_source?: FeedSource }
avalanche: { enabled: boolean; tick_seconds: number; center_ids: string[]; season_months: number[]; feed_source?: FeedSource }
usgs: { enabled: boolean; tick_seconds: number; sites: string[]; feed_source?: FeedSource }
usgs_quake: { enabled: boolean; tick_seconds: number; feed_url: string; min_magnitude: number; bbox: number[]; region: string; feed_source?: FeedSource }
traffic: { enabled: boolean; tick_seconds: number; api_key: string; corridors: { name: string; lat: number; lon: number }[]; feed_source?: FeedSource }
roads511: { enabled: boolean; tick_seconds: number; api_key: string; base_url: string; endpoints: string[]; bbox: number[]; feed_source?: FeedSource }
firms: { enabled: boolean; tick_seconds: number; map_key: string; source: string; bbox: number[]; day_range: number; confidence_min: string; proximity_km: number; feed_source?: FeedSource }
central?: { enabled: boolean; url: string; durable: string; region: string }
}
type FeedHealth = EnvStatus['feeds'][number]
// ---------------------------------------------------------------- status cards
function FeedStatusCard({ feed }: { feed: FeedHealth }) {
const color = !feed.is_loaded ? 'bg-red-500' : feed.consecutive_errors > 0 ? 'bg-amber-500' : 'bg-green-500'
const text = !feed.is_loaded ? 'Not loaded' : feed.consecutive_errors > 0 ? `${feed.consecutive_errors} errors` : 'Healthy'
const lastFetch = feed.last_fetch ? new Date(feed.last_fetch * 1000).toLocaleTimeString() : 'Never'
return (
<div className="bg-bg-hover rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${color}`} />
<span className="text-sm font-medium text-slate-200 uppercase">{feed.source}</span>
</div>
<span className="text-xs text-slate-400">{text}</span>
</div>
<div className="text-xs text-slate-500 space-y-1">
<div>Events: {feed.event_count}</div>
<div>Last fetch: {lastFetch}</div>
{feed.last_error && <div className="text-amber-500 truncate">{feed.last_error}</div>}
</div>
</div>
)
}
function EventCard({ event }: { event: EnvEvent }) {
const sev = event.severity.toLowerCase()
const styles = (sev === 'extreme' || sev === 'severe' || sev === 'immediate')
? { bg: 'bg-red-500/10', border: 'border-red-500', Icon: AlertCircle, color: 'text-red-500' }
: (sev === 'moderate' || sev === 'warning' || sev === 'priority')
? { bg: 'bg-amber-500/10', border: 'border-amber-500', Icon: AlertTriangle, color: 'text-amber-500' }
: { bg: 'bg-blue-500/10', border: 'border-blue-500', Icon: Info, color: 'text-blue-500' }
const Icon = styles.Icon
return (
<div className={`p-3 rounded-lg ${styles.bg} border-l-2 ${styles.border}`}>
<div className="flex items-start gap-3">
<Icon size={16} className={styles.color} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-slate-200">{event.event_type}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${styles.bg} ${styles.color}`}>{event.severity}</span>
</div>
<div className="text-sm text-slate-300">{event.headline}</div>
</div>
</div>
</div>
)
}
// ---------------------------------------------------------------- feed_source toggle
function FeedSourceToggle({ value, onChange, disabled, centralDisabled }: {
value: FeedSource; onChange: (v: FeedSource) => void; disabled: boolean; centralDisabled: boolean
}) {
const base = 'px-2 py-1 text-xs transition-colors'
return (
<div className={`flex rounded border border-[#1e2a3a] overflow-hidden ${disabled ? 'opacity-40' : ''}`}>
<button
type="button"
disabled={disabled}
onClick={() => onChange('native')}
className={`${base} ${value === 'native' ? 'bg-accent text-white' : 'text-slate-400 hover:text-slate-200'}`}
>native</button>
<button
type="button"
disabled={disabled || centralDisabled}
title={centralDisabled ? 'Central not available for this adapter' : ''}
onClick={() => { if (!centralDisabled) onChange('central') }}
className={`${base} ${centralDisabled ? 'text-slate-600 cursor-not-allowed' : value === 'central' ? 'bg-accent text-white' : 'text-slate-400 hover:text-slate-200'}`}
>central</button>
</div>
)
}
// ---------------------------------------------------------------- adapter panel
function AdapterPanel({ title, subtitle, enabled, onEnabled, feedSource, onFeedSource, hasCentral, nativeOnly, hasKey, health, events, children }: {
title: string; subtitle?: string
enabled: boolean; onEnabled: (v: boolean) => void
feedSource: FeedSource; onFeedSource: (v: FeedSource) => void
hasCentral: boolean; nativeOnly: boolean; hasKey: boolean
health?: FeedHealth; events?: EnvEvent[]; children?: ReactNode
}) {
const centralDisabled = nativeOnly || !hasCentral
return (
<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">{title}</span>
{subtitle && <p className="text-xs text-slate-600">{subtitle}</p>}
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<span className="text-[10px] uppercase tracking-wide text-slate-600">source</span>
<FeedSourceToggle value={feedSource} onChange={onFeedSource} disabled={!enabled} centralDisabled={centralDisabled} />
</div>
<Toggle label="" checked={enabled} onChange={onEnabled} />
</div>
</div>
{!hasKey && (
<div className="text-xs text-amber-400 bg-amber-500/10 rounded p-2">
API key not configured contact admin
</div>
)}
{nativeOnly && (
<div className="text-[11px] text-slate-600">Central not available for this adapter native only</div>
)}
<div className={enabled ? 'space-y-3' : 'space-y-3 opacity-40 pointer-events-none select-none'}>
{children}
</div>
{(health || (events && events.length > 0)) && (
<div className="pt-2 border-t border-[#1e2a3a] space-y-3">
<div className="text-[10px] uppercase tracking-wide text-slate-600">Live status</div>
{health ? <FeedStatusCard feed={health} /> : <div className="text-xs text-slate-600">No status reported.</div>}
{events && events.length > 0 && (
<div className="space-y-2">
{events.slice(0, 5).map((e, i) => <EventCard key={i} event={e} />)}
</div>
)}
</div>
)}
</div>
)
}
// ---------------------------------------------------------------- families
type AdapterKey = 'nws' | 'fires' | 'firms' | 'swpc' | 'ducting' | 'traffic' | 'roads511' | 'usgs_quake' | 'usgs' | 'avalanche'
interface AdapterMeta { label: string; subtitle: string; health: string; hasCentral: boolean; nativeOnly: boolean; hasKey: boolean }
const META: Record<AdapterKey, AdapterMeta> = {
nws: { label: 'NWS Weather Alerts', subtitle: 'National Weather Service alerts', health: 'nws', hasCentral: true, nativeOnly: false, hasKey: true },
fires: { label: 'NIFC Fire Perimeters', subtitle: 'Active wildfires (National Interagency Fire Center)', health: 'nifc', hasCentral: true, nativeOnly: false, hasKey: true },
firms: { label: 'NASA FIRMS Hotspots', subtitle: 'Satellite thermal-anomaly detections', health: 'firms', hasCentral: true, nativeOnly: false, hasKey: false },
swpc: { label: 'NOAA Space Weather (SWPC)', subtitle: 'Solar indices, geomagnetic storms', health: 'swpc', hasCentral: true, nativeOnly: false, hasKey: true },
ducting: { label: 'Tropospheric Ducting', subtitle: 'VHF/UHF extended-range conditions', health: 'ducting', hasCentral: false, nativeOnly: true, hasKey: true },
traffic: { label: 'TomTom Traffic', subtitle: 'Traffic flow on monitored corridors', health: 'traffic', hasCentral: true, nativeOnly: false, hasKey: true },
roads511: { label: '511 Road Conditions', subtitle: 'State DOT road events and closures', health: 'roads511', hasCentral: true, nativeOnly: false, hasKey: false },
usgs_quake: { label: 'USGS Earthquakes', subtitle: 'Seismic events from the USGS feed', health: 'usgs_quake', hasCentral: true, nativeOnly: false, hasKey: true },
usgs: { label: 'USGS Stream Gauges', subtitle: 'River and stream water levels', health: 'usgs', hasCentral: true, nativeOnly: false, hasKey: true },
avalanche: { label: 'Avalanche Advisories', subtitle: 'Backcountry avalanche danger ratings', health: 'avalanche', hasCentral: false, nativeOnly: true, hasKey: true },
}
const FAMILIES: { key: string; label: string; icon: typeof Cloud; adapters: AdapterKey[] }[] = [
{ key: 'weather', label: 'Weather', icon: Cloud, adapters: ['nws'] },
{ key: 'fire', label: 'Fire', icon: Flame, adapters: ['fires', 'firms'] },
{ key: 'rf', label: 'RF Propagation', icon: Radio, adapters: ['swpc', 'ducting'] },
{ key: 'roads', label: 'Roads', icon: Car, adapters: ['traffic', 'roads511'] },
{ key: 'geohazards', label: 'Geohazards', icon: Mountain, adapters: ['usgs_quake', 'usgs', 'avalanche'] },
{ key: 'tracking', label: 'Tracking', icon: Satellite, adapters: [] },
{ key: 'mesh', label: 'Mesh Health', icon: Activity, adapters: [] },
]
// ---------------------------------------------------------------- main page
export default function Environment() {
const [env, setEnv] = useState<EnvConfig | null>(null)
const [original, setOriginal] = useState<string>('')
const [status, setStatus] = useState<EnvStatus | null>(null)
const [events, setEvents] = useState<EnvEvent[]>([])
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 [restartRequired, setRestartRequired] = useState(false)
const [family, setFamily] = useState('weather')
const [adapter, setAdapter] = useState<AdapterKey | null>('nws')
useEffect(() => {
document.title = 'Environment — MeshAI'
;(async () => {
try {
const res = await fetch('/api/config/environmental')
const data = await res.json()
setEnv(data)
setOriginal(JSON.stringify(data))
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load config')
} finally {
setLoading(false)
}
})()
}, [])
useEffect(() => {
const load = async () => {
try {
setStatus(await fetchEnvStatus())
setEvents(await fetchEnvActive())
} catch { /* status is best-effort */ }
}
load()
const t = setInterval(load, 30000)
return () => clearInterval(t)
}, [])
const hasChanges = env !== null && JSON.stringify(env) !== original
const save = async () => {
if (!env) return
setSaving(true); setError(null); setSuccess(null)
try {
const res = await fetch('/api/config/environmental', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(env),
})
const result = await res.json()
if (!res.ok) throw new Error(result.detail || 'Save failed')
setOriginal(JSON.stringify(env))
setSuccess('Environmental config saved')
if (result.restart_required) setRestartRequired(true)
setTimeout(() => setSuccess(null), 3000)
} catch (e) {
setError(e instanceof Error ? e.message : 'Save failed')
} finally {
setSaving(false)
}
}
const discard = () => { if (env) setEnv(JSON.parse(original)) }
const restart = async () => {
try { await fetch('/api/restart', { method: 'POST' }); setRestartRequired(false); setSuccess('Restart initiated') }
catch { setError('Restart failed') }
}
const up = (patch: Partial<EnvConfig>) => env && setEnv({ ...env, ...patch })
if (loading) return <div className="flex items-center justify-center h-64 text-slate-400">Loading environmental config</div>
if (!env) return <div className="flex items-center justify-center h-64 text-red-400">{error || 'No config'}</div>
const healthFor = (key: AdapterKey): FeedHealth | undefined =>
status?.feeds.find((f) => f.source === META[key].health)
const eventsFor = (key: AdapterKey): EnvEvent[] =>
events.filter((e) => e.source === META[key].health)
const fam = FAMILIES.find((f) => f.key === family)!
const activeAdapter: AdapterKey | null =
fam.adapters.length === 0 ? null : (adapter && fam.adapters.includes(adapter) ? adapter : fam.adapters[0])
// -- per-adapter settings forms (preserve all existing settings) --
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" />
<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)" />
<div className="grid grid-cols-2 gap-4">
<NumberInput label="Tick Seconds" value={env.nws.tick_seconds} onChange={(v) => up({ nws: { ...env.nws, tick_seconds: v } })} min={30} />
<SelectInput label="Min Severity" value={env.nws.severity_min} onChange={(v) => up({ nws: { ...env.nws, severity_min: v } })} options={[{ value: 'minor', label: 'Minor' }, { value: 'moderate', label: 'Moderate' }, { value: 'severe', label: 'Severe' }, { value: 'extreme', label: 'Extreme' }]} />
</div>
</>)
case 'swpc': return <div className="text-xs text-slate-500">No additional settings.</div>
case 'ducting': return (
<div className="grid grid-cols-3 gap-4">
<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} />
</div>)
case 'fires': return (
<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} />
</div>)
case 'avalanche': return (<>
<NumberInput label="Tick Seconds" value={env.avalanche.tick_seconds} onChange={(v) => up({ avalanche: { ...env.avalanche, tick_seconds: v } })} min={60} />
<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/" />
<NumberListInput label="Season Months" value={env.avalanche.season_months} onChange={(v) => up({ avalanche: { ...env.avalanche, season_months: v } })} helper="e.g., 12, 1, 2, 3, 4" />
</>)
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" />
</>)
case 'usgs_quake': return (<>
<NumberInput label="Tick Seconds" value={env.usgs_quake.tick_seconds} onChange={(v) => up({ usgs_quake: { ...env.usgs_quake, tick_seconds: v } })} min={60} />
<NumberInput label="Min Magnitude" value={env.usgs_quake.min_magnitude} onChange={(v) => up({ usgs_quake: { ...env.usgs_quake, min_magnitude: v } })} step={0.1} min={0} />
<TextInput label="Region Tag" value={env.usgs_quake.region} onChange={(v) => up({ usgs_quake: { ...env.usgs_quake, region: v } })} />
<div className="grid grid-cols-4 gap-2">
{(['West', 'South', 'East', 'North'] as const).map((lbl, i) => (
<NumberInput key={lbl} label={lbl} value={env.usgs_quake.bbox?.[i] ?? 0} onChange={(v) => { const b = [...(env.usgs_quake.bbox || [0, 0, 0, 0])]; b[i] = v; up({ usgs_quake: { ...env.usgs_quake, bbox: b } }) }} step={0.01} />
))}
</div>
<div className="text-xs text-slate-500">Bounding box [W,S,E,N] geographic filter</div>
</>)
case 'traffic': return (<>
<TextInput label="API Key" value={env.traffic.api_key} onChange={(v) => up({ traffic: { ...env.traffic, api_key: v } })} type="password" 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-slate-500 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 rounded">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>
</>)
case 'roads511': return (<>
<TextInput label="Base URL" value={env.roads511.base_url} onChange={(v) => up({ roads511: { ...env.roads511, base_url: v } })} placeholder="https://511.yourstate.gov/api/v2" />
<TextInput label="API Key" value={env.roads511.api_key} onChange={(v) => up({ roads511: { ...env.roads511, api_key: v } })} type="password" helper="Leave empty if not required" />
<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>
</>)
case 'firms': return (<>
<TextInput label="MAP Key" value={env.firms.map_key} onChange={(v) => up({ firms: { ...env.firms, map_key: v } })} type="password" helper="firms.modaps.eosdis.nasa.gov/api/area/" infoLink="https://firms.modaps.eosdis.nasa.gov/api/area/" />
<NumberInput label="Tick Seconds" value={env.firms.tick_seconds} onChange={(v) => up({ firms: { ...env.firms, tick_seconds: v } })} min={300} />
<SelectInput label="Satellite Source" value={env.firms.source} onChange={(v) => up({ firms: { ...env.firms, source: v } })} options={[{ value: 'VIIRS_SNPP_NRT', label: 'VIIRS SNPP (NRT)' }, { value: 'VIIRS_NOAA20_NRT', label: 'VIIRS NOAA-20 (NRT)' }, { value: 'MODIS_NRT', label: 'MODIS (NRT)' }]} />
<div className="grid grid-cols-3 gap-4">
<NumberInput label="Day Range" value={env.firms.day_range} onChange={(v) => up({ firms: { ...env.firms, day_range: v } })} min={1} max={10} />
<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>
</>)
}
}
const a = env as unknown as Record<AdapterKey, { enabled: boolean; feed_source?: FeedSource }>
const setAdapterField = (key: AdapterKey, patch: { enabled?: boolean; feed_source?: FeedSource }) => {
const cur = (env as any)[key] || {}
up({ [key]: { ...cur, ...patch } } as unknown as Partial<EnvConfig>)
}
return (
<div className="space-y-6">
{/* Header + master enable + save bar */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-slate-200">Environment</h1>
<div className="flex items-center gap-3">
<Toggle label="Feeds Enabled" checked={env.enabled} onChange={(v) => up({ enabled: v })} />
{hasChanges && (
<>
<button onClick={discard} className="flex items-center gap-1 px-3 py-1.5 text-sm text-slate-400 hover:text-slate-200 border border-border rounded">
<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 rounded disabled:opacity-50">
<Save size={14} /> {saving ? 'Saving…' : 'Save'}
</button>
</>
)}
</div>
</div>
{error && <div className="text-sm text-red-400 bg-red-500/10 rounded p-3">{error}</div>}
{success && <div className="text-sm text-green-400 bg-green-500/10 rounded p-3">{success}</div>}
{restartRequired && (
<div className="flex items-center justify-between text-sm text-amber-400 bg-amber-500/10 border border-amber-500/30 rounded p-3">
<span className="flex items-center gap-2"><RefreshCw size={14} /> A restart is required for some changes to take effect.</span>
<button onClick={restart} className="px-3 py-1 bg-amber-500/20 hover:bg-amber-500/30 rounded">Restart now</button>
</div>
)}
{/* Central Connection (v0.5) -- NATS source for adapters set to central */}
{env.central && (
<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">Central Connection</span>
<p className="text-xs text-slate-600">NATS JetStream source for any adapter set to "central"</p>
</div>
<Toggle label="" checked={!!env.central.enabled}
onChange={(v) => up({ central: { ...env.central!, enabled: v } })} />
</div>
<div className={env.central.enabled ? 'space-y-3' : 'space-y-3 opacity-40 pointer-events-none select-none'}>
<TextInput label="URL" value={env.central.url || ''}
onChange={(v) => up({ central: { ...env.central!, url: v } })}
placeholder="nats://central.echo6.mesh:4222" />
<TextInput label="Durable" value={env.central.durable || ''}
onChange={(v) => up({ central: { ...env.central!, durable: v } })}
placeholder="meshai-v04" />
<TextInput label="Region" value={env.central.region || ''}
onChange={(v) => up({ central: { ...env.central!, region: v } })}
placeholder="us.id"
helper="Central v0.9.20 region token (dotted, e.g. 'us.id'). Empty = bare wildcards (all-US firehose). Each adapter is either Central or native, never both — see Reference → OR-not-AND Architecture for why." />
</div>
</div>
)}
{/* Family tabs */}
<div className="flex gap-1 border-b border-border overflow-x-auto">
{FAMILIES.map(({ key, label, icon: Icon }) => (
<button key={key} onClick={() => { setFamily(key); const f = FAMILIES.find((x) => x.key === key)!; setAdapter(f.adapters[0] ?? null) }}
className={`flex items-center gap-2 px-4 py-2 text-sm whitespace-nowrap border-b-2 -mb-px transition-colors ${family === key ? 'border-accent text-accent' : 'border-transparent text-slate-400 hover:text-slate-200'}`}>
<Icon size={15} /> {label}
</button>
))}
</div>
{/* Tracking placeholder */}
{family === 'tracking' && (
<div className="flex flex-col items-center justify-center h-[40vh] text-center">
<Satellite size={32} className="text-slate-600 mb-4" />
<p className="text-slate-500 max-w-md">No adapters yet. ADS-B / AIS / satellite passes are planned for v0.5.</p>
</div>
)}
{/* Mesh Health (no env adapters; central greyed for future migration) */}
{family === 'mesh' && (
<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">Mesh Health</span>
<p className="text-xs text-slate-600">Node/infra telemetry sourced from the mesh, not an environmental feed.</p>
</div>
<div className="flex items-center gap-1">
<span className="text-[10px] uppercase tracking-wide text-slate-600">source</span>
<FeedSourceToggle value="native" onChange={() => {}} disabled={false} centralDisabled={true} />
</div>
</div>
<div className="text-[11px] text-slate-600">Central not available reserved for a future migration.</div>
</div>
)}
{/* Adapter sub-tabs + panel */}
{fam.adapters.length > 0 && activeAdapter && (
<>
{fam.adapters.length > 1 && (
<div className="flex gap-1">
{fam.adapters.map((k) => (
<button key={k} onClick={() => setAdapter(k)}
className={`px-3 py-1.5 text-sm rounded ${activeAdapter === k ? 'bg-bg-hover text-slate-100' : 'text-slate-400 hover:text-slate-200'}`}>
{META[k].label}
</button>
))}
</div>
)}
<AdapterPanel
title={META[activeAdapter].label}
subtitle={META[activeAdapter].subtitle}
enabled={a[activeAdapter]?.enabled ?? false}
onEnabled={(v) => setAdapterField(activeAdapter, { enabled: v })}
feedSource={(a[activeAdapter]?.feed_source ?? 'native')}
onFeedSource={(v) => setAdapterField(activeAdapter, { feed_source: v })}
hasCentral={META[activeAdapter].hasCentral}
nativeOnly={META[activeAdapter].nativeOnly}
hasKey={META[activeAdapter].hasKey}
health={healthFor(activeAdapter)}
events={eventsFor(activeAdapter)}
>
{renderSettings(activeAdapter)}
</AdapterPanel>
</>
)}
</div>
)
}

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-7P5nbdAV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-EzV2LMjq.css">
<script type="module" crossorigin src="/assets/index-ChPg5oDu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-UYhE7jnf.css">
</head>
<body>
<div id="root"></div>