mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 09:24:44 +02:00
Merge branch 'feature/mesh-intelligence'
This commit is contained in:
commit
98e3fcf675
18 changed files with 221 additions and 139 deletions
|
|
@ -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">
|
||||
|
|
|
|||
BIN
dashboard-frontend/public/meshai-icon.png
Normal file
BIN
dashboard-frontend/public/meshai-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
dashboard-frontend/public/meshai-logo.png
Normal file
BIN
dashboard-frontend/public/meshai-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 414 KiB |
|
|
@ -93,15 +93,14 @@ 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]">
|
||||
v{status?.version || '...'}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
meshai/dashboard/static/assets/index-BC90GDxp.css
Normal file
1
meshai/dashboard/static/assets/index-BC90GDxp.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
BIN
meshai/dashboard/static/meshai-icon.png
Normal file
BIN
meshai/dashboard/static/meshai-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
meshai/dashboard/static/meshai-logo.png
Normal file
BIN
meshai/dashboard/static/meshai-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 414 KiB |
120
meshai/env/store.py
vendored
120
meshai/env/store.py
vendored
|
|
@ -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":
|
||||
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
|
||||
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()]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue