BMS/frontend/components/dashboard/rack-detail-sheet.tsx
2026-03-19 11:32:17 +00:00

422 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState } from "react";
import {
fetchRackHistory, fetchRackDevices,
type RackHistory, type Device,
} from "@/lib/api";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { TimeRangePicker } from "@/components/ui/time-range-picker";
import { Badge } from "@/components/ui/badge";
import {
LineChart, Line, AreaChart, Area,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from "recharts";
import { Thermometer, Zap, Droplets, Bell, Server } from "lucide-react";
import { cn } from "@/lib/utils";
interface Props {
siteId: string;
rackId: string | null;
onClose: () => void;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function timeAgo(iso: string) {
const diff = Date.now() - new Date(iso).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
return `${Math.floor(m / 60)}h ago`;
}
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
// ── Device type styles ────────────────────────────────────────────────────────
const TYPE_STYLES: Record<string, { bg: string; border: string; text: string; label: string }> = {
server: { bg: "bg-blue-500/15", border: "border-blue-500/40", text: "text-blue-300", label: "Server" },
switch: { bg: "bg-green-500/15", border: "border-green-500/40", text: "text-green-300", label: "Switch" },
patch_panel: { bg: "bg-slate-500/15", border: "border-slate-400/40", text: "text-slate-300", label: "Patch Panel" },
pdu: { bg: "bg-amber-500/15", border: "border-amber-500/40", text: "text-amber-300", label: "PDU" },
storage: { bg: "bg-purple-500/15", border: "border-purple-500/40", text: "text-purple-300", label: "Storage" },
firewall: { bg: "bg-red-500/15", border: "border-red-500/40", text: "text-red-300", label: "Firewall" },
kvm: { bg: "bg-teal-500/15", border: "border-teal-500/40", text: "text-teal-300", label: "KVM" },
};
const TYPE_DOT: Record<string, string> = {
server: "bg-blue-400",
switch: "bg-green-400",
patch_panel: "bg-slate-400",
pdu: "bg-amber-400",
storage: "bg-purple-400",
firewall: "bg-red-400",
kvm: "bg-teal-400",
};
// ── U-diagram ─────────────────────────────────────────────────────────────────
const TOTAL_U = 42;
const U_PX = 20; // height per U in pixels
type Segment = { u: number; height: number; device: Device | null };
function buildSegments(devices: Device[]): Segment[] {
const sorted = [...devices].sort((a, b) => a.u_start - b.u_start);
const segs: Segment[] = [];
let u = 1;
let i = 0;
while (u <= TOTAL_U) {
if (i < sorted.length && sorted[i].u_start === u) {
const d = sorted[i];
segs.push({ u, height: d.u_height, device: d });
u += d.u_height;
i++;
} else {
const nextU = i < sorted.length ? sorted[i].u_start : TOTAL_U + 1;
const empty = Math.min(nextU, TOTAL_U + 1) - u;
if (empty > 0) {
segs.push({ u, height: empty, device: null });
u += empty;
} else {
break;
}
}
}
return segs;
}
function RackDiagram({ devices, loading }: { devices: Device[]; loading: boolean }) {
const [selected, setSelected] = useState<Device | null>(null);
if (loading) {
return (
<div className="mt-3 space-y-1 flex-1">
{Array.from({ length: 12 }).map((_, i) => (
<Skeleton key={i} className="h-5 w-full" />
))}
</div>
);
}
const segments = buildSegments(devices);
const totalPower = devices.reduce((s, d) => s + d.power_draw_w, 0);
return (
<div className="mt-3 flex flex-col flex-1 min-h-0 gap-3">
{/* Legend */}
<div className="flex flex-wrap gap-x-3 gap-y-1 shrink-0">
{Object.entries(TYPE_STYLES).map(([type, style]) => (
devices.some(d => d.type === type) ? (
<div key={type} className="flex items-center gap-1">
<span className={cn("w-2 h-2 rounded-sm", TYPE_DOT[type])} />
<span className="text-[10px] text-muted-foreground">{style.label}</span>
</div>
) : null
))}
</div>
{/* Rack diagram */}
<div className="flex flex-col flex-1 min-h-0 border border-border/50 rounded-lg overflow-hidden">
{/* Header */}
<div className="flex bg-muted/40 border-b border-border/50 px-2 py-1 shrink-0">
<span className="w-8 text-[9px] text-muted-foreground text-right pr-1 shrink-0">U</span>
<span className="flex-1 text-[9px] text-muted-foreground pl-2">
{devices[0]?.rack_id.toUpperCase() ?? "Rack"} 42U
</span>
<span className="text-[9px] text-muted-foreground">{totalPower} W total</span>
</div>
{/* Slots */}
<div className="flex-1 overflow-y-auto">
{segments.map((seg) => {
const style = seg.device ? (TYPE_STYLES[seg.device.type] ?? TYPE_STYLES.server) : null;
const isSelected = selected?.device_id === seg.device?.device_id;
return (
<div
key={seg.u}
style={{ height: seg.height * U_PX }}
className={cn(
"flex items-stretch border-b border-border/20 last:border-0",
seg.device && "cursor-pointer",
)}
onClick={() => setSelected(seg.device && isSelected ? null : seg.device)}
>
{/* U number */}
<div className="w-8 flex items-start justify-end pt-1 pr-1.5 shrink-0">
<span className="text-[9px] text-muted-foreground/50 font-mono leading-none">
{seg.u}
</span>
</div>
{/* Device or empty */}
<div className="flex-1 flex items-stretch py-px pr-1">
{seg.device ? (
<div className={cn(
"flex-1 rounded border flex items-center px-2 gap-2 transition-colors",
style!.bg, style!.border,
isSelected && "ring-1 ring-primary/50",
)}>
<span className={cn("text-xs font-medium truncate flex-1", style!.text)}>
{seg.device.name}
</span>
{seg.height >= 2 && (
<span className="text-[9px] text-muted-foreground/60 font-mono shrink-0">
{seg.device.ip !== "-" ? seg.device.ip : ""}
</span>
)}
<span className="text-[9px] text-muted-foreground/60 font-mono shrink-0">
{seg.device.u_height}U
</span>
</div>
) : (
<div className="flex-1 rounded border border-dashed border-border/25 flex items-center px-2">
{seg.height > 1 && (
<span className="text-[9px] text-muted-foreground/25">
{seg.height}U empty
</span>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Selected device detail */}
{selected && (() => { // shrink-0 via parent gap
const style = TYPE_STYLES[selected.type] ?? TYPE_STYLES.server;
return (
<div className={cn("rounded-lg border p-3 space-y-2", style.bg, style.border)}>
<div className="flex items-start justify-between gap-2">
<div>
<p className={cn("text-sm font-semibold", style.text)}>{selected.name}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">{style.label}</p>
</div>
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-green-500/10 text-green-400">
Online
</span>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div><span className="text-muted-foreground">Serial: </span><span className="font-mono">{selected.serial}</span></div>
<div><span className="text-muted-foreground">IP: </span><span className="font-mono">{selected.ip !== "-" ? selected.ip : "—"}</span></div>
<div><span className="text-muted-foreground">Position: </span><span>U{selected.u_start}U{selected.u_start + selected.u_height - 1}</span></div>
<div><span className="text-muted-foreground">Power: </span><span>{selected.power_draw_w} W</span></div>
</div>
</div>
);
})()}
</div>
);
}
// ── History tab ───────────────────────────────────────────────────────────────
function HistoryTab({ data, hours, onHoursChange, loading }: {
data: RackHistory | null;
hours: number;
onHoursChange: (h: number) => void;
loading: boolean;
}) {
const chartData = (data?.history ?? []).map(p => ({ ...p, time: formatTime(p.bucket) }));
const latest = chartData[chartData.length - 1];
return (
<div>
<div className="flex justify-end mt-3 mb-4">
<TimeRangePicker value={hours} onChange={onHoursChange} />
</div>
{loading ? (
<div className="space-y-4">
<Skeleton className="h-40 w-full" /><Skeleton className="h-40 w-full" />
</div>
) : (
<div className="space-y-5">
{latest && (
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg bg-muted/30 p-3 text-center">
<Thermometer className="w-4 h-4 mx-auto mb-1 text-primary" />
<p className="text-lg font-bold">{latest.temperature !== undefined ? `${latest.temperature}°C` : "—"}</p>
<p className="text-[10px] text-muted-foreground">Temperature</p>
</div>
<div className="rounded-lg bg-muted/30 p-3 text-center">
<Zap className="w-4 h-4 mx-auto mb-1 text-amber-400" />
<p className="text-lg font-bold">{latest.power_kw !== undefined ? `${latest.power_kw} kW` : "—"}</p>
<p className="text-[10px] text-muted-foreground">Power</p>
</div>
<div className="rounded-lg bg-muted/30 p-3 text-center">
<Droplets className="w-4 h-4 mx-auto mb-1 text-blue-400" />
<p className="text-lg font-bold">{latest.humidity !== undefined ? `${latest.humidity}%` : "—"}</p>
<p className="text-[10px] text-muted-foreground">Humidity</p>
</div>
</div>
)}
<div>
<p className="text-xs font-medium text-muted-foreground mb-2 flex items-center gap-1">
<Thermometer className="w-3 h-3" /> Temperature (°C)
</p>
<ResponsiveContainer width="100%" height={140}>
<LineChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
<XAxis dataKey="time" tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} domain={["auto", "auto"]} />
<Tooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "11px" }}
formatter={(v) => [`${v}°C`, "Temp"]}
/>
<Line type="monotone" dataKey="temperature" stroke="oklch(0.62 0.17 212)" strokeWidth={2} dot={false} activeDot={{ r: 3 }} />
</LineChart>
</ResponsiveContainer>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-2 flex items-center gap-1">
<Zap className="w-3 h-3" /> Power Draw (kW)
</p>
<ResponsiveContainer width="100%" height={140}>
<AreaChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="powerGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="oklch(0.78 0.17 84)" stopOpacity={0.3} />
<stop offset="95%" stopColor="oklch(0.78 0.17 84)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
<XAxis dataKey="time" tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} domain={[0, "auto"]} />
<Tooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "11px" }}
formatter={(v) => [`${v} kW`, "Power"]}
/>
<Area type="monotone" dataKey="power_kw" stroke="oklch(0.78 0.17 84)" fill="url(#powerGrad)" strokeWidth={2} dot={false} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
)}
</div>
);
}
// ── Alarms tab ────────────────────────────────────────────────────────────────
const severityColors: Record<string, string> = {
critical: "bg-destructive/15 text-destructive border-destructive/30",
warning: "bg-amber-500/15 text-amber-400 border-amber-500/30",
info: "bg-blue-500/15 text-blue-400 border-blue-500/30",
};
const stateColors: Record<string, string> = {
active: "bg-destructive/10 text-destructive",
acknowledged: "bg-amber-500/10 text-amber-400",
resolved: "bg-green-500/10 text-green-400",
};
function AlarmsTab({ data }: { data: RackHistory | null }) {
return (
<div className="mt-3">
{!data || data.alarms.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-8">
No alarms on record for this rack.
</p>
) : (
<div className="space-y-2">
{data.alarms.map((alarm) => (
<div
key={alarm.id}
className={cn("rounded-lg border px-3 py-2 text-xs", severityColors[alarm.severity] ?? severityColors.info)}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{alarm.message}</span>
<Badge className={cn("text-[9px] border-0 shrink-0", stateColors[alarm.state] ?? stateColors.active)}>
{alarm.state}
</Badge>
</div>
<p className="mt-0.5 opacity-70">{timeAgo(alarm.triggered_at)}</p>
</div>
))}
</div>
)}
</div>
);
}
// ── Sheet ─────────────────────────────────────────────────────────────────────
export function RackDetailSheet({ siteId, rackId, onClose }: Props) {
const [data, setData] = useState<RackHistory | null>(null);
const [devices, setDevices] = useState<Device[]>([]);
const [hours, setHours] = useState(6);
const [histLoading, setHistLoad] = useState(false);
const [devLoading, setDevLoad] = useState(false);
useEffect(() => {
if (!rackId) { setData(null); setDevices([]); return; }
setHistLoad(true);
setDevLoad(true);
fetchRackHistory(siteId, rackId, hours)
.then(setData).catch(() => setData(null))
.finally(() => setHistLoad(false));
fetchRackDevices(siteId, rackId)
.then(setDevices).catch(() => setDevices([]))
.finally(() => setDevLoad(false));
}, [siteId, rackId, hours]);
const alarmCount = data?.alarms.filter(a => a.state === "active").length ?? 0;
return (
<Sheet open={!!rackId} onOpenChange={open => { if (!open) onClose(); }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col h-dvh overflow-hidden">
<SheetHeader className="mb-2 shrink-0">
<SheetTitle className="flex items-center gap-2">
<Server className="w-4 h-4 text-primary" />
{rackId?.toUpperCase() ?? ""}
</SheetTitle>
<p className="text-xs text-muted-foreground">Singapore DC01</p>
</SheetHeader>
<Tabs defaultValue="layout" className="flex flex-col flex-1 min-h-0">
<TabsList className="w-full shrink-0">
<TabsTrigger value="layout" className="flex-1">Layout</TabsTrigger>
<TabsTrigger value="history" className="flex-1">History</TabsTrigger>
<TabsTrigger value="alarms" className="flex-1">
Alarms
{alarmCount > 0 && (
<span className="ml-1.5 text-[9px] font-bold text-destructive">{alarmCount}</span>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="layout" className="flex flex-col flex-1 min-h-0 overflow-hidden mt-0">
<RackDiagram devices={devices} loading={devLoading} />
</TabsContent>
<TabsContent value="history" className="flex-1 overflow-y-auto">
<HistoryTab
data={data}
hours={hours}
onHoursChange={setHours}
loading={histLoading}
/>
</TabsContent>
<TabsContent value="alarms" className="flex-1 overflow-y-auto">
<AlarmsTab data={data} />
</TabsContent>
</Tabs>
</SheetContent>
</Sheet>
);
}