mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-10 17:04:45 +02:00
feat: dashboard — DB-backed live feed, active alerts, band conditions panel (replace env_store dependency)
- /api/env/active: direct DB queries for fires, nws_alerts, quake_events instead of env_store.get_active() (which depends on live NATS data) - /api/env/swpc: reads band_conditions_broadcasts table, returns ratings with slot label (Day/Night Propagation) derived from Mountain Time - Frontend: replace RFPropagationCard (SFI/Kp/R/S/G charts) with BandConditionsCard showing 4-band Good/Fair/Poor ratings - Remove unused recharts dependency from Dashboard.tsx Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
82567c6a90
commit
5d8a277fa7
8 changed files with 663 additions and 852 deletions
|
|
@ -179,19 +179,22 @@ export interface GradientEntry {
|
|||
gradient: number
|
||||
}
|
||||
|
||||
export interface SWPCStatus {
|
||||
export interface BandConditionsStatus {
|
||||
enabled: boolean
|
||||
kp_current?: number
|
||||
kp_timestamp?: string
|
||||
sfi?: number
|
||||
r_scale?: number
|
||||
s_scale?: number
|
||||
g_scale?: number
|
||||
active_warnings?: string[]
|
||||
kp_history?: KpHistoryEntry[]
|
||||
sfi_history?: SfiHistoryEntry[]
|
||||
ratings?: {
|
||||
"80-40m"?: string
|
||||
"30-20m"?: string
|
||||
"17-15m"?: string
|
||||
"12-10m"?: string
|
||||
}
|
||||
slot_label?: string
|
||||
sent_at?: number
|
||||
source?: string
|
||||
}
|
||||
|
||||
// Kept for backward compat references
|
||||
export type SWPCStatus = BandConditionsStatus
|
||||
|
||||
export interface DuctingStatus {
|
||||
enabled: boolean
|
||||
condition?: string
|
||||
|
|
@ -307,8 +310,8 @@ 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 fetchSWPC(): Promise<BandConditionsStatus> {
|
||||
return fetchJson<BandConditionsStatus>('/api/env/swpc')
|
||||
}
|
||||
|
||||
export async function fetchDucting(): Promise<DuctingStatus> {
|
||||
|
|
|
|||
|
|
@ -6,14 +6,12 @@ import {
|
|||
fetchEnvStatus,
|
||||
fetchEnvActive,
|
||||
fetchSWPC,
|
||||
fetchDucting,
|
||||
type MeshHealth,
|
||||
type SourceHealth,
|
||||
type Alert,
|
||||
type EnvStatus,
|
||||
type EnvEvent,
|
||||
type SWPCStatus,
|
||||
type DuctingStatus,
|
||||
type BandConditionsStatus,
|
||||
} from '@/lib/api'
|
||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||
import {
|
||||
|
|
@ -35,49 +33,9 @@ import {
|
|||
Satellite,
|
||||
Sun,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
LineChart,
|
||||
Line,
|
||||
} from 'recharts'
|
||||
|
||||
// Extended types for history data
|
||||
interface KpHistoryEntry {
|
||||
time: string
|
||||
value: number
|
||||
}
|
||||
|
||||
interface ProfileEntry {
|
||||
level_hPa: number
|
||||
height_m: number
|
||||
N: number
|
||||
M: number
|
||||
T_C: number
|
||||
RH: number
|
||||
}
|
||||
|
||||
interface ExtendedSWPCStatus extends SWPCStatus {
|
||||
kp_history?: KpHistoryEntry[]
|
||||
sfi_history?: { time: string; value: number }[]
|
||||
}
|
||||
|
||||
interface ExtendedDuctingStatus extends DuctingStatus {
|
||||
profile?: ProfileEntry[]
|
||||
gradients?: {
|
||||
from_level: number
|
||||
to_level: number
|
||||
from_height_m: number
|
||||
to_height_m: number
|
||||
gradient: number
|
||||
}[]
|
||||
assessment?: string
|
||||
location?: { lat: number; lon: number }
|
||||
}
|
||||
|
||||
function HealthGauge({ health }: { health: MeshHealth }) {
|
||||
const score = health.score
|
||||
|
|
@ -193,243 +151,88 @@ function StatCard({ icon: Icon, label, value, subvalue }: { icon: typeof Radio;
|
|||
)
|
||||
}
|
||||
|
||||
// Scale badge component for R/S/G
|
||||
function ScaleBadge({ label, value }: { label: string; value: number }) {
|
||||
const getColor = () => {
|
||||
if (value === 0) return 'bg-green-500/20 text-green-400 border-green-500/50'
|
||||
if (value <= 2) return 'bg-amber-500/20 text-amber-400 border-amber-500/50'
|
||||
return 'bg-red-500/20 text-red-400 border-red-500/50'
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded text-xs font-mono font-medium border ${getColor()}`}>
|
||||
{label}{value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Large value display for SFI/Kp
|
||||
function BigValue({ label, value, unit, getColor }: { label: string; value: number | undefined; unit?: string; getColor: (v: number) => string }) {
|
||||
const color = value !== undefined ? getColor(value) : 'text-slate-400'
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-slate-500 mb-1">{label}</div>
|
||||
<div className={`font-mono text-3xl font-bold ${color}`}>
|
||||
{value?.toFixed(0) ?? '—'}
|
||||
</div>
|
||||
{unit && <div className="text-xs text-slate-500">{unit}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Kp trend sparkline chart
|
||||
function KpTrendChart({ history }: { history: KpHistoryEntry[] }) {
|
||||
const chartData = useMemo(() => {
|
||||
if (!history || history.length === 0) return []
|
||||
// Take last 16 entries (48 hours of 3-hourly data)
|
||||
return history.slice(-16).map((entry, i) => ({
|
||||
idx: i,
|
||||
value: entry.value,
|
||||
time: entry.time,
|
||||
}))
|
||||
}, [history])
|
||||
|
||||
if (chartData.length === 0) return null
|
||||
|
||||
const maxKp = Math.max(...chartData.map(d => d.value), 5)
|
||||
const currentKp = chartData[chartData.length - 1]?.value ?? 0
|
||||
|
||||
// Gradient color based on max Kp
|
||||
const getGradientId = () => {
|
||||
if (maxKp > 5) return 'kpGradientRed'
|
||||
if (maxKp > 3) return 'kpGradientAmber'
|
||||
return 'kpGradientGreen'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-20 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="kpGradientGreen" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#22c55e" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
<linearGradient id="kpGradientAmber" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#f59e0b" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#f59e0b" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
<linearGradient id="kpGradientRed" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#ef4444" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#ef4444" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<YAxis domain={[0, Math.ceil(maxKp)]} hide />
|
||||
<XAxis dataKey="idx" hide />
|
||||
<ReferenceLine y={3} stroke="#f59e0b" strokeDasharray="3 3" strokeOpacity={0.5} />
|
||||
<ReferenceLine y={5} stroke="#ef4444" strokeDasharray="3 3" strokeOpacity={0.5} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={currentKp > 5 ? '#ef4444' : currentKp > 3 ? '#f59e0b' : '#22c55e'}
|
||||
fill={`url(#${getGradientId()})`}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex justify-between text-xs text-slate-600 px-1">
|
||||
<span>48h ago</span>
|
||||
<span>now</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Refractivity profile chart
|
||||
function RefractivityChart({ profile }: { profile: ProfileEntry[] }) {
|
||||
const chartData = useMemo(() => {
|
||||
if (!profile || profile.length === 0) return []
|
||||
return [...profile].sort((a, b) => a.height_m - b.height_m).map(p => ({
|
||||
height: p.height_m,
|
||||
M: p.M,
|
||||
}))
|
||||
}, [profile])
|
||||
|
||||
if (chartData.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="h-24 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 10, bottom: 5, left: 5 }}>
|
||||
<XAxis
|
||||
dataKey="M"
|
||||
type="number"
|
||||
domain={['dataMin - 20', 'dataMax + 20']}
|
||||
tick={{ fontSize: 10, fill: '#64748b' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#334155' }}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="height"
|
||||
type="number"
|
||||
domain={[0, 'dataMax']}
|
||||
tick={{ fontSize: 10, fill: '#64748b' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#334155' }}
|
||||
tickFormatter={(v) => `${(v/1000).toFixed(1)}k`}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="M"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: '#3b82f6' }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="text-center text-xs text-slate-600">M-units vs Height (km)</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// RF Propagation Card
|
||||
function RFPropagationCard({ swpc, ducting }: { swpc: ExtendedSWPCStatus | null; ducting: ExtendedDuctingStatus | null }) {
|
||||
const getSfiColor = (v: number) => {
|
||||
if (v >= 120) return 'text-green-400'
|
||||
if (v >= 80) return 'text-amber-400'
|
||||
return 'text-red-400'
|
||||
}
|
||||
|
||||
const getKpColor = (v: number) => {
|
||||
if (v <= 3) return 'text-green-400'
|
||||
if (v <= 5) return 'text-amber-400'
|
||||
return 'text-red-400'
|
||||
}
|
||||
|
||||
const getDuctingBadge = (condition?: string) => {
|
||||
if (!condition) return null
|
||||
const styles: Record<string, string> = {
|
||||
normal: 'bg-green-500/20 text-green-400 border-green-500/50',
|
||||
super_refraction: 'bg-amber-500/20 text-amber-400 border-amber-500/50',
|
||||
surface_duct: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
|
||||
elevated_duct: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
normal: 'Normal',
|
||||
super_refraction: 'Super Refraction',
|
||||
surface_duct: 'Surface Duct',
|
||||
elevated_duct: 'Elevated Duct',
|
||||
// Band Conditions Card
|
||||
function BandConditionsCard({ bandConditions }: { bandConditions: BandConditionsStatus | null }) {
|
||||
const getRatingEmoji = (rating?: string) => {
|
||||
switch (rating) {
|
||||
case 'Good': return '\ud83d\udfe2' // green circle
|
||||
case 'Fair': return '\ud83d\udfe1' // yellow circle
|
||||
case 'Poor': return '\ud83d\udd34' // red circle
|
||||
default: return '\u2014'
|
||||
}
|
||||
}
|
||||
|
||||
const getSlotEmoji = (label?: string) => {
|
||||
if (!label) return ''
|
||||
return label.includes('Night') ? '\ud83c\udf19' : '\u2600\ufe0f'
|
||||
}
|
||||
|
||||
if (!bandConditions?.enabled || !bandConditions?.ratings) {
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium border ${styles[condition] || styles.normal}`}>
|
||||
{labels[condition] || condition}
|
||||
</span>
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4 flex flex-col h-full">
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Zap size={14} />
|
||||
RF Propagation
|
||||
</h2>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center py-8">
|
||||
<div className="text-slate-400">No band conditions data</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const bands = ['80-40m', '30-20m', '17-15m', '12-10m'] as const
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4 flex flex-col h-full">
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Zap size={14} />
|
||||
<span title="R (Radio Blackouts), S (Solar Radiation Storms), G (Geomagnetic Storms) — NOAA SWPC scales. Kp 3 = quiet baseline, Kp >= 5 = aurora visible at mid-latitudes and HF degraded. See Reference → Solar & Geomagnetic.">RF Propagation</span>
|
||||
RF Propagation
|
||||
</h2>
|
||||
|
||||
{/* Top row: SFI and Kp big values */}
|
||||
<div className="flex justify-around mb-4">
|
||||
<BigValue label="SFI" value={swpc?.sfi} getColor={getSfiColor} />
|
||||
<div className="w-px bg-border" />
|
||||
<BigValue label="Kp" value={swpc?.kp_current} getColor={getKpColor} />
|
||||
{/* Slot label */}
|
||||
<div className="text-center mb-4">
|
||||
<span className="text-lg">{getSlotEmoji(bandConditions.slot_label)}</span>
|
||||
<span className="text-sm text-slate-300 ml-2">{bandConditions.slot_label}</span>
|
||||
</div>
|
||||
|
||||
{/* R/S/G Scale badges */}
|
||||
<div className="flex justify-center gap-2 mb-4">
|
||||
<ScaleBadge label="R" value={swpc?.r_scale ?? 0} />
|
||||
<ScaleBadge label="S" value={swpc?.s_scale ?? 0} />
|
||||
<ScaleBadge label="G" value={swpc?.g_scale ?? 0} />
|
||||
{/* Band conditions header */}
|
||||
<div className="text-xs text-slate-500 mb-3 flex items-center gap-1">
|
||||
<span>\ud83d\udce1</span> Band Conditions:
|
||||
</div>
|
||||
|
||||
{/* Kp Trend Chart */}
|
||||
{swpc?.kp_history && swpc.kp_history.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Kp Trend (48h)</div>
|
||||
<KpTrendChart history={swpc.kp_history} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border my-3" />
|
||||
|
||||
{/* Tropospheric section */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Cloud size={14} className="text-slate-400" />
|
||||
<span className="text-xs text-slate-500">Tropospheric</span>
|
||||
{getDuctingBadge(ducting?.condition)}
|
||||
</div>
|
||||
|
||||
{ducting?.min_gradient !== undefined && (
|
||||
<div className="text-xs text-slate-400 font-mono mb-2">
|
||||
dM/dz: {ducting.min_gradient.toFixed(1)} M-units/km
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Refractivity profile chart */}
|
||||
{ducting?.profile && ducting.profile.length > 0 && (
|
||||
<RefractivityChart profile={ducting.profile} />
|
||||
)}
|
||||
|
||||
{/* SWPC Warnings */}
|
||||
{swpc?.active_warnings && swpc.active_warnings.length > 0 && (
|
||||
<div className="mt-auto pt-3 border-t border-border">
|
||||
<div className="text-xs text-slate-500 mb-1">SWPC Alerts</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{swpc.active_warnings.slice(0, 3).map((w, i) => (
|
||||
<span key={i} className="px-2 py-0.5 rounded text-xs bg-amber-500/20 text-amber-400 border border-amber-500/30 truncate max-w-full">
|
||||
{w.replace('Space Weather Message Code: ', '')}
|
||||
{/* Band rows */}
|
||||
<div className="space-y-2">
|
||||
{bands.map(band => {
|
||||
const rating = bandConditions.ratings?.[band]
|
||||
return (
|
||||
<div key={band} className="flex items-center justify-between px-2 py-1.5 rounded bg-bg-hover">
|
||||
<span className="text-sm font-mono text-slate-300">{band}</span>
|
||||
<span className="text-sm">
|
||||
{getRatingEmoji(rating)} <span className="text-slate-300 ml-1">{rating || '\u2014'}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer: source and time */}
|
||||
<div className="mt-auto pt-3 border-t border-border text-xs text-slate-500">
|
||||
{bandConditions.source && (
|
||||
<span>{bandConditions.source === 'swpc_local' ? 'SWPC' : 'HamQSL'}</span>
|
||||
)}
|
||||
{bandConditions.sent_at && (
|
||||
<span className="ml-2">
|
||||
{new Date(bandConditions.sent_at * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -613,8 +416,7 @@ export default function Dashboard() {
|
|||
const [alerts, setAlerts] = useState<Alert[]>([])
|
||||
const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null)
|
||||
const [envEvents, setEnvEvents] = useState<EnvEvent[]>([])
|
||||
const [swpc, setSwpc] = useState<ExtendedSWPCStatus | null>(null)
|
||||
const [ducting, setDucting] = useState<ExtendedDuctingStatus | null>(null)
|
||||
const [bandConditions, setBandConditions] = useState<BandConditionsStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -628,16 +430,14 @@ export default function Dashboard() {
|
|||
fetchEnvStatus(),
|
||||
fetchEnvActive().catch(() => []),
|
||||
fetchSWPC().catch(() => null),
|
||||
fetchDucting().catch(() => null),
|
||||
])
|
||||
.then(([h, src, a, e, events, sw, duct]) => {
|
||||
.then(([h, src, a, e, events, bc]) => {
|
||||
setHealth(h)
|
||||
setSources(src)
|
||||
setAlerts(a)
|
||||
setEnvStatus(e)
|
||||
setEnvEvents(events)
|
||||
setSwpc(sw as ExtendedSWPCStatus)
|
||||
setDucting(duct as ExtendedDuctingStatus)
|
||||
setBandConditions(bc as BandConditionsStatus)
|
||||
setLoading(false)
|
||||
document.title = 'Dashboard — MeshAI'
|
||||
})
|
||||
|
|
@ -786,7 +586,7 @@ export default function Dashboard() {
|
|||
</div>
|
||||
|
||||
{/* RF Propagation */}
|
||||
<RFPropagationCard swpc={swpc} ducting={ducting} />
|
||||
<BandConditionsCard bandConditions={bandConditions} />
|
||||
|
||||
{/* Live Event Feed */}
|
||||
<LiveEventFeed events={envEvents} envStatus={envStatus} />
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
"""Environmental data API routes."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from meshai.persistence import get_db
|
||||
|
||||
router = APIRouter(tags=["environment"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/env/status")
|
||||
|
|
@ -21,51 +27,127 @@ async def get_env_status(request: Request):
|
|||
|
||||
@router.get("/env/active")
|
||||
async def get_active_env(request: Request):
|
||||
"""Get active environmental events with local zone marking."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
"""Get active environmental events from DB."""
|
||||
db = get_db()
|
||||
|
||||
if not env_store:
|
||||
return []
|
||||
rows = []
|
||||
|
||||
events = env_store.get_active()
|
||||
mesh_zones = set(getattr(env_store, '_mesh_zones', []))
|
||||
# Fires: active (not tombstoned), seen in last 7 days
|
||||
try:
|
||||
fire_rows = db.execute(
|
||||
"SELECT irwin_id AS event_id, 'nifc' AS source, 'wildfire' AS event_type,"
|
||||
" incident_name AS headline, 'immediate' AS severity,"
|
||||
" lat, lon, county, state, last_event_at AS fetched_at,"
|
||||
" last_broadcast_at"
|
||||
" FROM fires"
|
||||
" WHERE tombstoned_at IS NULL"
|
||||
" AND last_event_at > CAST(strftime('%s','now','-7 days') AS INTEGER)"
|
||||
).fetchall()
|
||||
rows.extend(fire_rows)
|
||||
except Exception as e:
|
||||
log.warning("env/active fires query failed: %s", e)
|
||||
|
||||
# Dedup by event_id and add is_local field
|
||||
seen_ids = set()
|
||||
# NWS alerts: not expired
|
||||
try:
|
||||
nws_rows = db.execute(
|
||||
"SELECT event_id, 'nws' AS source, alert_type AS event_type,"
|
||||
" headline, severity, county, state,"
|
||||
" first_seen_at AS fetched_at, last_broadcast_at"
|
||||
" FROM nws_alerts"
|
||||
" WHERE expires_at IS NULL OR expires_at > CAST(strftime('%s','now') AS INTEGER)"
|
||||
).fetchall()
|
||||
rows.extend(nws_rows)
|
||||
except Exception as e:
|
||||
log.warning("env/active nws query failed: %s", e)
|
||||
|
||||
# Earthquakes: last 24h, magnitude >= 2.5
|
||||
try:
|
||||
quake_rows = db.execute(
|
||||
"SELECT event_id, 'usgs' AS source, 'earthquake' AS event_type,"
|
||||
" ('M' || ROUND(magnitude,1) || ' \u2014 ' || place) AS headline,"
|
||||
" CASE WHEN magnitude >= 5.0 THEN 'immediate'"
|
||||
" WHEN magnitude >= 3.5 THEN 'priority'"
|
||||
" ELSE 'routine' END AS severity,"
|
||||
" lat, lon, NULL AS county, NULL AS state,"
|
||||
" occurred_at AS fetched_at, last_broadcast_at"
|
||||
" FROM quake_events"
|
||||
" WHERE occurred_at > CAST(strftime('%s','now','-1 day') AS INTEGER)"
|
||||
" AND magnitude >= 2.5"
|
||||
).fetchall()
|
||||
rows.extend(quake_rows)
|
||||
except Exception as e:
|
||||
log.warning("env/active quake query failed: %s", e)
|
||||
|
||||
# Convert to dicts, add is_local, sort by fetched_at desc
|
||||
result = []
|
||||
for event in events:
|
||||
event_id = event.get("event_id")
|
||||
if event_id and event_id in seen_ids:
|
||||
continue
|
||||
if event_id:
|
||||
seen_ids.add(event_id)
|
||||
|
||||
# Mark as local if event zones overlap with configured mesh zones
|
||||
event_zones = set(event.get("areas", []))
|
||||
event["is_local"] = bool(event_zones & mesh_zones)
|
||||
result.append(event)
|
||||
for row in rows:
|
||||
d = dict(row)
|
||||
d["is_local"] = False
|
||||
result.append(d)
|
||||
|
||||
result.sort(key=lambda e: e.get("fetched_at") or 0, reverse=True)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/env/swpc")
|
||||
async def get_swpc_data(request: Request):
|
||||
"""Get SWPC space weather data."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
"""Get band conditions from DB."""
|
||||
db = get_db()
|
||||
|
||||
if not env_store:
|
||||
try:
|
||||
row = db.execute(
|
||||
"SELECT ratings_json, source, sent_at, scheduled_for"
|
||||
" FROM band_conditions_broadcasts"
|
||||
" WHERE ratings_json IS NOT NULL"
|
||||
" AND source != 'skipped_no_data'"
|
||||
" ORDER BY scheduled_for DESC"
|
||||
" LIMIT 1"
|
||||
).fetchone()
|
||||
except Exception as e:
|
||||
log.warning("env/swpc band_conditions query failed: %s", e)
|
||||
return {"enabled": False}
|
||||
|
||||
status = env_store.get_swpc_status()
|
||||
if not status:
|
||||
if not row:
|
||||
return {"enabled": False}
|
||||
|
||||
try:
|
||||
ratings = json.loads(row["ratings_json"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {"enabled": False}
|
||||
|
||||
# Derive slot_label from scheduled_for hour in Mountain Time
|
||||
slot_label = _slot_label(row["scheduled_for"])
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
**status,
|
||||
"ratings": ratings,
|
||||
"slot_label": slot_label,
|
||||
"sent_at": row["sent_at"],
|
||||
"source": row["source"],
|
||||
}
|
||||
|
||||
|
||||
def _slot_label(epoch: int) -> str:
|
||||
"""Derive slot label from scheduled_for epoch in Mountain Time."""
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
mt = ZoneInfo("America/Boise")
|
||||
except ImportError:
|
||||
# Fallback: UTC offset -6/-7 not critical for label
|
||||
mt = timezone.utc
|
||||
|
||||
dt = datetime.fromtimestamp(epoch, tz=mt)
|
||||
hour = dt.hour
|
||||
|
||||
if 6 <= hour < 14:
|
||||
return "Day Propagation"
|
||||
elif 14 <= hour < 22:
|
||||
return "Day Propagation"
|
||||
else:
|
||||
return "Night Propagation"
|
||||
|
||||
|
||||
@router.get("/env/propagation")
|
||||
async def get_rf_propagation(request: Request):
|
||||
"""Get combined HF + UHF propagation data for dashboard."""
|
||||
|
|
@ -163,12 +245,6 @@ async def lookup_usgs_site(request: Request, site_id: str):
|
|||
usgs_adapter = adapters.get("usgs")
|
||||
|
||||
if not usgs_adapter:
|
||||
# No native usgs adapter on the env_store means usgs is either
|
||||
# disabled or running on a non-native feed_source (central). In
|
||||
# central-feed mode meshai must NOT make direct upstream API calls;
|
||||
# that's the AND-model anti-pattern Central's v0.10.2 report
|
||||
# called out explicitly. Surface this to the UI as a 404 so the
|
||||
# frontend can switch the form to manual-entry mode.
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=("site lookup unavailable in central-feed mode; values "
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
475
meshai/dashboard/static/assets/index-DcZj_ef-.js
Normal file
475
meshai/dashboard/static/assets/index-DcZj_ef-.js
Normal file
File diff suppressed because one or more lines are too long
1
meshai/dashboard/static/assets/index-eNVU4AZQ.css
Normal file
1
meshai/dashboard/static/assets/index-eNVU4AZQ.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -8,8 +8,8 @@
|
|||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script type="module" crossorigin src="/assets/index-BCcZxs_h.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BEgceSNC.css">
|
||||
<script type="module" crossorigin src="/assets/index-DcZj_ef-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-eNVU4AZQ.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue