BMS/frontend/app/(dashboard)/network/page.tsx
2026-03-19 11:32:17 +00:00

256 lines
11 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback } from "react";
import { toast } from "sonner";
import { fetchNetworkStatus, type NetworkSwitchStatus } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
Network, Wifi, WifiOff, AlertTriangle, CheckCircle2,
RefreshCw, Cpu, HardDrive, Thermometer, Activity,
} from "lucide-react";
import { cn } from "@/lib/utils";
const SITE_ID = "sg-01";
function formatUptime(seconds: number | null): string {
if (seconds === null) return "—";
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
if (d > 0) return `${d}d ${h}h`;
const m = Math.floor((seconds % 3600) / 60);
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function StateChip({ state }: { state: NetworkSwitchStatus["state"] }) {
const cfg = {
up: { label: "Up", icon: CheckCircle2, cls: "bg-green-500/10 text-green-400 border-green-500/20" },
degraded: { label: "Degraded", icon: AlertTriangle, cls: "bg-amber-500/10 text-amber-400 border-amber-500/20" },
down: { label: "Down", icon: WifiOff, cls: "bg-destructive/10 text-destructive border-destructive/20" },
unknown: { label: "Unknown", icon: WifiOff, cls: "bg-muted/50 text-muted-foreground border-border" },
}[state] ?? { label: state, icon: WifiOff, cls: "bg-muted/50 text-muted-foreground border-border" };
const Icon = cfg.icon;
return (
<span className={cn("inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wide border", cfg.cls)}>
<Icon className="w-3 h-3" /> {cfg.label}
</span>
);
}
function MiniBar({ value, max, className }: { value: number; max: number; className?: string }) {
const pct = Math.min(100, (value / max) * 100);
return (
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full transition-all", className)} style={{ width: `${pct}%` }} />
</div>
);
}
function SwitchCard({ sw }: { sw: NetworkSwitchStatus }) {
const portPct = sw.active_ports !== null ? Math.round((sw.active_ports / sw.port_count) * 100) : null;
const stateOk = sw.state === "up";
const stateDeg = sw.state === "degraded";
return (
<Card className={cn(
"border",
sw.state === "down" && "border-destructive/40",
sw.state === "degraded" && "border-amber-500/30",
)}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="space-y-0.5 min-w-0">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Network className={cn(
"w-4 h-4 shrink-0",
stateOk ? "text-green-400" : stateDeg ? "text-amber-400" : "text-destructive"
)} />
<span className="truncate">{sw.name}</span>
</CardTitle>
<p className="text-[10px] text-muted-foreground font-mono">{sw.model}</p>
</div>
<StateChip state={sw.state} />
</div>
<div className="flex items-center gap-3 text-[10px] text-muted-foreground mt-1">
<span className="capitalize">{sw.role}</span>
<span>·</span>
<span>{sw.room_id}</span>
<span>·</span>
<span className="font-mono">{sw.rack_id}</span>
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* Ports headline */}
<div className="flex items-start justify-between">
<div>
<p className="text-[10px] text-muted-foreground uppercase tracking-wide">Ports Active</p>
<p className="text-base font-bold tabular-nums leading-tight">
{sw.active_ports !== null ? Math.round(sw.active_ports) : "—"} / {sw.port_count}
</p>
{portPct !== null && <p className="text-[10px] text-muted-foreground">{portPct}% utilised</p>}
</div>
</div>
<MiniBar
value={sw.active_ports ?? 0}
max={sw.port_count}
className={portPct !== null && portPct >= 90 ? "bg-amber-500" : "bg-primary"}
/>
{/* Bandwidth */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
<Activity className="w-3 h-3" /> Ingress
</p>
<p className="text-sm font-semibold tabular-nums">
{sw.bandwidth_in_mbps !== null ? `${sw.bandwidth_in_mbps.toFixed(0)} Mbps` : "—"}
</p>
</div>
<div className="space-y-1">
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
<Activity className="w-3 h-3 rotate-180" /> Egress
</p>
<p className="text-sm font-semibold tabular-nums">
{sw.bandwidth_out_mbps !== null ? `${sw.bandwidth_out_mbps.toFixed(0)} Mbps` : "—"}
</p>
</div>
</div>
{/* CPU + Mem */}
<div className="border-t border-border/40 pt-2 space-y-2">
<div className="flex items-center gap-2">
<Cpu className="w-3 h-3 text-muted-foreground shrink-0" />
<span className="text-[10px] text-muted-foreground w-8">CPU</span>
<MiniBar
value={sw.cpu_pct ?? 0}
max={100}
className={
(sw.cpu_pct ?? 0) >= 80 ? "bg-destructive" :
(sw.cpu_pct ?? 0) >= 60 ? "bg-amber-500" : "bg-green-500"
}
/>
<span className="text-xs font-semibold tabular-nums w-10 text-right">
{sw.cpu_pct !== null ? `${sw.cpu_pct.toFixed(0)}%` : "—"}
</span>
</div>
<div className="flex items-center gap-2">
<HardDrive className="w-3 h-3 text-muted-foreground shrink-0" />
<span className="text-[10px] text-muted-foreground w-8">Mem</span>
<MiniBar
value={sw.mem_pct ?? 0}
max={100}
className={
(sw.mem_pct ?? 0) >= 85 ? "bg-destructive" :
(sw.mem_pct ?? 0) >= 70 ? "bg-amber-500" : "bg-blue-500"
}
/>
<span className="text-xs font-semibold tabular-nums w-10 text-right">
{sw.mem_pct !== null ? `${sw.mem_pct.toFixed(0)}%` : "—"}
</span>
</div>
</div>
{/* Footer stats */}
<div className="flex items-center justify-between pt-1 border-t border-border/50 text-[10px] text-muted-foreground">
<span className="flex items-center gap-1">
<Thermometer className="w-3 h-3" />
{sw.temperature_c !== null ? `${sw.temperature_c.toFixed(0)}°C` : "—"}
</span>
<span>
Pkt loss: <span className={cn(
"font-semibold",
(sw.packet_loss_pct ?? 0) > 1 ? "text-destructive" :
(sw.packet_loss_pct ?? 0) > 0.1 ? "text-amber-400" : "text-green-400"
)}>
{sw.packet_loss_pct !== null ? `${sw.packet_loss_pct.toFixed(2)}%` : "—"}
</span>
</span>
<span>Up: {formatUptime(sw.uptime_s)}</span>
</div>
</CardContent>
</Card>
);
}
export default function NetworkPage() {
const [switches, setSwitches] = useState<NetworkSwitchStatus[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const data = await fetchNetworkStatus(SITE_ID);
setSwitches(data);
} catch { toast.error("Failed to load network data"); }
finally { setLoading(false); }
}, []);
useEffect(() => {
load();
const id = setInterval(load, 30_000);
return () => clearInterval(id);
}, [load]);
const down = switches.filter((s) => s.state === "down").length;
const degraded = switches.filter((s) => s.state === "degraded").length;
const up = switches.filter((s) => s.state === "up").length;
return (
<div className="p-6 space-y-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold">Network Infrastructure</h1>
<p className="text-sm text-muted-foreground">Singapore DC01 switch health · refreshes every 30s</p>
</div>
<button
onClick={load}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" /> Refresh
</button>
</div>
{/* Summary chips */}
{!loading && switches.length > 0 && (
<div className="flex items-center gap-3 flex-wrap">
<span className="flex items-center gap-1.5 text-sm">
<span className="w-2 h-2 rounded-full bg-green-500" />
<span className="font-semibold">{up}</span>
<span className="text-muted-foreground">up</span>
</span>
{degraded > 0 && (
<span className="flex items-center gap-1.5 text-sm">
<span className="w-2 h-2 rounded-full bg-amber-500" />
<span className="font-semibold text-amber-400">{degraded}</span>
<span className="text-muted-foreground">degraded</span>
</span>
)}
{down > 0 && (
<span className="flex items-center gap-1.5 text-sm">
<span className="w-2 h-2 rounded-full bg-destructive" />
<span className="font-semibold text-destructive">{down}</span>
<span className="text-muted-foreground">down</span>
</span>
)}
<span className="text-xs text-muted-foreground ml-2">{switches.length} switches total</span>
</div>
)}
{/* Switch cards */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-64" />)}
</div>
) : switches.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 gap-3 text-muted-foreground">
<Wifi className="w-8 h-8 opacity-30" />
<p className="text-sm">No network switch data available</p>
<p className="text-xs text-center">Ensure the simulator is running and network bots are publishing data</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{switches.map((sw) => <SwitchCard key={sw.switch_id} sw={sw} />)}
</div>
)}
</div>
);
}