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.
This commit is contained in:
Matt Johnson 2026-07-16 22:53:04 +00:00
commit 202ed5795f

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>
)
}