mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
5288089768
commit
202ed5795f
1 changed files with 187 additions and 7 deletions
|
|
@ -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>
|
||||
<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">
|
||||
<Plus className="w-4 h-4" /> Add site
|
||||
</button>
|
||||
<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="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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue