422 lines
18 KiB
TypeScript
422 lines
18 KiB
TypeScript
"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>
|
||
);
|
||
}
|