feat(env): USGS stream gauges, TomTom traffic, 511 road conditions

This commit is contained in:
K7ZVX 2026-05-12 22:22:57 +00:00
commit f8bf7e5057
16 changed files with 2542 additions and 1183 deletions

View file

@ -1,288 +1,360 @@
// API types matching actual backend responses
export interface SystemStatus {
version: string
uptime_seconds: number
bot_name: string
connection_type: string
connection_target: string
connected: boolean
node_count: number
source_count: number
env_feeds_enabled: boolean
dashboard_port: number
}
export interface MeshHealth {
score: number
tier: string
pillars: {
infrastructure: number
utilization: number
behavior: number
power: number
}
infra_online: number
infra_total: number
util_percent: number
flagged_nodes: number
battery_warnings: number
total_nodes: number
total_regions: number
unlocated_count: number
last_computed: string
recommendations: string[]
}
export interface NodeInfo {
node_num: number
node_id_hex: string
short_name: string
long_name: string
role: string
latitude: number | null
longitude: number | null
last_heard: string | null
battery_level: number | null
voltage: number | null
snr: number | null
firmware: string
hardware: string
uptime: number | null
sources: string[]
}
export interface EdgeInfo {
from_node: number
to_node: number
snr: number
quality: string
}
export interface RegionInfo {
name: string
local_name: string
node_count: number
infra_count: number
infra_online: number
online_count: number
score: number
tier: string
center_lat: number
center_lon: number
}
export interface SourceHealth {
name: string
type: string
url: string
is_loaded: boolean
last_error: string | null
consecutive_errors: number
response_time_ms: number | null
tick_count: number
node_count: number
}
export interface Alert {
type: string
severity: string
message: string
timestamp: string
scope_type?: string
scope_value?: string
}
export interface EnvStatus {
enabled: boolean
feeds: EnvFeedHealth[]
}
export interface EnvFeedHealth {
source: string
is_loaded: boolean
last_error: string | null
consecutive_errors: number
event_count: number
last_fetch: number
}
export interface EnvEvent {
source: string
event_id: string
event_type: string
severity: string
headline: string
description?: string
expires?: number
fetched_at: number
[key: string]: unknown
}
export interface SWPCStatus {
enabled: boolean
kp_current?: number
kp_timestamp?: string
sfi?: number
r_scale?: number
s_scale?: number
g_scale?: number
active_warnings?: string[]
}
export interface DuctingStatus {
enabled: boolean
condition?: string
min_gradient?: number
duct_thickness_m?: number | null
duct_base_m?: number | null
last_update?: string
}
export interface RFPropagation {
hf: {
kp_current?: number
sfi?: number
r_scale?: number
s_scale?: number
g_scale?: number
active_warnings?: string[]
}
uhf_ducting: {
condition?: string
min_gradient?: number
duct_thickness_m?: number | null
}
}
// API fetch helpers
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
export async function fetchStatus(): Promise<SystemStatus> {
return fetchJson<SystemStatus>('/api/status')
}
export async function fetchHealth(): Promise<MeshHealth> {
return fetchJson<MeshHealth>('/api/health')
}
export async function fetchNodes(): Promise<NodeInfo[]> {
return fetchJson<NodeInfo[]>('/api/nodes')
}
export async function fetchEdges(): Promise<EdgeInfo[]> {
return fetchJson<EdgeInfo[]>('/api/edges')
}
export async function fetchSources(): Promise<SourceHealth[]> {
return fetchJson<SourceHealth[]>('/api/sources')
}
export async function fetchConfig(section?: string): Promise<unknown> {
const url = section ? `/api/config/${section}` : '/api/config'
return fetchJson(url)
}
export async function updateConfig(
section: string,
data: unknown
): Promise<{ saved: boolean; restart_required: boolean }> {
const response = await fetch(`/api/config/${section}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
export async function fetchAlerts(): Promise<Alert[]> {
return fetchJson<Alert[]>('/api/alerts/active')
}
export async function fetchEnvStatus(): Promise<EnvStatus> {
return fetchJson<EnvStatus>('/api/env/status')
}
export async function fetchEnvActive(): Promise<EnvEvent[]> {
return fetchJson<EnvEvent[]>('/api/env/active')
}
export async function fetchRFPropagation(): Promise<RFPropagation> {
return fetchJson<RFPropagation>('/api/env/propagation')
}
export async function fetchSWPC(): Promise<SWPCStatus> {
return fetchJson<SWPCStatus>('/api/env/swpc')
}
export async function fetchDucting(): Promise<DuctingStatus> {
return fetchJson<DuctingStatus>('/api/env/ducting')
}
export interface FireEvent {
source: string
event_id: string
event_type: string
severity: string
headline: string
name: string
acres: number
pct_contained: number
lat: number | null
lon: number | null
distance_km: number | null
nearest_anchor: string | null
state: string
expires: number
fetched_at: number
polygon?: number[][][]
}
export interface AvalancheEvent {
source: string
event_id: string
event_type: string
severity: string
headline: string
zone_name: string
center: string
center_id: string
center_link: string
forecast_link: string
danger: string
danger_level: number
danger_name: string
travel_advice: string
state: string
lat: number | null
lon: number | null
expires: number
fetched_at: number
}
export interface AvalancheResponse {
off_season: boolean
advisories: AvalancheEvent[]
}
export async function fetchFires(): Promise<FireEvent[]> {
return fetchJson<FireEvent[]>('/api/env/fires')
}
export async function fetchAvalanche(): Promise<AvalancheResponse> {
return fetchJson<AvalancheResponse>('/api/env/avalanche')
}
export async function fetchRegions(): Promise<RegionInfo[]> {
return fetchJson<RegionInfo[]>('/api/regions')
}
// API types matching actual backend responses
export interface SystemStatus {
version: string
uptime_seconds: number
bot_name: string
connection_type: string
connection_target: string
connected: boolean
node_count: number
source_count: number
env_feeds_enabled: boolean
dashboard_port: number
}
export interface MeshHealth {
score: number
tier: string
pillars: {
infrastructure: number
utilization: number
behavior: number
power: number
}
infra_online: number
infra_total: number
util_percent: number
flagged_nodes: number
battery_warnings: number
total_nodes: number
total_regions: number
unlocated_count: number
last_computed: string
recommendations: string[]
}
export interface NodeInfo {
node_num: number
node_id_hex: string
short_name: string
long_name: string
role: string
latitude: number | null
longitude: number | null
last_heard: string | null
battery_level: number | null
voltage: number | null
snr: number | null
firmware: string
hardware: string
uptime: number | null
sources: string[]
}
export interface EdgeInfo {
from_node: number
to_node: number
snr: number
quality: string
}
export interface RegionInfo {
name: string
local_name: string
node_count: number
infra_count: number
infra_online: number
online_count: number
score: number
tier: string
center_lat: number
center_lon: number
}
export interface SourceHealth {
name: string
type: string
url: string
is_loaded: boolean
last_error: string | null
consecutive_errors: number
response_time_ms: number | null
tick_count: number
node_count: number
}
export interface Alert {
type: string
severity: string
message: string
timestamp: string
scope_type?: string
scope_value?: string
}
export interface EnvStatus {
enabled: boolean
feeds: EnvFeedHealth[]
}
export interface EnvFeedHealth {
source: string
is_loaded: boolean
last_error: string | null
consecutive_errors: number
event_count: number
last_fetch: number
}
export interface EnvEvent {
source: string
event_id: string
event_type: string
severity: string
headline: string
description?: string
expires?: number
fetched_at: number
[key: string]: unknown
}
export interface SWPCStatus {
enabled: boolean
kp_current?: number
kp_timestamp?: string
sfi?: number
r_scale?: number
s_scale?: number
g_scale?: number
active_warnings?: string[]
}
export interface DuctingStatus {
enabled: boolean
condition?: string
min_gradient?: number
duct_thickness_m?: number | null
duct_base_m?: number | null
last_update?: string
}
export interface RFPropagation {
hf: {
kp_current?: number
sfi?: number
r_scale?: number
s_scale?: number
g_scale?: number
active_warnings?: string[]
}
uhf_ducting: {
condition?: string
min_gradient?: number
duct_thickness_m?: number | null
}
}
// API fetch helpers
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
export async function fetchStatus(): Promise<SystemStatus> {
return fetchJson<SystemStatus>('/api/status')
}
export async function fetchHealth(): Promise<MeshHealth> {
return fetchJson<MeshHealth>('/api/health')
}
export async function fetchNodes(): Promise<NodeInfo[]> {
return fetchJson<NodeInfo[]>('/api/nodes')
}
export async function fetchEdges(): Promise<EdgeInfo[]> {
return fetchJson<EdgeInfo[]>('/api/edges')
}
export async function fetchSources(): Promise<SourceHealth[]> {
return fetchJson<SourceHealth[]>('/api/sources')
}
export async function fetchConfig(section?: string): Promise<unknown> {
const url = section ? `/api/config/${section}` : '/api/config'
return fetchJson(url)
}
export async function updateConfig(
section: string,
data: unknown
): Promise<{ saved: boolean; restart_required: boolean }> {
const response = await fetch(`/api/config/${section}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
export async function fetchAlerts(): Promise<Alert[]> {
return fetchJson<Alert[]>('/api/alerts/active')
}
export async function fetchEnvStatus(): Promise<EnvStatus> {
return fetchJson<EnvStatus>('/api/env/status')
}
export async function fetchEnvActive(): Promise<EnvEvent[]> {
return fetchJson<EnvEvent[]>('/api/env/active')
}
export async function fetchRFPropagation(): Promise<RFPropagation> {
return fetchJson<RFPropagation>('/api/env/propagation')
}
export async function fetchSWPC(): Promise<SWPCStatus> {
return fetchJson<SWPCStatus>('/api/env/swpc')
}
export async function fetchDucting(): Promise<DuctingStatus> {
return fetchJson<DuctingStatus>('/api/env/ducting')
}
export interface FireEvent {
source: string
event_id: string
event_type: string
severity: string
headline: string
name: string
acres: number
pct_contained: number
lat: number | null
lon: number | null
distance_km: number | null
nearest_anchor: string | null
state: string
expires: number
fetched_at: number
polygon?: number[][][]
}
export interface AvalancheEvent {
source: string
event_id: string
event_type: string
severity: string
headline: string
zone_name: string
center: string
center_id: string
center_link: string
forecast_link: string
danger: string
danger_level: number
danger_name: string
travel_advice: string
state: string
lat: number | null
lon: number | null
expires: number
fetched_at: number
}
export interface StreamGaugeEvent {
source: string
event_id: string
event_type: string
headline: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
site_id: string
site_name: string
parameter: string
value: number
unit: string
timestamp: string
}
}
export interface TrafficEvent {
source: string
event_id: string
event_type: string
headline: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
corridor: string
currentSpeed: number
freeFlowSpeed: number
speedRatio: number
currentTravelTime: number
freeFlowTravelTime: number
confidence: number
roadClosure: boolean
}
}
export interface RoadEvent {
source: string
event_id: string
event_type: string
headline: string
description?: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
roadway: string
is_closure: boolean
last_updated?: string
}
}
export interface AvalancheResponse {
off_season: boolean
advisories: AvalancheEvent[]
}
export async function fetchFires(): Promise<FireEvent[]> {
return fetchJson<FireEvent[]>('/api/env/fires')
}
export async function fetchAvalanche(): Promise<AvalancheResponse> {
return fetchJson<AvalancheResponse>('/api/env/avalanche')
}
export async function fetchStreams(): Promise<StreamGaugeEvent[]> {
return fetchJson<StreamGaugeEvent[]>('/api/env/streams')
}
export async function fetchTraffic(): Promise<TrafficEvent[]> {
return fetchJson<TrafficEvent[]>('/api/env/traffic')
}
export async function fetchRoads(): Promise<RoadEvent[]> {
return fetchJson<RoadEvent[]>('/api/env/roads')
}
export async function fetchRegions(): Promise<RegionInfo[]> {
return fetchJson<RegionInfo[]>('/api/regions')
}

View file

@ -10,6 +10,8 @@ import {
Wind,
Flame,
Mountain,
Droplets,
Car,
} from 'lucide-react'
import {
fetchEnvStatus,
@ -18,12 +20,18 @@ import {
fetchDucting,
fetchFires,
fetchAvalanche,
fetchStreams,
fetchTraffic,
fetchRoads,
type EnvStatus,
type EnvEvent,
type SWPCStatus,
type DuctingStatus,
type FireEvent,
type AvalancheResponse,
type StreamGaugeEvent,
type TrafficEvent,
type RoadEvent,
} from '@/lib/api'
function FeedStatusCard({ feed }: { feed: { source: string; is_loaded: boolean; last_error: string | null; consecutive_errors: number; event_count: number; last_fetch: number } }) {
@ -348,6 +356,9 @@ export default function Environment() {
const [ducting, setDucting] = useState<DuctingStatus | null>(null)
const [fires, setFires] = useState<FireEvent[]>([])
const [avalanche, setAvalanche] = useState<AvalancheResponse | null>(null)
const [streams, setStreams] = useState<StreamGaugeEvent[]>([])
const [traffic, setTraffic] = useState<TrafficEvent[]>([])
const [roads, setRoads] = useState<RoadEvent[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@ -359,14 +370,20 @@ export default function Environment() {
fetchDucting().catch(() => null),
fetchFires().catch(() => []),
fetchAvalanche().catch(() => null),
fetchStreams().catch(() => []),
fetchTraffic().catch(() => []),
fetchRoads().catch(() => []),
])
.then(([status, active, swpcData, ductingData, firesData, avyData]) => {
.then(([status, active, swpcData, ductingData, firesData, avyData, streamsData, trafficData, roadsData]) => {
setEnvStatus(status)
setEvents(active)
setSWPC(swpcData)
setDucting(ductingData)
setFires(firesData)
setAvalanche(avyData)
setStreams(streamsData || [])
setTraffic(trafficData || [])
setRoads(roadsData || [])
setLoading(false)
})
.catch((err) => {
@ -563,6 +580,116 @@ export default function Environment() {
</div>
</div>
{/* Stream Gauges */}
{streams.length > 0 && (
<div className="bg-bg-card border border-border rounded-lg p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<Droplets size={14} />
Stream Gauges ({streams.length})
</h2>
<div className="space-y-2">
{streams.map((stream) => (
<div
key={stream.event_id}
className={`p-3 rounded-lg ${
stream.severity === 'warning'
? 'bg-amber-500/10 border-l-2 border-amber-500'
: 'bg-blue-500/10 border-l-2 border-blue-500'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-200">
{stream.properties?.site_name || 'Unknown Site'}
</span>
<span className="text-sm font-mono text-slate-300">
{stream.properties?.value?.toLocaleString()} {stream.properties?.unit}
</span>
</div>
<div className="text-xs text-slate-500 mt-1">
{stream.properties?.parameter}
</div>
</div>
))}
</div>
</div>
)}
{/* Road Conditions */}
{(traffic.length > 0 || roads.length > 0) && (
<div className="bg-bg-card border border-border rounded-lg p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<Car size={14} />
Road Conditions
</h2>
{traffic.length > 0 && (
<div className="mb-4">
<div className="text-xs text-slate-500 mb-2 uppercase">Traffic Flow</div>
<div className="space-y-2">
{traffic.map((t) => (
<div
key={t.event_id}
className={`p-3 rounded-lg ${
t.properties?.roadClosure
? 'bg-red-500/10 border-l-2 border-red-500'
: t.properties?.speedRatio < 0.5
? 'bg-amber-500/10 border-l-2 border-amber-500'
: t.properties?.speedRatio < 0.8
? 'bg-yellow-500/10 border-l-2 border-yellow-500'
: 'bg-green-500/10 border-l-2 border-green-500'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-200">
{t.properties?.corridor || 'Unknown'}
</span>
<span className="text-sm font-mono text-slate-300">
{t.properties?.roadClosure ? 'CLOSED' : `${Math.round(t.properties?.currentSpeed || 0)}mph`}
</span>
</div>
{!t.properties?.roadClosure && (
<div className="text-xs text-slate-500 mt-1">
{Math.round((t.properties?.speedRatio || 1) * 100)}% of free flow ({Math.round(t.properties?.freeFlowSpeed || 0)}mph)
</div>
)}
</div>
))}
</div>
</div>
)}
{roads.length > 0 && (
<div>
<div className="text-xs text-slate-500 mb-2 uppercase">Road Events</div>
<div className="space-y-2">
{roads.map((r) => (
<div
key={r.event_id}
className={`p-3 rounded-lg ${
r.properties?.is_closure
? 'bg-red-500/10 border-l-2 border-red-500'
: 'bg-amber-500/10 border-l-2 border-amber-500'
}`}
>
<div className="flex items-center gap-2">
{r.properties?.is_closure && (
<span className="text-xs px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">
CLOSURE
</span>
)}
<span className="text-sm text-slate-200 line-clamp-1">
{r.headline}
</span>
</div>
<div className="text-xs text-slate-500 mt-1 uppercase">
{r.event_type}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Active Events */}
<div className="bg-bg-card border border-border rounded-lg p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">