256 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|