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
|
gradient: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SWPCStatus {
|
export interface BandConditionsStatus {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
kp_current?: number
|
ratings?: {
|
||||||
kp_timestamp?: string
|
"80-40m"?: string
|
||||||
sfi?: number
|
"30-20m"?: string
|
||||||
r_scale?: number
|
"17-15m"?: string
|
||||||
s_scale?: number
|
"12-10m"?: string
|
||||||
g_scale?: number
|
}
|
||||||
active_warnings?: string[]
|
slot_label?: string
|
||||||
kp_history?: KpHistoryEntry[]
|
sent_at?: number
|
||||||
sfi_history?: SfiHistoryEntry[]
|
source?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kept for backward compat references
|
||||||
|
export type SWPCStatus = BandConditionsStatus
|
||||||
|
|
||||||
export interface DuctingStatus {
|
export interface DuctingStatus {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
condition?: string
|
condition?: string
|
||||||
|
|
@ -307,8 +310,8 @@ export async function fetchRFPropagation(): Promise<RFPropagation> {
|
||||||
return fetchJson<RFPropagation>('/api/env/propagation')
|
return fetchJson<RFPropagation>('/api/env/propagation')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSWPC(): Promise<SWPCStatus> {
|
export async function fetchSWPC(): Promise<BandConditionsStatus> {
|
||||||
return fetchJson<SWPCStatus>('/api/env/swpc')
|
return fetchJson<BandConditionsStatus>('/api/env/swpc')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchDucting(): Promise<DuctingStatus> {
|
export async function fetchDucting(): Promise<DuctingStatus> {
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,12 @@ import {
|
||||||
fetchEnvStatus,
|
fetchEnvStatus,
|
||||||
fetchEnvActive,
|
fetchEnvActive,
|
||||||
fetchSWPC,
|
fetchSWPC,
|
||||||
fetchDucting,
|
|
||||||
type MeshHealth,
|
type MeshHealth,
|
||||||
type SourceHealth,
|
type SourceHealth,
|
||||||
type Alert,
|
type Alert,
|
||||||
type EnvStatus,
|
type EnvStatus,
|
||||||
type EnvEvent,
|
type EnvEvent,
|
||||||
type SWPCStatus,
|
type BandConditionsStatus,
|
||||||
type DuctingStatus,
|
|
||||||
} from '@/lib/api'
|
} from '@/lib/api'
|
||||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||||
import {
|
import {
|
||||||
|
|
@ -35,49 +33,9 @@ import {
|
||||||
Satellite,
|
Satellite,
|
||||||
Sun,
|
Sun,
|
||||||
} from 'lucide-react'
|
} 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 }) {
|
function HealthGauge({ health }: { health: MeshHealth }) {
|
||||||
const score = health.score
|
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'
|
// Band Conditions Card
|
||||||
return 'bg-red-500/20 text-red-400 border-red-500/50'
|
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'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const getSlotEmoji = (label?: string) => {
|
||||||
<span className={`px-2 py-1 rounded text-xs font-mono font-medium border ${getColor()}`}>
|
if (!label) return ''
|
||||||
{label}{value}
|
return label.includes('Night') ? '\ud83c\udf19' : '\u2600\ufe0f'
|
||||||
</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'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!bandConditions?.enabled || !bandConditions?.ratings) {
|
||||||
return (
|
return (
|
||||||
<div className="h-20 w-full">
|
<div className="bg-bg-card border border-border rounded-lg p-4 flex flex-col h-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||||
<AreaChart data={chartData} margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
|
<Zap size={14} />
|
||||||
<defs>
|
RF Propagation
|
||||||
<linearGradient id="kpGradientGreen" x1="0" y1="0" x2="0" y2="1">
|
</h2>
|
||||||
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.4} />
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<stop offset="100%" stopColor="#22c55e" stopOpacity={0.05} />
|
<div className="text-center py-8">
|
||||||
</linearGradient>
|
<div className="text-slate-400">No band conditions data</div>
|
||||||
<linearGradient id="kpGradientAmber" x1="0" y1="0" x2="0" y2="1">
|
</div>
|
||||||
<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>
|
||||||
</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) => {
|
const bands = ['80-40m', '30-20m', '17-15m', '12-10m'] as const
|
||||||
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',
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium border ${styles[condition] || styles.normal}`}>
|
|
||||||
{labels[condition] || condition}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-bg-card border border-border rounded-lg p-4 flex flex-col h-full">
|
<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">
|
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||||
<Zap size={14} />
|
<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>
|
</h2>
|
||||||
|
|
||||||
{/* Top row: SFI and Kp big values */}
|
{/* Slot label */}
|
||||||
<div className="flex justify-around mb-4">
|
<div className="text-center mb-4">
|
||||||
<BigValue label="SFI" value={swpc?.sfi} getColor={getSfiColor} />
|
<span className="text-lg">{getSlotEmoji(bandConditions.slot_label)}</span>
|
||||||
<div className="w-px bg-border" />
|
<span className="text-sm text-slate-300 ml-2">{bandConditions.slot_label}</span>
|
||||||
<BigValue label="Kp" value={swpc?.kp_current} getColor={getKpColor} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* R/S/G Scale badges */}
|
{/* Band conditions header */}
|
||||||
<div className="flex justify-center gap-2 mb-4">
|
<div className="text-xs text-slate-500 mb-3 flex items-center gap-1">
|
||||||
<ScaleBadge label="R" value={swpc?.r_scale ?? 0} />
|
<span>\ud83d\udce1</span> Band Conditions:
|
||||||
<ScaleBadge label="S" value={swpc?.s_scale ?? 0} />
|
|
||||||
<ScaleBadge label="G" value={swpc?.g_scale ?? 0} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Kp Trend Chart */}
|
{/* Band rows */}
|
||||||
{swpc?.kp_history && swpc.kp_history.length > 0 && (
|
<div className="space-y-2">
|
||||||
<div className="mb-4">
|
{bands.map(band => {
|
||||||
<div className="text-xs text-slate-500 mb-1">Kp Trend (48h)</div>
|
const rating = bandConditions.ratings?.[band]
|
||||||
<KpTrendChart history={swpc.kp_history} />
|
return (
|
||||||
</div>
|
<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">
|
||||||
{/* Divider */}
|
{getRatingEmoji(rating)} <span className="text-slate-300 ml-1">{rating || '\u2014'}</span>
|
||||||
<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: ', '')}
|
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -613,8 +416,7 @@ export default function Dashboard() {
|
||||||
const [alerts, setAlerts] = useState<Alert[]>([])
|
const [alerts, setAlerts] = useState<Alert[]>([])
|
||||||
const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null)
|
const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null)
|
||||||
const [envEvents, setEnvEvents] = useState<EnvEvent[]>([])
|
const [envEvents, setEnvEvents] = useState<EnvEvent[]>([])
|
||||||
const [swpc, setSwpc] = useState<ExtendedSWPCStatus | null>(null)
|
const [bandConditions, setBandConditions] = useState<BandConditionsStatus | null>(null)
|
||||||
const [ducting, setDucting] = useState<ExtendedDuctingStatus | null>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -628,16 +430,14 @@ export default function Dashboard() {
|
||||||
fetchEnvStatus(),
|
fetchEnvStatus(),
|
||||||
fetchEnvActive().catch(() => []),
|
fetchEnvActive().catch(() => []),
|
||||||
fetchSWPC().catch(() => null),
|
fetchSWPC().catch(() => null),
|
||||||
fetchDucting().catch(() => null),
|
|
||||||
])
|
])
|
||||||
.then(([h, src, a, e, events, sw, duct]) => {
|
.then(([h, src, a, e, events, bc]) => {
|
||||||
setHealth(h)
|
setHealth(h)
|
||||||
setSources(src)
|
setSources(src)
|
||||||
setAlerts(a)
|
setAlerts(a)
|
||||||
setEnvStatus(e)
|
setEnvStatus(e)
|
||||||
setEnvEvents(events)
|
setEnvEvents(events)
|
||||||
setSwpc(sw as ExtendedSWPCStatus)
|
setBandConditions(bc as BandConditionsStatus)
|
||||||
setDucting(duct as ExtendedDuctingStatus)
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
document.title = 'Dashboard — MeshAI'
|
document.title = 'Dashboard — MeshAI'
|
||||||
})
|
})
|
||||||
|
|
@ -786,7 +586,7 @@ export default function Dashboard() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RF Propagation */}
|
{/* RF Propagation */}
|
||||||
<RFPropagationCard swpc={swpc} ducting={ducting} />
|
<BandConditionsCard bandConditions={bandConditions} />
|
||||||
|
|
||||||
{/* Live Event Feed */}
|
{/* Live Event Feed */}
|
||||||
<LiveEventFeed events={envEvents} envStatus={envStatus} />
|
<LiveEventFeed events={envEvents} envStatus={envStatus} />
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
"""Environmental data API routes."""
|
"""Environmental data API routes."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
|
||||||
|
from meshai.persistence import get_db
|
||||||
|
|
||||||
router = APIRouter(tags=["environment"])
|
router = APIRouter(tags=["environment"])
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/status")
|
@router.get("/env/status")
|
||||||
|
|
@ -21,51 +27,127 @@ async def get_env_status(request: Request):
|
||||||
|
|
||||||
@router.get("/env/active")
|
@router.get("/env/active")
|
||||||
async def get_active_env(request: Request):
|
async def get_active_env(request: Request):
|
||||||
"""Get active environmental events with local zone marking."""
|
"""Get active environmental events from DB."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
db = get_db()
|
||||||
|
|
||||||
if not env_store:
|
rows = []
|
||||||
return []
|
|
||||||
|
|
||||||
events = env_store.get_active()
|
# Fires: active (not tombstoned), seen in last 7 days
|
||||||
mesh_zones = set(getattr(env_store, '_mesh_zones', []))
|
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
|
# NWS alerts: not expired
|
||||||
seen_ids = set()
|
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 = []
|
result = []
|
||||||
for event in events:
|
for row in rows:
|
||||||
event_id = event.get("event_id")
|
d = dict(row)
|
||||||
if event_id and event_id in seen_ids:
|
d["is_local"] = False
|
||||||
continue
|
result.append(d)
|
||||||
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)
|
|
||||||
|
|
||||||
|
result.sort(key=lambda e: e.get("fetched_at") or 0, reverse=True)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/swpc")
|
@router.get("/env/swpc")
|
||||||
async def get_swpc_data(request: Request):
|
async def get_swpc_data(request: Request):
|
||||||
"""Get SWPC space weather data."""
|
"""Get band conditions from DB."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
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}
|
return {"enabled": False}
|
||||||
|
|
||||||
status = env_store.get_swpc_status()
|
if not row:
|
||||||
if not status:
|
|
||||||
return {"enabled": False}
|
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 {
|
return {
|
||||||
"enabled": True,
|
"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")
|
@router.get("/env/propagation")
|
||||||
async def get_rf_propagation(request: Request):
|
async def get_rf_propagation(request: Request):
|
||||||
"""Get combined HF + UHF propagation data for dashboard."""
|
"""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")
|
usgs_adapter = adapters.get("usgs")
|
||||||
|
|
||||||
if not usgs_adapter:
|
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(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
detail=("site lookup unavailable in central-feed mode; values "
|
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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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">
|
<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>
|
<script type="module" crossorigin src="/assets/index-DcZj_ef-.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BEgceSNC.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-eNVU4AZQ.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue