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"> <html lang="en" class="dark">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MeshAI Dashboard</title> <title>MeshAI Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <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 */} {/* Sidebar */}
<aside className="w-[220px] flex-shrink-0 bg-bg-card border-r border-border flex flex-col overflow-y-auto"> <aside className="w-[220px] flex-shrink-0 bg-bg-card border-r border-border flex flex-col overflow-y-auto">
{/* Logo */} {/* Logo */}
<div className="p-5 border-b border-border"> <div className="bg-[#000000] px-4 py-3 border-b border-border flex flex-col items-center">
<div className="flex items-center gap-3"> <img
<div className="w-[3px] h-8 bg-accent flex-shrink-0" /> src="/meshai-logo.png"
<div> alt="MeshAI"
<div className="font-sans font-bold text-white text-[15px] leading-tight tracking-tight">MeshAI</div> className="w-[190px] block"
<div className="text-xs font-mono text-[#666]"> />
<div className="font-mono text-[10px] text-[#555] mt-1 self-start">
v{status?.version || '...'} v{status?.version || '...'}
</div> </div>
</div> </div>
</div>
</div>
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 py-4"> <nav className="flex-1 py-4">
@ -116,14 +115,14 @@ export default function Layout({ children }: LayoutProps) {
to={item.path} to={item.path}
className={`flex items-center gap-3 px-5 py-3 text-sm font-sans transition-colors relative ${ className={`flex items-center gap-3 px-5 py-3 text-sm font-sans transition-colors relative ${
isActive isActive
? 'text-white' ? 'text-white bg-transparent'
: 'text-[#777] hover:text-[#888]' : 'text-[#777] hover:text-white hover:bg-bg-hover'
}`} }`}
> >
{isActive && ( {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} {item.label}
</Link> </Link>
) )

View file

@ -76,10 +76,10 @@ function getSeverityStyles(severity: string) {
case 'routine': case 'routine':
default: default:
return { return {
bg: 'bg-sky-400/10', bg: 'bg-[#f59e0b]/10',
border: 'border-sky-400', border: 'border-[#f59e0b]',
badge: 'bg-sky-400/20 text-sky-400', badge: 'bg-[#f59e0b]/20 text-[#f59e0b]',
iconColor: 'text-sky-400', iconColor: 'text-[#f59e0b]',
} }
} }
} }
@ -207,7 +207,7 @@ function AlertHistoryTable({
<select <select
value={typeFilter} value={typeFilter}
onChange={(e) => onTypeFilterChange(e.target.value)} 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) => ( {alertTypes.map((t) => (
<option key={t} value={t}> <option key={t} value={t}>
@ -218,7 +218,7 @@ function AlertHistoryTable({
<select <select
value={severityFilter} value={severityFilter}
onChange={(e) => onSeverityFilterChange(e.target.value)} 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) => ( {severities.map((s) => (
<option key={s} value={s}> <option key={s} value={s}>
@ -354,8 +354,8 @@ function SubscriptionCard({ subscription, nodes }: { subscription: Subscription;
return ( return (
<div className="p-4 bg-bg-hover border border-border"> <div className="p-4 bg-bg-hover border border-border">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 bg-sky-400/10 flex items-center justify-center"> <div className="w-10 h-10 bg-[#f59e0b]/10 flex items-center justify-center">
<Icon size={18} className="text-sky-400" /> <Icon size={18} className="text-[#f59e0b]" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="text-sm text-slate-200 font-medium"> <div className="text-sm text-slate-200 font-medium">
@ -553,7 +553,7 @@ export default function Alerts() {
<div className="text-slate-500 py-4"> <div className="text-slate-500 py-4">
<p>No active subscriptions.</p> <p>No active subscriptions.</p>
<p className="text-xs mt-2"> <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> </p>
</div> </div>
)} )}

View file

@ -96,7 +96,7 @@ export default function GaugeSites() {
<h1 className="text-xl font-semibold text-slate-100">Gauge Sites</h1> <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> <span className="text-xs text-slate-500 ml-2">{rows.length} sites</span>
<button onClick={beginAdd} <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 <Plus className="w-4 h-4" /> Add site
</button> </button>
</div> </div>
@ -106,30 +106,30 @@ export default function GaugeSites() {
{adding && <RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} adding feedSource={feedSource} />} {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"> <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> <tr>
<th className="px-3 py-2 text-left">Site ID</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 text-left">Name</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 text-right">Lat,Lon</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 text-right">Action</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 text-right">Minor</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 text-right">Moderate</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 text-right">Major</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 text-center">On</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"></th> <th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666]"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-700/60"> <tbody className="divide-y divide-border">
{rows.map(r => editing === r.site_id ? ( {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"> <td colSpan={9} className="px-3 py-2">
<RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} feedSource={feedSource} /> <RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} feedSource={feedSource} />
</td> </td>
</tr> </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 font-mono text-xs">{r.site_id}</td>
<td className="px-3 py-2">{r.gauge_name}</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> <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 ( 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"> <label className="text-xs text-slate-400 col-span-2">
Site ID Site ID
<div className="flex items-center gap-1 mt-1"> <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} /> value={draft.site_id} onChange={e => upd('site_id', e.target.value)} disabled={!adding} />
<button type="button" onClick={onLookup} disabled={lookupDisabled || lookupBusy} <button type="button" onClick={onLookup} disabled={lookupDisabled || lookupBusy}
title={lookupTitle} 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" />} {lookupBusy ? <Loader2 className="w-3 h-3 animate-spin" /> : <Search className="w-3 h-3" />}
USGS lookup USGS lookup
</button> </button>
@ -221,31 +221,31 @@ function RowEditor({ draft, setDraft, onSave, onCancel, adding, feedSource }: {
</label> </label>
<label className="text-xs text-slate-400 col-span-2"> <label className="text-xs text-slate-400 col-span-2">
Gauge name 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)} /> value={draft.gauge_name} onChange={e => upd('gauge_name', e.target.value)} />
</label> </label>
<label className="text-xs text-slate-400">Lat <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))} /> value={draft.lat} onChange={e => upd('lat', parseFloat(e.target.value))} />
</label> </label>
<label className="text-xs text-slate-400">Lon <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))} /> value={draft.lon} onChange={e => upd('lon', parseFloat(e.target.value))} />
</label> </label>
<label className="text-xs text-slate-400">Action ft <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))} /> value={draft.action_ft ?? ''} onChange={e => upd('action_ft', e.target.value === '' ? null : parseFloat(e.target.value))} />
</label> </label>
<label className="text-xs text-slate-400">Minor flood ft <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))} /> value={draft.flood_minor_ft ?? ''} onChange={e => upd('flood_minor_ft', e.target.value === '' ? null : parseFloat(e.target.value))} />
</label> </label>
<label className="text-xs text-slate-400">Moderate flood ft <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))} /> value={draft.flood_moderate_ft ?? ''} onChange={e => upd('flood_moderate_ft', e.target.value === '' ? null : parseFloat(e.target.value))} />
</label> </label>
<label className="text-xs text-slate-400">Major flood ft <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))} /> value={draft.flood_major_ft ?? ''} onChange={e => upd('flood_major_ft', e.target.value === '' ? null : parseFloat(e.target.value))} />
</label> </label>
<label className="text-xs text-slate-300 col-span-2 flex items-center gap-2 mt-2"> <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 Enabled
</label> </label>
<div className="col-span-2 flex items-center justify-end gap-2 mt-2"> <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={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-cyan-700 hover:bg-cyan-600 text-white rounded text-sm">Save</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>
</div> </div>
) )

View file

@ -929,7 +929,7 @@ function NotificationRuleCard({
title={rule.enabled ? 'Enabled' : 'Disabled'} title={rule.enabled ? 'Enabled' : 'Disabled'}
/> />
{rule.trigger_type === 'schedule' ? ( {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" /> <Zap size={14} className="text-yellow-400 flex-shrink-0" />
)} )}
@ -976,7 +976,7 @@ function NotificationRuleCard({
<button <button
onClick={(e) => { e.stopPropagation(); handleTest() }} onClick={(e) => { e.stopPropagation(); handleTest() }}
disabled={testing || !rule.name} 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" title="Test rule"
> >
<Send size={14} /> <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> <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> <span className="text-xs text-slate-500 ml-2">{rows.length} towns</span>
<button onClick={beginAdd} <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 <Plus className="w-4 h-4" /> Add town
</button> </button>
</div> </div>
@ -82,25 +82,25 @@ export default function TownAnchors() {
{adding && <RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} adding />} {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"> <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> <tr>
<th className="px-3 py-2 text-left">Name</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 text-right">Lat</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 text-right">Lon</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 text-center">State</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 text-center">On</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"></th> <th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666]"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-700/60"> <tbody className="divide-y divide-border">
{rows.map(r => editing === r.anchor_id ? ( {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> <td colSpan={6} className="px-3 py-2"><RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} /></td>
</tr> </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 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.lat.toFixed(4)}</td>
<td className="px-3 py-2 text-right text-xs">{r.lon.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 }) const upd = (k: keyof TownAnchor, v: unknown) => setDraft({ ...draft, [k]: v })
return ( 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) <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} /> value={draft.name} onChange={e => upd('name', e.target.value)} disabled={!adding} />
</label> </label>
<label className="text-xs text-slate-400">State <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)} /> value={draft.state ?? ''} onChange={e => upd('state', e.target.value)} />
</label> </label>
<label className="text-xs text-slate-400 flex items-center gap-2"> <label className="text-xs text-slate-400 flex items-center gap-2">
@ -140,16 +140,16 @@ function RowEditor({ draft, setDraft, onSave, onCancel, adding }: {
Enabled Enabled
</label> </label>
<label className="text-xs text-slate-400">Lat <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))} /> value={draft.lat} onChange={e => upd('lat', parseFloat(e.target.value))} />
</label> </label>
<label className="text-xs text-slate-400">Lon <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))} /> value={draft.lon} onChange={e => upd('lon', parseFloat(e.target.value))} />
</label> </label>
<div className="col-span-2 flex items-center justify-end gap-2 mt-2"> <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={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-cyan-700 hover:bg-cyan-600 text-white rounded text-sm">Save</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>
</div> </div>
) )

View file

@ -25,7 +25,7 @@ import urllib.request
from collections import OrderedDict from collections import OrderedDict
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Optional from typing import Any, Optional
from meshai.adapter_config import adapter_config # Geocoder config is set via init_geocoder_config()
logger = logging.getLogger(__name__) 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 # 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 # NaviBackend reverse-geocoder. Photon takes osm_tag=place (KEY only, not
# key:value with comma-list -- that returns 0 features -- per probe). # key:value with comma-list -- that returns 0 features -- per probe).
# v0.6-3b: photon endpoint settings live in adapter_config.geocoder. # v0.6-3b: photon geocoder config - initialized via init_geocoder_config()
# Module-level names retained as backward-compat aliases so existing # Defaults to public Komoot Photon; deployments override in config.yaml.
# test imports / monkeypatches still resolve.
PHOTON_BASE_URL = "http://100.64.0.24:2322" class _GeocoderSettings:
PHOTON_TIMEOUT_S = 2.0 url: str = "https://photon.komoot.io"
PHOTON_RADIUS_KM = 80 # ≈ 50 miles timeout_seconds: float = 2.0
PHOTON_LIMIT = 10 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; # OSM place classes we accept as "town". Suburb included for metro coverage;
# locality is rare but valid for tiny rural places. # locality is rare but valid for tiny rural places.
_TOWN_OSM_VALUES = frozenset({"city", "town", "village"}) _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({ qs = urllib.parse.urlencode({
"lat": f"{lat:.6f}", "lat": f"{lat:.6f}",
"lon": f"{lon:.6f}", "lon": f"{lon:.6f}",
"radius": PHOTON_RADIUS_KM, "radius": _geocoder.radius_km,
"osm_tag": "place", "osm_tag": "place",
"limit": PHOTON_LIMIT, "limit": _geocoder.limit,
}) })
url = f"{PHOTON_BASE_URL}/reverse?{qs}" url = f"{_geocoder.url}/reverse?{qs}"
try: 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() body = resp.read()
d = json.loads(body) d = json.loads(body)
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, 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 event is N of the town. Returns None if no town within range or if
Photon is unreachable. 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 H3-cell-cached (resolution 7 5 km cells) so the second event near
the same town is free. the same town is free.
""" """

View file

@ -468,6 +468,16 @@ class CentralConsumerConfig:
region: str = "us.id" 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 @dataclass
class EnvironmentalConfig: class EnvironmentalConfig:
"""Environmental feeds settings.""" """Environmental feeds settings."""
@ -486,6 +496,7 @@ class EnvironmentalConfig:
wzdx: WZDxConfig = field(default_factory=WZDxConfig) wzdx: WZDxConfig = field(default_factory=WZDxConfig)
firms: FIRMSConfig = field(default_factory=FIRMSConfig) firms: FIRMSConfig = field(default_factory=FIRMSConfig)
central: CentralConsumerConfig = field(default_factory=CentralConsumerConfig) central: CentralConsumerConfig = field(default_factory=CentralConsumerConfig)
geocoder: GeocoderConfig = field(default_factory=GeocoderConfig)
@dataclass @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"> <html lang="en" class="dark">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MeshAI Dashboard</title> <title>MeshAI Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <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> <script type="module" crossorigin src="/assets/index-D5IfmtDv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-UYhE7jnf.css"> <link rel="stylesheet" crossorigin href="/assets/index-BC90GDxp.css">
</head> </head>
<body> <body>
<div id="root"></div> <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, event_bus: Optional["EventBus"] = None,
): ):
self._adapters = {} # name -> adapter instance self._adapters = {} # name -> adapter instance
self._failed_adapters = {} # name -> last_error string
self._events = {} # (source, event_id) -> event dict self._events = {} # (source, event_id) -> event dict
self._event_bus = event_bus # Pipeline EventBus for emission self._event_bus = event_bus # Pipeline EventBus for emission
self._swpc_status = {} # Kp/SFI/scales snapshot self._swpc_status = {} # Kp/SFI/scales snapshot
@ -28,56 +29,60 @@ class EnvironmentalStore:
self._mesh_zones = config.nws_zones or [] self._mesh_zones = config.nws_zones or []
self._region_anchors = region_anchors or [] self._region_anchors = region_anchors or []
# Create adapter instances based on config # Create adapter instances with error isolation
if config.nws.enabled and config.nws.feed_source == "native": self._register_adapter("nws", config.nws, ".nws", "NWSAlertsAdapter",
from .nws import NWSAlertsAdapter lambda cfg: (cfg,))
self._adapters["nws"] = NWSAlertsAdapter(config.nws) self._register_adapter("swpc", config.swpc, ".swpc", "SWPCAdapter",
lambda cfg: (cfg,))
if config.swpc.enabled and config.swpc.feed_source == "native": self._register_adapter("ducting", config.ducting, ".ducting", "DuctingAdapter",
from .swpc import SWPCAdapter lambda cfg: (cfg,))
self._adapters["swpc"] = SWPCAdapter(config.swpc) self._register_adapter("nifc", config.fires, ".fires", "NICFFiresAdapter",
lambda cfg: (cfg, self._region_anchors))
if config.ducting.enabled and config.ducting.feed_source == "native": self._register_adapter("avalanche", config.avalanche, ".avalanche", "AvalancheAdapter",
from .ducting import DuctingAdapter lambda cfg: (cfg,))
self._adapters["ducting"] = DuctingAdapter(config.ducting) self._register_adapter("usgs", config.usgs, ".usgs", "USGSStreamsAdapter",
lambda cfg: (cfg,))
if config.fires.enabled and config.fires.feed_source == "native": self._register_adapter("usgs_quake", config.usgs_quake, ".usgs_quake", "USGSQuakeAdapter",
from .fires import NICFFiresAdapter lambda cfg: (cfg,))
self._adapters["nifc"] = NICFFiresAdapter(config.fires, self._region_anchors) self._register_adapter("traffic", config.traffic, ".traffic", "TomTomTrafficAdapter",
lambda cfg: (cfg,))
if config.avalanche.enabled and config.avalanche.feed_source == "native": self._register_adapter("roads511", config.roads511, ".roads511", "Roads511Adapter",
from .avalanche import AvalancheAdapter lambda cfg: (cfg,))
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)
# FIRMS needs reference to NIFC adapter for cross-referencing # FIRMS needs reference to NIFC adapter for cross-referencing
if config.firms.enabled and config.firms.feed_source == "native": if config.firms.enabled and config.firms.feed_source == "native":
try:
from .firms import FIRMSAdapter from .firms import FIRMSAdapter
fires_adapter = self._adapters.get("nifc") fires_adapter = self._adapters.get("nifc")
self._firms = FIRMSAdapter(config.firms, self._region_anchors, fires_adapter) self._firms = FIRMSAdapter(config.firms, self._region_anchors, fires_adapter)
self._adapters["firms"] = self._firms 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") _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 getattr(getattr(config, n, None), "feed_source", "native") == "central"]
if _central: if _central:
logger.debug("Adapters sourced from Central (native skipped): %s", _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") 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: def refresh(self) -> bool:
"""Called every second from main loop. Ticks each adapter. """Called every second from main loop. Ticks each adapter.
@ -302,6 +307,41 @@ class EnvironmentalStore:
return "\n".join(lines) 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: def get_source_health(self) -> list:
"""Get health status for all adapters.""" """Get health status for all adapters."""
return [a.health_status for a in self._adapters.values()] 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 import Config
from .config_loader import load_config, get_config_dir_from_path from .config_loader import load_config, get_config_dir_from_path
from .connector import MeshConnector, MeshMessage from .connector import MeshConnector, MeshMessage
from .central_normalizer import init_geocoder_config
from .context import MeshContext from .context import MeshContext
from .history import ConversationHistory from .history import ConversationHistory
from .memory import ConversationSummary from .memory import ConversationSummary
@ -245,6 +246,19 @@ class MeshAI:
except Exception: except Exception:
logger.exception("persistence init_db failed at startup") 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 # Conversation history
self.history = ConversationHistory(self.config.history) self.history = ConversationHistory(self.config.history)
await self.history.initialize() await self.history.initialize()