Merge branch 'feature/mesh-intelligence'

This commit is contained in:
Matt Johnson (via Claude) 2026-06-10 21:03:08 +00:00
commit 98e3fcf675
18 changed files with 221 additions and 139 deletions

View file

@ -2,7 +2,7 @@
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/meshai-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MeshAI Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

View file

@ -93,17 +93,16 @@ export default function Layout({ children }: LayoutProps) {
{/* Sidebar */}
<aside className="w-[220px] flex-shrink-0 bg-bg-card border-r border-border flex flex-col overflow-y-auto">
{/* Logo */}
<div className="p-5 border-b border-border">
<div className="flex items-center gap-3">
<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-[#666]">
<div className="bg-[#000000] px-4 py-3 border-b border-border flex flex-col items-center">
<img
src="/meshai-logo.png"
alt="MeshAI"
className="w-[190px] block"
/>
<div className="font-mono text-[10px] text-[#555] mt-1 self-start">
v{status?.version || '...'}
</div>
</div>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 py-4">
@ -116,14 +115,14 @@ export default function Layout({ children }: LayoutProps) {
to={item.path}
className={`flex items-center gap-3 px-5 py-3 text-sm font-sans transition-colors relative ${
isActive
? 'text-white'
: 'text-[#777] hover:text-[#888]'
? 'text-white bg-transparent'
: 'text-[#777] hover:text-white hover:bg-bg-hover'
}`}
>
{isActive && (
<div className="absolute right-0 top-0 bottom-0 w-0.5 bg-accent" />
<div className="absolute right-0 top-0 bottom-0 w-[2px] bg-[#f59e0b]" />
)}
<Icon size={18} />
<Icon size={16} />
{item.label}
</Link>
)

View file

@ -76,10 +76,10 @@ function getSeverityStyles(severity: string) {
case 'routine':
default:
return {
bg: 'bg-sky-400/10',
border: 'border-sky-400',
badge: 'bg-sky-400/20 text-sky-400',
iconColor: 'text-sky-400',
bg: 'bg-[#f59e0b]/10',
border: 'border-[#f59e0b]',
badge: 'bg-[#f59e0b]/20 text-[#f59e0b]',
iconColor: 'text-[#f59e0b]',
}
}
}
@ -207,7 +207,7 @@ function AlertHistoryTable({
<select
value={typeFilter}
onChange={(e) => onTypeFilterChange(e.target.value)}
className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-sky-400"
className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-[#f59e0b]"
>
{alertTypes.map((t) => (
<option key={t} value={t}>
@ -218,7 +218,7 @@ function AlertHistoryTable({
<select
value={severityFilter}
onChange={(e) => onSeverityFilterChange(e.target.value)}
className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-sky-400"
className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-[#f59e0b]"
>
{severities.map((s) => (
<option key={s} value={s}>
@ -354,8 +354,8 @@ function SubscriptionCard({ subscription, nodes }: { subscription: Subscription;
return (
<div className="p-4 bg-bg-hover border border-border">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-sky-400/10 flex items-center justify-center">
<Icon size={18} className="text-sky-400" />
<div className="w-10 h-10 bg-[#f59e0b]/10 flex items-center justify-center">
<Icon size={18} className="text-[#f59e0b]" />
</div>
<div className="flex-1">
<div className="text-sm text-slate-200 font-medium">
@ -553,7 +553,7 @@ export default function Alerts() {
<div className="text-slate-500 py-4">
<p>No active subscriptions.</p>
<p className="text-xs mt-2">
Manage subscriptions via <code className="text-sky-400">!subscribe</code> on mesh. Broadcasts arrive with one of three prefixes <strong>New:</strong> (first sight), <strong>Update:</strong> (material change), or <strong>Active:</strong> (clock-driven reminder while the event is still live). See <a href="/reference#broadcast-types" className="text-sky-400 hover:underline">Broadcast Types</a> and <a href="/reference#reminders" className="text-sky-400 hover:underline">Reminder System</a> in Reference.
Manage subscriptions via <code className="text-[#f59e0b]">!subscribe</code> on mesh. Broadcasts arrive with one of three prefixes <strong>New:</strong> (first sight), <strong>Update:</strong> (material change), or <strong>Active:</strong> (clock-driven reminder while the event is still live). See <a href="/reference#broadcast-types" className="text-[#f59e0b] hover:underline">Broadcast Types</a> and <a href="/reference#reminders" className="text-[#f59e0b] hover:underline">Reminder System</a> in Reference.
</p>
</div>
)}

View file

@ -96,7 +96,7 @@ export default function GaugeSites() {
<h1 className="text-xl font-semibold text-slate-100">Gauge Sites</h1>
<span className="text-xs text-slate-500 ml-2">{rows.length} sites</span>
<button onClick={beginAdd}
className="ml-auto flex items-center gap-1 px-3 py-1 bg-cyan-700 hover:bg-cyan-600 rounded text-white text-sm">
className="ml-auto flex items-center gap-1 px-3 py-1 bg-[#f59e0b] hover:bg-[#d97706] text-black font-sans font-medium text-sm">
<Plus className="w-4 h-4" /> Add site
</button>
</div>
@ -106,30 +106,30 @@ export default function GaugeSites() {
{adding && <RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} adding feedSource={feedSource} />}
<div className="bg-slate-800/60 border border-slate-700 overflow-x-auto">
<div className="bg-bg-card border border-border overflow-x-auto">
<table className="w-full text-sm text-slate-200">
<thead className="bg-slate-900 text-xs text-slate-400 uppercase">
<thead className="bg-[#161616] border-b border-border">
<tr>
<th className="px-3 py-2 text-left">Site ID</th>
<th className="px-3 py-2 text-left">Name</th>
<th className="px-3 py-2 text-right">Lat,Lon</th>
<th className="px-3 py-2 text-right">Action</th>
<th className="px-3 py-2 text-right">Minor</th>
<th className="px-3 py-2 text-right">Moderate</th>
<th className="px-3 py-2 text-right">Major</th>
<th className="px-3 py-2 text-center">On</th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-left">Site ID</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-left">Name</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-right">Lat,Lon</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-right">Action</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-right">Minor</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-right">Moderate</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-right">Major</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-center">On</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666]"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700/60">
<tbody className="divide-y divide-border">
{rows.map(r => editing === r.site_id ? (
<tr key={r.site_id} className="bg-slate-900/40">
<tr key={r.site_id} className="bg-bg-card border-b border-border hover:bg-bg-hover">
<td colSpan={9} className="px-3 py-2">
<RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} feedSource={feedSource} />
</td>
</tr>
) : (
<tr key={r.site_id} className="hover:bg-slate-800/50">
<tr key={r.site_id} className="hover:bg-bg-hover">
<td className="px-3 py-2 font-mono text-xs">{r.site_id}</td>
<td className="px-3 py-2">{r.gauge_name}</td>
<td className="px-3 py-2 text-right text-xs">{r.lat.toFixed(3)},{r.lon.toFixed(3)}</td>
@ -204,15 +204,15 @@ function RowEditor({ draft, setDraft, onSave, onCancel, adding, feedSource }: {
}
}
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 p-3 bg-slate-900/50 rounded">
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 p-3 bg-[#1a1a1a]">
<label className="text-xs text-slate-400 col-span-2">
Site ID
<div className="flex items-center gap-1 mt-1">
<input className="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100 font-mono text-xs"
<input className="flex-1 bg-bg border border-border px-2 py-1 text-slate-100 font-mono text-xs"
value={draft.site_id} onChange={e => upd('site_id', e.target.value)} disabled={!adding} />
<button type="button" onClick={onLookup} disabled={lookupDisabled || lookupBusy}
title={lookupTitle}
className="px-2 py-1 bg-slate-700 hover:bg-slate-600 disabled:opacity-30 disabled:cursor-not-allowed rounded text-xs text-slate-100 flex items-center gap-1">
className="px-2 py-1 bg-bg-hover hover:bg-[#333] disabled:opacity-30 disabled:cursor-not-allowed text-xs text-slate-100 flex items-center gap-1">
{lookupBusy ? <Loader2 className="w-3 h-3 animate-spin" /> : <Search className="w-3 h-3" />}
USGS lookup
</button>
@ -221,31 +221,31 @@ function RowEditor({ draft, setDraft, onSave, onCancel, adding, feedSource }: {
</label>
<label className="text-xs text-slate-400 col-span-2">
Gauge name
<input className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
<input className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.gauge_name} onChange={e => upd('gauge_name', e.target.value)} />
</label>
<label className="text-xs text-slate-400">Lat
<input type="number" step="any" className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
<input type="number" step="any" className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.lat} onChange={e => upd('lat', parseFloat(e.target.value))} />
</label>
<label className="text-xs text-slate-400">Lon
<input type="number" step="any" className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
<input type="number" step="any" className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.lon} onChange={e => upd('lon', parseFloat(e.target.value))} />
</label>
<label className="text-xs text-slate-400">Action ft
<input type="number" step="any" className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
<input type="number" step="any" className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.action_ft ?? ''} onChange={e => upd('action_ft', e.target.value === '' ? null : parseFloat(e.target.value))} />
</label>
<label className="text-xs text-slate-400">Minor flood ft
<input type="number" step="any" className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
<input type="number" step="any" className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.flood_minor_ft ?? ''} onChange={e => upd('flood_minor_ft', e.target.value === '' ? null : parseFloat(e.target.value))} />
</label>
<label className="text-xs text-slate-400">Moderate flood ft
<input type="number" step="any" className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
<input type="number" step="any" className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.flood_moderate_ft ?? ''} onChange={e => upd('flood_moderate_ft', e.target.value === '' ? null : parseFloat(e.target.value))} />
</label>
<label className="text-xs text-slate-400">Major flood ft
<input type="number" step="any" className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
<input type="number" step="any" className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.flood_major_ft ?? ''} onChange={e => upd('flood_major_ft', e.target.value === '' ? null : parseFloat(e.target.value))} />
</label>
<label className="text-xs text-slate-300 col-span-2 flex items-center gap-2 mt-2">
@ -253,8 +253,8 @@ function RowEditor({ draft, setDraft, onSave, onCancel, adding, feedSource }: {
Enabled
</label>
<div className="col-span-2 flex items-center justify-end gap-2 mt-2">
<button onClick={onCancel} className="px-3 py-1 text-slate-300 hover:bg-slate-700 rounded text-sm">Cancel</button>
<button onClick={onSave} className="px-3 py-1 bg-cyan-700 hover:bg-cyan-600 text-white rounded text-sm">Save</button>
<button onClick={onCancel} className="px-3 py-1 text-slate-300 hover:bg-bg-hover text-sm">Cancel</button>
<button onClick={onSave} className="px-3 py-1 bg-[#f59e0b] hover:bg-[#d97706] text-black font-sans font-medium text-sm">Save</button>
</div>
</div>
)

View file

@ -929,7 +929,7 @@ function NotificationRuleCard({
title={rule.enabled ? 'Enabled' : 'Disabled'}
/>
{rule.trigger_type === 'schedule' ? (
<Clock size={14} className="text-sky-400 flex-shrink-0" />
<Clock size={14} className="text-[#f59e0b] flex-shrink-0" />
) : (
<Zap size={14} className="text-yellow-400 flex-shrink-0" />
)}
@ -976,7 +976,7 @@ function NotificationRuleCard({
<button
onClick={(e) => { e.stopPropagation(); handleTest() }}
disabled={testing || !rule.name}
className="p-1.5 text-sky-400 hover:text-sky-300 hover:bg-sky-400/10 rounded disabled:opacity-50"
className="p-1.5 text-[#f59e0b] hover:text-[#d97706] hover:bg-[#f59e0b]/10 rounded disabled:opacity-50"
title="Test rule"
>
<Send size={14} />

View file

@ -68,7 +68,7 @@ export default function TownAnchors() {
<h1 className="text-xl font-semibold text-slate-100">Town Anchors</h1>
<span className="text-xs text-slate-500 ml-2">{rows.length} towns</span>
<button onClick={beginAdd}
className="ml-auto flex items-center gap-1 px-3 py-1 bg-cyan-700 hover:bg-cyan-600 rounded text-white text-sm">
className="ml-auto flex items-center gap-1 px-3 py-1 bg-[#f59e0b] hover:bg-[#d97706] text-black font-sans font-medium text-sm">
<Plus className="w-4 h-4" /> Add town
</button>
</div>
@ -82,25 +82,25 @@ export default function TownAnchors() {
{adding && <RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} adding />}
<div className="bg-slate-800/60 border border-slate-700 overflow-x-auto">
<div className="bg-bg-card border border-border overflow-x-auto">
<table className="w-full text-sm text-slate-200">
<thead className="bg-slate-900 text-xs text-slate-400 uppercase">
<thead className="bg-[#161616] border-b border-border">
<tr>
<th className="px-3 py-2 text-left">Name</th>
<th className="px-3 py-2 text-right">Lat</th>
<th className="px-3 py-2 text-right">Lon</th>
<th className="px-3 py-2 text-center">State</th>
<th className="px-3 py-2 text-center">On</th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-left">Name</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-right">Lat</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-right">Lon</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-center">State</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-center">On</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666]"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700/60">
<tbody className="divide-y divide-border">
{rows.map(r => editing === r.anchor_id ? (
<tr key={r.anchor_id} className="bg-slate-900/40">
<tr key={r.anchor_id} className="bg-bg-card border-b border-border hover:bg-bg-hover">
<td colSpan={6} className="px-3 py-2"><RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} /></td>
</tr>
) : (
<tr key={r.anchor_id} className="hover:bg-slate-800/50">
<tr key={r.anchor_id} className="hover:bg-bg-hover">
<td className="px-3 py-2 capitalize">{r.name}</td>
<td className="px-3 py-2 text-right text-xs">{r.lat.toFixed(4)}</td>
<td className="px-3 py-2 text-right text-xs">{r.lon.toFixed(4)}</td>
@ -126,13 +126,13 @@ function RowEditor({ draft, setDraft, onSave, onCancel, adding }: {
}) {
const upd = (k: keyof TownAnchor, v: unknown) => setDraft({ ...draft, [k]: v })
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 p-3 bg-slate-900/50 rounded">
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 p-3 bg-[#1a1a1a]">
<label className="text-xs text-slate-400 col-span-2">Name (lowercased on save)
<input className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
<input className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.name} onChange={e => upd('name', e.target.value)} disabled={!adding} />
</label>
<label className="text-xs text-slate-400">State
<input className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
<input className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.state ?? ''} onChange={e => upd('state', e.target.value)} />
</label>
<label className="text-xs text-slate-400 flex items-center gap-2">
@ -140,16 +140,16 @@ function RowEditor({ draft, setDraft, onSave, onCancel, adding }: {
Enabled
</label>
<label className="text-xs text-slate-400">Lat
<input type="number" step="any" className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
<input type="number" step="any" className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.lat} onChange={e => upd('lat', parseFloat(e.target.value))} />
</label>
<label className="text-xs text-slate-400">Lon
<input type="number" step="any" className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100"
<input type="number" step="any" className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.lon} onChange={e => upd('lon', parseFloat(e.target.value))} />
</label>
<div className="col-span-2 flex items-center justify-end gap-2 mt-2">
<button onClick={onCancel} className="px-3 py-1 text-slate-300 hover:bg-slate-700 rounded text-sm">Cancel</button>
<button onClick={onSave} className="px-3 py-1 bg-cyan-700 hover:bg-cyan-600 text-white rounded text-sm">Save</button>
<button onClick={onCancel} className="px-3 py-1 text-slate-300 hover:bg-bg-hover text-sm">Cancel</button>
<button onClick={onSave} className="px-3 py-1 bg-[#f59e0b] hover:bg-[#d97706] text-black font-sans font-medium text-sm">Save</button>
</div>
</div>
)

View file

@ -25,7 +25,7 @@ import urllib.request
from collections import OrderedDict
from datetime import datetime, timezone
from typing import Any, Optional
from meshai.adapter_config import adapter_config
# Geocoder config is set via init_geocoder_config()
logger = logging.getLogger(__name__)
@ -248,13 +248,31 @@ def _is_uninformative_road(road: Optional[str]) -> bool:
# 2026-06-04). It's the same Echo6-local Photon instance that backs Central's
# NaviBackend reverse-geocoder. Photon takes osm_tag=place (KEY only, not
# key:value with comma-list -- that returns 0 features -- per probe).
# v0.6-3b: photon endpoint settings live in adapter_config.geocoder.
# Module-level names retained as backward-compat aliases so existing
# test imports / monkeypatches still resolve.
PHOTON_BASE_URL = "http://100.64.0.24:2322"
PHOTON_TIMEOUT_S = 2.0
PHOTON_RADIUS_KM = 80 # ≈ 50 miles
PHOTON_LIMIT = 10
# v0.6-3b: photon geocoder config - initialized via init_geocoder_config()
# Defaults to public Komoot Photon; deployments override in config.yaml.
class _GeocoderSettings:
url: str = "https://photon.komoot.io"
timeout_seconds: float = 2.0
radius_km: float = 80.0
limit: int = 10
_geocoder = _GeocoderSettings()
def init_geocoder_config(url: str = None, timeout: float = None,
radius: float = None, limit: int = None) -> None:
"""Initialize geocoder settings from config.yaml values."""
if url is not None:
_geocoder.url = url
if timeout is not None:
_geocoder.timeout_seconds = timeout
if radius is not None:
_geocoder.radius_km = radius
if limit is not None:
_geocoder.limit = limit
# OSM place classes we accept as "town". Suburb included for metro coverage;
# locality is rare but valid for tiny rural places.
_TOWN_OSM_VALUES = frozenset({"city", "town", "village"})
@ -282,13 +300,13 @@ def _photon_reverse_places(lat: float, lon: float) -> list[dict]:
qs = urllib.parse.urlencode({
"lat": f"{lat:.6f}",
"lon": f"{lon:.6f}",
"radius": PHOTON_RADIUS_KM,
"radius": _geocoder.radius_km,
"osm_tag": "place",
"limit": PHOTON_LIMIT,
"limit": _geocoder.limit,
})
url = f"{PHOTON_BASE_URL}/reverse?{qs}"
url = f"{_geocoder.url}/reverse?{qs}"
try:
with urllib.request.urlopen(url, timeout=PHOTON_TIMEOUT_S) as resp:
with urllib.request.urlopen(url, timeout=_geocoder.timeout_seconds) as resp:
body = resp.read()
d = json.loads(body)
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError,
@ -308,7 +326,7 @@ def nearest_town(lat: float, lon: float, max_distance_mi: float = 50.0) -> Optio
event is N of the town. Returns None if no town within range or if
Photon is unreachable.
Calls Photon /reverse?osm_tag=place at PHOTON_BASE_URL. Results are
Calls Photon /reverse?osm_tag=place at _geocoder.url. Results are
H3-cell-cached (resolution 7 5 km cells) so the second event near
the same town is free.
"""

View file

@ -468,6 +468,16 @@ class CentralConsumerConfig:
region: str = "us.id"
@dataclass
class GeocoderConfig:
"""Photon reverse geocoder settings."""
url: str = "https://photon.komoot.io"
timeout_seconds: float = 2.0
radius_km: float = 80.0
limit: int = 10
@dataclass
class EnvironmentalConfig:
"""Environmental feeds settings."""
@ -486,6 +496,7 @@ class EnvironmentalConfig:
wzdx: WZDxConfig = field(default_factory=WZDxConfig)
firms: FIRMSConfig = field(default_factory=FIRMSConfig)
central: CentralConsumerConfig = field(default_factory=CentralConsumerConfig)
geocoder: GeocoderConfig = field(default_factory=GeocoderConfig)
@dataclass

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -2,14 +2,14 @@
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/meshai-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MeshAI Dashboard</title>
<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-ChPg5oDu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-UYhE7jnf.css">
<script type="module" crossorigin src="/assets/index-D5IfmtDv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BC90GDxp.css">
</head>
<body>
<div id="root"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

112
meshai/env/store.py vendored
View file

@ -21,6 +21,7 @@ class EnvironmentalStore:
event_bus: Optional["EventBus"] = None,
):
self._adapters = {} # name -> adapter instance
self._failed_adapters = {} # name -> last_error string
self._events = {} # (source, event_id) -> event dict
self._event_bus = event_bus # Pipeline EventBus for emission
self._swpc_status = {} # Kp/SFI/scales snapshot
@ -28,56 +29,60 @@ class EnvironmentalStore:
self._mesh_zones = config.nws_zones or []
self._region_anchors = region_anchors or []
# Create adapter instances based on config
if config.nws.enabled and config.nws.feed_source == "native":
from .nws import NWSAlertsAdapter
self._adapters["nws"] = NWSAlertsAdapter(config.nws)
if config.swpc.enabled and config.swpc.feed_source == "native":
from .swpc import SWPCAdapter
self._adapters["swpc"] = SWPCAdapter(config.swpc)
if config.ducting.enabled and config.ducting.feed_source == "native":
from .ducting import DuctingAdapter
self._adapters["ducting"] = DuctingAdapter(config.ducting)
if config.fires.enabled and config.fires.feed_source == "native":
from .fires import NICFFiresAdapter
self._adapters["nifc"] = NICFFiresAdapter(config.fires, self._region_anchors)
if config.avalanche.enabled and config.avalanche.feed_source == "native":
from .avalanche import AvalancheAdapter
self._adapters["avalanche"] = AvalancheAdapter(config.avalanche)
if config.usgs.enabled and config.usgs.feed_source == "native":
from .usgs import USGSStreamsAdapter
self._adapters["usgs"] = USGSStreamsAdapter(config.usgs)
if config.usgs_quake.enabled and config.usgs_quake.feed_source == "native":
from .usgs_quake import USGSQuakeAdapter
self._adapters["usgs_quake"] = USGSQuakeAdapter(config.usgs_quake)
if config.traffic.enabled and config.traffic.feed_source == "native":
from .traffic import TomTomTrafficAdapter
self._adapters["traffic"] = TomTomTrafficAdapter(config.traffic)
if config.roads511.enabled and config.roads511.feed_source == "native":
from .roads511 import Roads511Adapter
self._adapters["roads511"] = Roads511Adapter(config.roads511)
# Create adapter instances with error isolation
self._register_adapter("nws", config.nws, ".nws", "NWSAlertsAdapter",
lambda cfg: (cfg,))
self._register_adapter("swpc", config.swpc, ".swpc", "SWPCAdapter",
lambda cfg: (cfg,))
self._register_adapter("ducting", config.ducting, ".ducting", "DuctingAdapter",
lambda cfg: (cfg,))
self._register_adapter("nifc", config.fires, ".fires", "NICFFiresAdapter",
lambda cfg: (cfg, self._region_anchors))
self._register_adapter("avalanche", config.avalanche, ".avalanche", "AvalancheAdapter",
lambda cfg: (cfg,))
self._register_adapter("usgs", config.usgs, ".usgs", "USGSStreamsAdapter",
lambda cfg: (cfg,))
self._register_adapter("usgs_quake", config.usgs_quake, ".usgs_quake", "USGSQuakeAdapter",
lambda cfg: (cfg,))
self._register_adapter("traffic", config.traffic, ".traffic", "TomTomTrafficAdapter",
lambda cfg: (cfg,))
self._register_adapter("roads511", config.roads511, ".roads511", "Roads511Adapter",
lambda cfg: (cfg,))
# FIRMS needs reference to NIFC adapter for cross-referencing
if config.firms.enabled and config.firms.feed_source == "native":
try:
from .firms import FIRMSAdapter
fires_adapter = self._adapters.get("nifc")
self._firms = FIRMSAdapter(config.firms, self._region_anchors, fires_adapter)
self._adapters["firms"] = self._firms
except Exception as e:
err_msg = f"{type(e).__name__}: {e}"
logger.warning("Failed to initialize firms adapter: %s", err_msg)
self._failed_adapters["firms"] = err_msg
_central = [n for n in ("nws", "swpc", "ducting", "fires", "avalanche", "usgs", "usgs_quake", "traffic", "roads511", "firms")
if getattr(getattr(config, n, None), "feed_source", "native") == "central"]
if _central:
logger.debug("Adapters sourced from Central (native skipped): %s", _central)
if self._failed_adapters:
logger.warning("Failed adapters: %s", list(self._failed_adapters.keys()))
logger.info(f"EnvironmentalStore initialized with {len(self._adapters)} adapters")
def _register_adapter(self, name: str, cfg, module_path: str, class_name: str, args_fn):
"""Register a single adapter with error isolation."""
if not cfg.enabled or cfg.feed_source != "native":
return
try:
module = __import__(f"meshai.env{module_path}", fromlist=[class_name])
cls = getattr(module, class_name)
self._adapters[name] = cls(*args_fn(cfg))
except Exception as e:
err_msg = f"{type(e).__name__}: {e}"
logger.warning("Failed to initialize %s adapter: %s", name, err_msg)
self._failed_adapters[name] = err_msg
def refresh(self) -> bool:
"""Called every second from main loop. Ticks each adapter.
@ -302,6 +307,41 @@ class EnvironmentalStore:
return "\n".join(lines)
def get_status(self) -> list:
"""Get status of all adapters including failed ones."""
status = []
for name, adapter in self._adapters.items():
try:
hs = adapter.health_status
status.append({
"source": name,
"is_loaded": True,
"last_error": hs.get("last_error"),
"consecutive_errors": hs.get("consecutive_errors", 0),
"event_count": hs.get("event_count", 0),
"last_fetch": hs.get("last_fetch"),
})
except Exception:
status.append({
"source": name,
"is_loaded": True,
"last_error": None,
"consecutive_errors": 0,
"event_count": 0,
"last_fetch": None,
})
for name, error in self._failed_adapters.items():
status.append({
"source": name,
"is_loaded": False,
"last_error": error,
"consecutive_errors": 0,
"event_count": 0,
"last_fetch": None,
})
return status
def get_source_health(self) -> list:
"""Get health status for all adapters."""
return [a.health_status for a in self._adapters.values()]

View file

@ -19,6 +19,7 @@ from .commands.status import set_start_time
from .config import Config
from .config_loader import load_config, get_config_dir_from_path
from .connector import MeshConnector, MeshMessage
from .central_normalizer import init_geocoder_config
from .context import MeshContext
from .history import ConversationHistory
from .memory import ConversationSummary
@ -245,6 +246,19 @@ class MeshAI:
except Exception:
logger.exception("persistence init_db failed at startup")
# v0.6-3b: Initialize geocoder config from config.yaml
try:
gc = self.config.environmental.geocoder
init_geocoder_config(
url=gc.url,
timeout=gc.timeout_seconds,
radius=gc.radius_km,
limit=gc.limit
)
logger.info("Geocoder configured: %s", gc.url)
except Exception:
logger.exception("geocoder init failed - using defaults")
# Conversation history
self.history = ConversationHistory(self.config.history)
await self.history.initialize()