From b0d1a76e700a342f1acb43d4ab96670ff93234f8 Mon Sep 17 00:00:00 2001 From: malice Date: Fri, 17 Jul 2026 14:07:09 -0600 Subject: [PATCH] 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 --- .../src/pages/GaugeSites.tsx | 194 +++++++++++++++++- work/meshai/dashboard/server.py | 2 + work/tests/test_tail_followups.py | 36 ++++ 3 files changed, 225 insertions(+), 7 deletions(-) diff --git a/work/dashboard-frontend/src/pages/GaugeSites.tsx b/work/dashboard-frontend/src/pages/GaugeSites.tsx index 6bb8b6f..93abe48 100644 --- a/work/dashboard-frontend/src/pages/GaugeSites.tsx +++ b/work/dashboard-frontend/src/pages/GaugeSites.tsx @@ -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([]) 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('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() {

Gauge Sites

{rows.length} sites - +
+ + +

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.

+ {importOpen && setImportOpen(false)} />} + {adding && }
@@ -259,3 +281,161 @@ function RowEditor({ draft, setDraft, onSave, onCancel, adding, feedSource }: {
) } + + +// 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('csv') + const [csvText, setCsvText] = useState('') + const [wfoText, setWfoText] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [result, setResult] = useState(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 ( +
+
+
+ + +
+ +
+ + {mode === 'csv' ? ( +
+

+ Header row required. Required columns: site_id, gauge_name, lat, lon. + {' '}Optional: action_ft, flood_minor_ft, flood_moderate_ft, flood_major_ft, enabled. + Rows missing site_id or gauge_name are skipped; existing site_ids are updated in place. +

+