first commit
This commit is contained in:
commit
4b98219bf7
144 changed files with 31561 additions and 0 deletions
422
frontend/components/dashboard/rack-detail-sheet.tsx
Normal file
422
frontend/components/dashboard/rack-detail-sheet.tsx
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue