feat(dashboard): make the gauge-sites bulk import reachable (#149)

* fix(dashboard): register gauge_sites_import router

The gauge-sites bulk-import endpoint (CSV / NWS-AHPS) was fully built and
tested but never wired into the app -- server.py never called
include_router() for it, so POST /api/gauge-sites/import 404'd in
production. Only test_tail_followups.py exercised it, via a raw
TestClient bypassing the real app.

* test(dashboard): assert gauge_sites import route is reachable via real app

Guards against the router silently going unregistered again: builds the
actual dashboard app via create_app() and drives a real CSV import
through it end-to-end (POST + GET round-trip), instead of only hitting
the router in isolation.

* feat(dashboard): add gauge-sites bulk import UI

Wires a UI onto the now-registered POST /api/gauge-sites/import endpoint,
inside the existing GaugeSites tab (no new nav entries/pages). Adds an
Import toggle next to Add site, with CSV (paste or file-load into a
textarea) and NWS-AHPS (WFO code list) modes. Required/optional CSV
columns are documented inline so an operator isn't guessing. Result
counts (inserted/updated/skipped/detail_fetched) and any partial-failure
errors from the AHPS scrape are always shown; a pending spinner covers
the AHPS path's live water.weather.gov calls so a slow response never
reads as "imported 0 sites". The list refreshes only after a successful
import that actually changed rows.

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
This commit is contained in:
malice 2026-07-17 14:07:09 -06:00 committed by GitHub
commit b0d1a76e70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 225 additions and 7 deletions

View file

@ -1,6 +1,6 @@
// v0.6-4 GaugeSites table editor.
import { useEffect, useState, useCallback } from 'react'
import { Loader2, Plus, Trash2, Check, X, Droplets, Search } from 'lucide-react'
import { Loader2, Plus, Trash2, Check, X, Droplets, Search, Upload, AlertTriangle } from 'lucide-react'
interface GaugeSite {
site_id: string
@ -21,6 +21,18 @@ const EMPTY_DRAFT: GaugeSite = {
enabled: true, updated_at: 0,
}
// v0.6-tail item 3 UI: bulk-import panel result shape, matching the
// /api/gauge-sites/import response (see dashboard/api/gauge_sites_import.py).
interface ImportResult {
inserted: number
updated: number
skipped: number
detail_fetched?: number
errors?: string[]
}
type ImportMode = 'csv' | 'nws-ahps'
export default function GaugeSites() {
const [rows, setRows] = useState<GaugeSite[]>([])
const [loading, setLoading] = useState(true)
@ -30,6 +42,7 @@ export default function GaugeSites() {
const [adding, setAdding] = useState(false)
// v0.6-tail-3: USGS lookup is only available when usgs.feed_source==='native'.
const [feedSource, setFeedSource] = useState<string>('unknown')
const [importOpen, setImportOpen] = useState(false)
const refresh = useCallback(async () => {
setLoading(true)
@ -54,9 +67,10 @@ export default function GaugeSites() {
.catch(() => setFeedSource('unknown'))
}, [])
const beginEdit = (r: GaugeSite) => { setEditing(r.site_id); setDraft({ ...r }); setAdding(false) }
const beginAdd = () => { setAdding(true); setEditing(null); setDraft({ ...EMPTY_DRAFT }) }
const beginEdit = (r: GaugeSite) => { setEditing(r.site_id); setDraft({ ...r }); setAdding(false); setImportOpen(false) }
const beginAdd = () => { setAdding(true); setEditing(null); setDraft({ ...EMPTY_DRAFT }); setImportOpen(false) }
const cancel = () => { setEditing(null); setAdding(false); setDraft(EMPTY_DRAFT) }
const toggleImport = () => { setImportOpen(v => !v); setAdding(false); setEditing(null) }
const save = async () => {
try {
@ -95,15 +109,23 @@ export default function GaugeSites() {
<Droplets className="w-5 h-5 text-accent" />
<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>
<div className="ml-auto flex items-center gap-2">
<button onClick={toggleImport}
className="flex items-center gap-1 px-3 py-1 bg-bg-hover hover:bg-[#333] border border-border text-slate-100 font-sans font-medium text-sm">
<Upload className="w-4 h-4" /> Import
</button>
<button onClick={beginAdd}
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">
className="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>
</div>
<p className="text-xs text-slate-400 max-w-3xl">
NWS-AHPS stream gauge thresholds for the USGS NWIS handler. Each row pairs a USGS site_id with a human gauge name, lat/lon, and four flood thresholds (Action / Minor / Moderate / Major, all in feet). Disabled rows still ingest into gauge_readings -- they don't broadcast. The USGS lookup button auto-populates name + coords + thresholds from USGS Site Service + NWS NWPS when this adapter is on native feed_source; Central-feed mode disables it (see Reference OR-not-AND for why). Changes take effect on the next event.
</p>
{importOpen && <ImportPanel onImported={refresh} onClose={() => setImportOpen(false)} />}
{adding && <RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} adding feedSource={feedSource} />}
<div className="bg-bg-card border border-border overflow-x-auto">
@ -259,3 +281,161 @@ function RowEditor({ draft, setDraft, onSave, onCancel, adding, feedSource }: {
</div>
)
}
// v0.6-tail item 3 UI: bulk import (CSV paste/upload or NWS-AHPS scrape)
// against POST /api/gauge-sites/import. A failed or empty import must never
// look like a no-op success -- errors and zero-count results are both
// surfaced explicitly, not swallowed.
function ImportPanel({ onImported, onClose }: { onImported: () => void, onClose: () => void }) {
const [mode, setMode] = useState<ImportMode>('csv')
const [csvText, setCsvText] = useState('')
const [wfoText, setWfoText] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<ImportResult | null>(null)
const switchMode = (m: ImportMode) => { setMode(m); setResult(null); setError(null) }
const runImport = async () => {
setError(null)
setResult(null)
const wfos = wfoText.split(/[,\s]+/).map(s => s.trim()).filter(Boolean)
if (mode === 'csv' && !csvText.trim()) {
setError('Paste CSV text or load a file first')
return
}
if (mode === 'nws-ahps' && wfos.length === 0) {
setError('Enter at least one WFO code')
return
}
setBusy(true)
try {
const body = mode === 'csv'
? { format: 'csv', data: csvText }
: { format: 'nws-ahps', wfo: wfos }
const res = await fetch('/api/gauge-sites/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const parsed = await res.json().catch(() => null)
if (!res.ok) {
setError((parsed && parsed.detail) || `Import failed (${res.status})`)
return
}
const r: ImportResult = parsed
setResult(r)
if (r.inserted > 0 || r.updated > 0) onImported()
} catch (e) {
// Network error / timeout talking to our own API, or (nws-ahps) the
// upstream water.weather.gov fetch inside the request blowing up
// before it could return a partial result. Never let this read as
// "imported 0 sites" -- it's a failure, not a no-op.
setError(`Import request failed: ${String(e)}`)
} finally {
setBusy(false)
}
}
return (
<div className="bg-[#1a1a1a] border border-border p-3 space-y-3">
<div className="flex items-center gap-2">
<div className="flex gap-1">
<button type="button" onClick={() => switchMode('csv')}
className={`px-3 py-1 text-xs font-sans font-medium border ${mode === 'csv' ? 'bg-[#f59e0b] text-black border-[#f59e0b]' : 'bg-bg-hover text-slate-300 border-border hover:bg-[#333]'}`}>
CSV
</button>
<button type="button" onClick={() => switchMode('nws-ahps')}
className={`px-3 py-1 text-xs font-sans font-medium border ${mode === 'nws-ahps' ? 'bg-[#f59e0b] text-black border-[#f59e0b]' : 'bg-bg-hover text-slate-300 border-border hover:bg-[#333]'}`}>
NWS-AHPS
</button>
</div>
<button type="button" onClick={onClose} className="ml-auto text-slate-400 hover:text-slate-200 text-xs">Close</button>
</div>
{mode === 'csv' ? (
<div className="space-y-2">
<p className="text-xs text-slate-400">
Header row required. Required columns: <code className="text-slate-300">site_id, gauge_name, lat, lon</code>.
{' '}Optional: <code className="text-slate-300">action_ft, flood_minor_ft, flood_moderate_ft, flood_major_ft, enabled</code>.
Rows missing site_id or gauge_name are skipped; existing site_ids are updated in place.
</p>
<textarea
className="block w-full h-40 bg-bg border border-border px-2 py-1 text-slate-100 font-mono text-xs"
placeholder="site_id,gauge_name,lat,lon,action_ft,flood_minor_ft,flood_moderate_ft,flood_major_ft,enabled"
value={csvText}
onChange={e => setCsvText(e.target.value)}
/>
<label className="inline-flex items-center gap-2 text-xs text-slate-400 cursor-pointer">
<span className="px-2 py-1 bg-bg-hover hover:bg-[#333] text-slate-100">Load from file</span>
<input type="file" accept=".csv,text/csv" className="hidden" onChange={e => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => setCsvText(String(reader.result ?? ''))
reader.onerror = () => setError(`Failed to read file: ${reader.error?.message ?? 'unknown error'}`)
reader.readAsText(file)
e.target.value = ''
}} />
</label>
</div>
) : (
<div className="space-y-2">
<p className="text-xs text-slate-400">
One or more NWS Weather Forecast Office (WFO) codes, comma- or space-separated.
Fetches each office's public AHPS gauge index and scrapes gauge name, coordinates,
and flood thresholds. This is a live call to water.weather.gov made when you click
Run import -- it can take a while and individual gauges are skipped (not fatal) if a
detail page doesn't parse.
</p>
<input
type="text"
className="block w-full bg-bg border border-border px-2 py-1 text-slate-100 font-mono text-xs"
placeholder="e.g. XXX, YYY"
value={wfoText}
onChange={e => setWfoText(e.target.value)}
/>
</div>
)}
{error && (
<div className="flex items-start gap-2 text-xs text-red-400 bg-red-500/10 border border-red-500/40 px-2 py-1.5">
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{result && (
<div className="text-xs text-slate-300 bg-bg border border-border px-2 py-1.5 space-y-1">
<div className="flex flex-wrap gap-x-4 gap-y-1">
<span>Inserted: <span className="text-emerald-400">{result.inserted}</span></span>
<span>Updated: <span className="text-sky-400">{result.updated}</span></span>
<span>Skipped: <span className="text-slate-400">{result.skipped}</span></span>
{typeof result.detail_fetched === 'number' && (
<span>Detail pages fetched: <span className="text-slate-400">{result.detail_fetched}</span></span>
)}
</div>
{result.errors && result.errors.length > 0 && (
<ul className="list-disc list-inside text-amber-400 space-y-0.5">
{result.errors.map((e, i) => <li key={i}>{e}</li>)}
</ul>
)}
{result.inserted === 0 && result.updated === 0 && (!result.errors || result.errors.length === 0) && (
<div className="text-amber-400">Nothing was imported -- check the input above.</div>
)}
</div>
)}
<div className="flex items-center justify-end">
<button type="button" onClick={runImport} disabled={busy}
className="flex items-center gap-1 px-3 py-1 bg-[#f59e0b] hover:bg-[#d97706] disabled:opacity-40 disabled:cursor-not-allowed text-black font-sans font-medium text-sm">
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
{busy ? 'Importing…' : 'Run import'}
</button>
</div>
</div>
)
}

View file

@ -60,11 +60,13 @@ def create_app() -> FastAPI:
from .api.notification_routes import router as notification_router
from .api.debug_routes import router as debug_router
from .api.serial_ports_routes import router as serial_ports_router
from .api.gauge_sites_import import router as gauge_sites_import_router
app.include_router(system_router, prefix="/api")
app.include_router(serial_ports_router, prefix="/api")
app.include_router(adapter_config_router, prefix="/api")
app.include_router(curation_router, prefix="/api")
app.include_router(gauge_sites_import_router, prefix="/api")
app.include_router(config_router, prefix="/api")
app.include_router(generic_sources_router, prefix="/api")
app.include_router(mesh_router, prefix="/api")

View file

@ -230,6 +230,42 @@ def test_ahps_detail_extracts_thresholds():
assert parsed["flood_major_ft"] == 14.5
# ============================================================================
# Item 3b -- gauge_sites import route is registered on the real app
#
# The endpoint logic above is fully covered, but until server.create_app()
# calls app.include_router(gauge_sites_import_router, ...) the route is
# unreachable in production -- the raw-router `client` fixture above can't
# catch that because it builds its own bare FastAPI app. This test drives
# the actual dashboard app the server process serves.
# ============================================================================
def test_gauge_sites_import_reachable_through_real_app():
from meshai.dashboard.server import create_app
app = create_app()
real_client = TestClient(app)
res = real_client.post(
"/api/gauge-sites/import",
json={
"format": "csv",
"data": "site_id,gauge_name,lat,lon\nUSGS-REAL1,Real App Creek,43.5,-114.5\n",
},
)
assert res.status_code == 200, res.text
body = res.json()
assert body["inserted"] == 1
assert body["updated"] == 0
# And it actually landed -- round-trip through the sibling curation
# router (also mounted on the real app) to prove the 200 reflects a
# real DB write through the real app, not a false positive.
listed = real_client.get("/api/gauge-sites").json()
assert any(r["site_id"] == "USGS-REAL1" for r in listed)
# ============================================================================
# Item 4 -- WFIGS tombstone column + reminder behavior
# ============================================================================