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:
Matt Johnson (via Claude) 2026-06-10 05:53:55 +00:00
commit 5d8a277fa7
8 changed files with 663 additions and 852 deletions

View file

@ -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> {

View file

@ -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 &amp; 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} />

View file

@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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>