feat(v0.6-4): gauge_sites + town_anchors curation tables + GUI CRUD

Closes Section A.5 (gauge_sites) and A.12 (town_anchors) of the audit
doc by lifting both Python-dict curation lists into editable SQLite
tables. Operators can add/edit/disable rows from the dashboard without
a deploy; runtime reads go through cached accessors that invalidate
when the REST API mutates state.

Schema:
  v8.sql adds gauge_sites(site_id PK, gauge_name, lat, lon, action_ft,
    flood_minor_ft, flood_moderate_ft, flood_major_ft, enabled, updated_at).
  v9.sql adds town_anchors(anchor_id AUTOINC PK, name UNIQUE, lat, lon,
    state, enabled, updated_at).
  SCHEMA_VERSION 7 -> 9.

Seed (meshai/persistence/curation.py):
  _GAUGE_SITES_SEED carries the original 9 Idaho rows from
  IDAHO_CURATED_SITES verbatim.
  _TOWN_ANCHORS_SEED carries the 29 Idaho-and-neighbor towns from
  _TOWN_COORDS verbatim.
  seed_gauge_sites() / seed_town_anchors() INSERT OR IGNORE -- safe to
  re-run; never overwrites user edits.

Handler integration:
  - meshai/central/idaho_gauge_sites.py: IDAHO_CURATED_SITES dict deleted.
    lookup_site() now calls meshai.persistence.curation.lookup_gauge_site()
    which reads the table. THRESHOLD_RANK, normalize_site_id, and
    compute_threshold_state remain in this module (CODE per Matt s rule).
  - meshai/central/nwis_handler.py drops IDAHO_CURATED_SITES from its
    import list; the table-backed lookup_site() is API-compatible.
  - meshai/central_normalizer.py: _TOWN_COORDS dict deleted.
    _compute_distance_bearing() now calls
    meshai.persistence.curation.lookup_town_anchor() with the same
    lowercased-name semantics it always used.

REST API (meshai/dashboard/api/curation_routes.py):
  /api/gauge-sites  GET list, GET one, POST add, PUT update, DELETE
  /api/town-anchors GET list, GET one, POST add, PUT update, DELETE
  Every mutation calls invalidate_curation_cache() so handler reads see
  the new state on the next call -- no container restart.

Dashboard (dashboard-frontend/src/pages/):
  - GaugeSites.tsx: table view with Add row / Edit row inline / Delete
    confirm + per-row enabled toggle. 8 columns mirror the schema.
  - TownAnchors.tsx: same pattern, 5 columns. Name is lowercased on
    save to match the lookup key.
  - Left-nav entries "Gauge Sites" (Droplets icon) and "Town Anchors"
    (MapPin icon) added to Layout.tsx; routes added to App.tsx.

Tests (tests/test_curation.py, 18 cases):
  - v8/v9 tables exist
  - Seed lands every row from both dicts
  - Seed idempotent; never overwrites user edits
  - lookup_gauge_site hits/miss, disabled rows are invisible
  - lookup_town_anchor case-insensitive
  - REST API: GET list, GET one, GET 404, POST add, PUT update, DELETE,
    POST missing-field 400; both gauge_sites + town_anchors
  - Accessor reflects API mutations after invalidate_curation_cache()

tests/test_nwis_handler.py back-compat: IDAHO_CURATED_SITES dict alias
points at _GAUGE_SITES_SEED so the existing assertion suite still passes.
tests/test_adapter_config_foundation.py schema_meta v7 -> v9 bump.

Test count: 797 -> 819 (+18 curation cases + 4 maintenance updates).
This commit is contained in:
Matt Johnson (via Claude) 2026-06-05 20:19:13 +00:00
commit e3bf53ade4
20 changed files with 1322 additions and 272 deletions

View file

@ -8,6 +8,8 @@ import Alerts from './pages/Alerts'
import Notifications from './pages/Notifications'
import Reference from './pages/Reference'
import AdapterConfig from './pages/AdapterConfig'
import GaugeSites from './pages/GaugeSites'
import TownAnchors from './pages/TownAnchors'
import { ToastProvider } from './components/ToastProvider'
function App() {
@ -23,6 +25,8 @@ function App() {
<Route path="/notifications" element={<Notifications />} />
<Route path="/reference" element={<Reference />} />
<Route path="/adapter-config" element={<AdapterConfig />} />
<Route path="/gauge-sites" element={<GaugeSites />} />
<Route path="/town-anchors" element={<TownAnchors />} />
</Routes>
</Layout>
</ToastProvider>

View file

@ -9,6 +9,8 @@ import {
BellRing,
BookOpen,
Sliders,
Droplets,
MapPin,
} from 'lucide-react'
import { fetchStatus, type SystemStatus } from '@/lib/api'
import { useWebSocket } from '@/hooks/useWebSocket'
@ -27,6 +29,8 @@ const navItems = [
{ path: '/notifications', label: 'Notifications', icon: BellRing },
{ path: '/reference', label: 'Reference', icon: BookOpen },
{ path: '/adapter-config', label: 'Adapter Config', icon: Sliders },
{ path: '/gauge-sites', label: 'Gauge Sites', icon: Droplets },
{ path: '/town-anchors', label: 'Town Anchors', icon: MapPin },
]
function formatUptime(seconds: number): string {

View file

@ -0,0 +1,198 @@
// v0.6-4 GaugeSites table editor.
import { useEffect, useState, useCallback } from 'react'
import { Loader2, Plus, Trash2, Check, X, Droplets } from 'lucide-react'
interface GaugeSite {
site_id: string
gauge_name: string
lat: number
lon: number
action_ft: number | null
flood_minor_ft: number | null
flood_moderate_ft: number | null
flood_major_ft: number | null
enabled: boolean
updated_at: number
}
const EMPTY_DRAFT: GaugeSite = {
site_id: '', gauge_name: '', lat: 0, lon: 0,
action_ft: null, flood_minor_ft: null, flood_moderate_ft: null, flood_major_ft: null,
enabled: true, updated_at: 0,
}
export default function GaugeSites() {
const [rows, setRows] = useState<GaugeSite[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [editing, setEditing] = useState<string | null>(null)
const [draft, setDraft] = useState<GaugeSite>(EMPTY_DRAFT)
const [adding, setAdding] = useState(false)
const refresh = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/gauge-sites')
if (!res.ok) throw new Error(`GET: ${res.status}`)
setRows(await res.json())
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
}
}, [])
useEffect(() => { refresh() }, [refresh])
const beginEdit = (r: GaugeSite) => { setEditing(r.site_id); setDraft({ ...r }); setAdding(false) }
const beginAdd = () => { setAdding(true); setEditing(null); setDraft({ ...EMPTY_DRAFT }) }
const cancel = () => { setEditing(null); setAdding(false); setDraft(EMPTY_DRAFT) }
const save = async () => {
try {
const url = adding ? '/api/gauge-sites' : `/api/gauge-sites/${editing}`
const method = adding ? 'POST' : 'PUT'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(draft),
})
if (!res.ok) {
const b = await res.json().catch(() => ({}))
alert(`save failed: ${b.detail || res.statusText}`)
return
}
cancel()
refresh()
} catch (e) {
alert(String(e))
}
}
const remove = async (siteId: string) => {
if (!confirm(`Delete ${siteId}?`)) return
const res = await fetch(`/api/gauge-sites/${siteId}`, { method: 'DELETE' })
if (!res.ok) { alert(`delete failed: ${res.status}`); return }
refresh()
}
if (loading) return <div className="p-6 text-slate-400"><Loader2 className="w-5 h-5 animate-spin inline mr-2" />Loading</div>
if (error) return <div className="p-6 text-red-400">Load failed: {error}</div>
return (
<div className="p-6 space-y-4">
<div className="flex items-center gap-2">
<Droplets className="w-5 h-5 text-cyan-400" />
<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">
<Plus className="w-4 h-4" /> Add site
</button>
</div>
<p className="text-xs text-slate-400 max-w-3xl">
NWS-AHPS stream gauge thresholds curated for the nwis_handler. Disabled rows are
ignored at envelope time. Changes propagate to the handler on the next event.
</p>
{adding && <RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} adding />}
<div className="bg-slate-800/60 border border-slate-700 rounded-lg overflow-x-auto">
<table className="w-full text-sm text-slate-200">
<thead className="bg-slate-900 text-xs text-slate-400 uppercase">
<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>
</tr>
</thead>
<tbody className="divide-y divide-slate-700/60">
{rows.map(r => editing === r.site_id ? (
<tr key={r.site_id} className="bg-slate-900/40">
<td colSpan={9} className="px-3 py-2">
<RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} />
</td>
</tr>
) : (
<tr key={r.site_id} className="hover:bg-slate-800/50">
<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>
<td className="px-3 py-2 text-right">{r.action_ft ?? '-'}</td>
<td className="px-3 py-2 text-right">{r.flood_minor_ft ?? '-'}</td>
<td className="px-3 py-2 text-right">{r.flood_moderate_ft ?? '-'}</td>
<td className="px-3 py-2 text-right">{r.flood_major_ft ?? '-'}</td>
<td className="px-3 py-2 text-center">{r.enabled ? <Check className="w-4 h-4 text-emerald-400 inline" /> : <X className="w-4 h-4 text-slate-500 inline" />}</td>
<td className="px-3 py-2 text-right">
<button onClick={() => beginEdit(r)} className="text-cyan-400 hover:text-cyan-300 text-xs mr-3">Edit</button>
<button onClick={() => remove(r.site_id)} className="text-red-400 hover:text-red-300"><Trash2 className="w-4 h-4 inline" /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function RowEditor({ draft, setDraft, onSave, onCancel, adding }: {
draft: GaugeSite, setDraft: (g: GaugeSite) => void,
onSave: () => void, onCancel: () => void, adding?: boolean,
}) {
const upd = (k: keyof GaugeSite, 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">
<label className="text-xs text-slate-400 col-span-2">
Site ID
<input className="block w-full mt-1 bg-slate-800 border border-slate-700 rounded 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} />
</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"
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"
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"
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"
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"
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"
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"
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">
<input type="checkbox" checked={draft.enabled} onChange={e => upd('enabled', e.target.checked)} className="accent-cyan-500" />
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>
</div>
</div>
)
}

View file

@ -0,0 +1,153 @@
// v0.6-4 TownAnchors table editor.
import { useEffect, useState, useCallback } from 'react'
import { Loader2, Plus, Trash2, Check, X, MapPin } from 'lucide-react'
interface TownAnchor {
anchor_id: number
name: string
lat: number
lon: number
state: string | null
enabled: boolean
updated_at: number
}
const EMPTY_DRAFT: TownAnchor = {
anchor_id: 0, name: '', lat: 0, lon: 0, state: 'ID', enabled: true, updated_at: 0,
}
export default function TownAnchors() {
const [rows, setRows] = useState<TownAnchor[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [editing, setEditing] = useState<number | null>(null)
const [adding, setAdding] = useState(false)
const [draft, setDraft] = useState<TownAnchor>(EMPTY_DRAFT)
const refresh = useCallback(async () => {
setLoading(true); setError(null)
try {
const res = await fetch('/api/town-anchors')
if (!res.ok) throw new Error(`GET: ${res.status}`)
setRows(await res.json())
} catch (e) { setError(String(e)) } finally { setLoading(false) }
}, [])
useEffect(() => { refresh() }, [refresh])
const beginEdit = (r: TownAnchor) => { setEditing(r.anchor_id); setDraft({ ...r }); setAdding(false) }
const beginAdd = () => { setAdding(true); setEditing(null); setDraft({ ...EMPTY_DRAFT }) }
const cancel = () => { setEditing(null); setAdding(false); setDraft(EMPTY_DRAFT) }
const save = async () => {
const url = adding ? '/api/town-anchors' : `/api/town-anchors/${editing}`
const method = adding ? 'POST' : 'PUT'
const res = await fetch(url, {
method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(draft),
})
if (!res.ok) {
const b = await res.json().catch(() => ({}))
alert(`save failed: ${b.detail || res.statusText}`); return
}
cancel(); refresh()
}
const remove = async (id: number) => {
if (!confirm(`Delete anchor ${id}?`)) return
const res = await fetch(`/api/town-anchors/${id}`, { method: 'DELETE' })
if (!res.ok) { alert(`delete failed: ${res.status}`); return }
refresh()
}
if (loading) return <div className="p-6 text-slate-400"><Loader2 className="w-5 h-5 animate-spin inline mr-2" />Loading</div>
if (error) return <div className="p-6 text-red-400">Load failed: {error}</div>
return (
<div className="p-6 space-y-4">
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-cyan-400" />
<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">
<Plus className="w-4 h-4" /> Add town
</button>
</div>
<p className="text-xs text-slate-400 max-w-3xl">
Lookup table for the &quot;X mi &lt;bearing&gt; of &lt;town&gt;&quot; anchor in wire-string rendering.
Disabled rows fall through to the generic anchor chain.
</p>
{adding && <RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} adding />}
<div className="bg-slate-800/60 border border-slate-700 rounded-lg overflow-x-auto">
<table className="w-full text-sm text-slate-200">
<thead className="bg-slate-900 text-xs text-slate-400 uppercase">
<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>
</tr>
</thead>
<tbody className="divide-y divide-slate-700/60">
{rows.map(r => editing === r.anchor_id ? (
<tr key={r.anchor_id} className="bg-slate-900/40">
<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">
<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>
<td className="px-3 py-2 text-center text-xs">{r.state || '-'}</td>
<td className="px-3 py-2 text-center">{r.enabled ? <Check className="w-4 h-4 text-emerald-400 inline" /> : <X className="w-4 h-4 text-slate-500 inline" />}</td>
<td className="px-3 py-2 text-right">
<button onClick={() => beginEdit(r)} className="text-cyan-400 hover:text-cyan-300 text-xs mr-3">Edit</button>
<button onClick={() => remove(r.anchor_id)} className="text-red-400 hover:text-red-300"><Trash2 className="w-4 h-4 inline" /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function RowEditor({ draft, setDraft, onSave, onCancel, adding }: {
draft: TownAnchor, setDraft: (t: TownAnchor) => void,
onSave: () => void, onCancel: () => void, adding?: boolean,
}) {
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">
<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"
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"
value={draft.state ?? ''} onChange={e => upd('state', e.target.value)} />
</label>
<label className="text-xs text-slate-400 flex items-center gap-2">
<input type="checkbox" checked={draft.enabled} onChange={e => upd('enabled', e.target.checked)} className="accent-cyan-500 mt-4" />
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"
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"
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>
</div>
</div>
)
}