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

703 lines
32 KiB
TypeScript
Raw 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, useMemo } from "react";
import { toast } from "sonner";
import {
fetchAssets, fetchAllDevices, fetchPduReadings,
type AssetsData, type RackAsset, type CracAsset, type UpsAsset, type Device, type PduReading,
} from "@/lib/api";
import { RackDetailSheet } from "@/components/dashboard/rack-detail-sheet";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Thermometer, Zap, Wind, Battery, AlertTriangle,
CheckCircle2, HelpCircle, LayoutGrid, List, Download,
} from "lucide-react";
import { cn } from "@/lib/utils";
const SITE_ID = "sg-01";
const roomLabels: Record<string, string> = { "hall-a": "Hall A", "hall-b": "Hall B" };
// ── Status helpers ────────────────────────────────────────────────────────────
const statusStyles: Record<string, { dot: string; border: string }> = {
ok: { dot: "bg-green-500", border: "border-green-500/20" },
warning: { dot: "bg-amber-500", border: "border-amber-500/30" },
critical: { dot: "bg-destructive", border: "border-destructive/30" },
unknown: { dot: "bg-muted", border: "border-border" },
};
const TYPE_STYLES: Record<string, { dot: string; label: string }> = {
server: { dot: "bg-blue-400", label: "Server" },
switch: { dot: "bg-green-400", label: "Switch" },
patch_panel: { dot: "bg-slate-400", label: "Patch Panel" },
pdu: { dot: "bg-amber-400", label: "PDU" },
storage: { dot: "bg-purple-400", label: "Storage" },
firewall: { dot: "bg-red-400", label: "Firewall" },
kvm: { dot: "bg-teal-400", label: "KVM" },
};
// ── Compact CRAC row ──────────────────────────────────────────────────────────
function CracRow({ crac }: { crac: CracAsset }) {
const online = crac.state === "online";
const fault = crac.state === "fault";
return (
<div className="flex items-center gap-3 px-3 py-2 text-xs">
<Wind className="w-3.5 h-3.5 text-primary shrink-0" />
<span className="font-semibold font-mono w-20 shrink-0">{crac.crac_id.toUpperCase()}</span>
<span className={cn(
"flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded-full uppercase tracking-wide shrink-0",
fault ? "bg-destructive/10 text-destructive" :
online ? "bg-green-500/10 text-green-400" :
"bg-muted text-muted-foreground",
)}>
{fault ? <AlertTriangle className="w-2.5 h-2.5" /> :
online ? <CheckCircle2 className="w-2.5 h-2.5" /> :
<HelpCircle className="w-2.5 h-2.5" />}
{fault ? "Fault" : online ? "Online" : "Unk"}
</span>
<span className="text-muted-foreground">Supply: <span className="text-foreground font-medium">{crac.supply_temp !== null ? `${crac.supply_temp}°C` : "—"}</span></span>
<span className="text-muted-foreground">Return: <span className="text-foreground font-medium">{crac.return_temp !== null ? `${crac.return_temp}°C` : "—"}</span></span>
<span className="text-muted-foreground">Fan: <span className="text-foreground font-medium">{crac.fan_pct !== null ? `${crac.fan_pct}%` : "—"}</span></span>
</div>
);
}
// ── Compact UPS row ───────────────────────────────────────────────────────────
function UpsRow({ ups }: { ups: UpsAsset }) {
const onBattery = ups.state === "battery";
return (
<div className="flex items-center gap-3 px-3 py-2 text-xs">
<Battery className="w-3.5 h-3.5 text-primary shrink-0" />
<span className="font-semibold font-mono w-20 shrink-0">{ups.ups_id.toUpperCase()}</span>
<span className={cn(
"flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded-full uppercase tracking-wide shrink-0",
onBattery ? "bg-amber-500/10 text-amber-400" :
ups.state === "online" ? "bg-green-500/10 text-green-400" :
"bg-muted text-muted-foreground",
)}>
{onBattery ? <AlertTriangle className="w-2.5 h-2.5" /> : <CheckCircle2 className="w-2.5 h-2.5" />}
{onBattery ? "Battery" : ups.state === "online" ? "Mains" : "Unk"}
</span>
<span className="text-muted-foreground">Charge: <span className="text-foreground font-medium">{ups.charge_pct !== null ? `${ups.charge_pct}%` : "—"}</span></span>
<span className="text-muted-foreground">Load: <span className="text-foreground font-medium">{ups.load_pct !== null ? `${ups.load_pct}%` : "—"}</span></span>
</div>
);
}
// ── Rack sortable table ───────────────────────────────────────────────────────
type RackSortCol = "rack_id" | "temp" | "power_kw" | "power_pct" | "alarm_count" | "status";
type SortDir = "asc" | "desc";
function RackTable({
racks, roomId, statusFilter, onRackClick,
}: {
racks: RackAsset[];
roomId: string;
statusFilter: "all" | "warning" | "critical";
onRackClick: (id: string) => void;
}) {
const [sortCol, setSortCol] = useState<RackSortCol>("rack_id");
const [sortDir, setSortDir] = useState<SortDir>("asc");
function toggleSort(col: RackSortCol) {
if (sortCol === col) setSortDir(d => d === "asc" ? "desc" : "asc");
else { setSortCol(col); setSortDir("asc"); }
}
function SortIcon({ col }: { col: RackSortCol }) {
if (sortCol !== col) return <span className="opacity-30 ml-0.5"></span>;
return <span className="ml-0.5">{sortDir === "asc" ? "↑" : "↓"}</span>;
}
const filtered = useMemo(() => {
const base = statusFilter === "all" ? racks : racks.filter(r => r.status === statusFilter);
return [...base].sort((a, b) => {
let cmp = 0;
if (sortCol === "temp" || sortCol === "power_kw" || sortCol === "alarm_count") {
cmp = ((a[sortCol] ?? 0) as number) - ((b[sortCol] ?? 0) as number);
} else if (sortCol === "power_pct") {
const aP = a.power_kw !== null ? a.power_kw / 10 * 100 : 0;
const bP = b.power_kw !== null ? b.power_kw / 10 * 100 : 0;
cmp = aP - bP;
} else {
cmp = String(a[sortCol]).localeCompare(String(b[sortCol]));
}
return sortDir === "asc" ? cmp : -cmp;
});
}, [racks, statusFilter, sortCol, sortDir]);
type ColDef = { col: RackSortCol; label: string };
const cols: ColDef[] = [
{ col: "rack_id", label: "Rack ID" },
{ col: "temp", label: "Temp (°C)" },
{ col: "power_kw", label: "Power (kW)" },
{ col: "power_pct", label: "Power%" },
{ col: "alarm_count", label: "Alarms" },
{ col: "status", label: "Status" },
];
return (
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-muted/40 text-muted-foreground select-none">
{cols.map(({ col, label }) => (
<th key={col} className="text-left px-3 py-2">
<button
onClick={() => toggleSort(col)}
className="font-semibold hover:text-foreground transition-colors flex items-center gap-0.5"
>
{label}<SortIcon col={col} />
</button>
</th>
))}
{/* Room column header */}
<th className="text-left px-3 py-2 font-semibold text-muted-foreground">Room</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-8 text-muted-foreground">No racks matching this filter</td>
</tr>
) : (
filtered.map(rack => {
const powerPct = rack.power_kw !== null ? (rack.power_kw / 10) * 100 : null;
const tempCls = rack.temp !== null
? rack.temp >= 30 ? "text-destructive" : rack.temp >= 28 ? "text-amber-400" : ""
: "";
const pctCls = powerPct !== null
? powerPct >= 85 ? "text-destructive" : powerPct >= 75 ? "text-amber-400" : ""
: "";
const s = statusStyles[rack.status] ?? statusStyles.unknown;
return (
<tr
key={rack.rack_id}
onClick={() => onRackClick(rack.rack_id)}
className="border-b border-border/40 last:border-0 hover:bg-muted/20 transition-colors cursor-pointer"
>
<td className="px-3 py-2 font-mono font-semibold">{rack.rack_id.toUpperCase()}</td>
<td className={cn("px-3 py-2 tabular-nums font-medium", tempCls)}>
{rack.temp !== null ? rack.temp : "—"}
</td>
<td className="px-3 py-2 tabular-nums text-muted-foreground">
{rack.power_kw !== null ? rack.power_kw : "—"}
</td>
<td className={cn("px-3 py-2 tabular-nums font-medium", pctCls)}>
{powerPct !== null ? `${powerPct.toFixed(0)}%` : "—"}
</td>
<td className="px-3 py-2">
{rack.alarm_count > 0
? <span className="font-bold text-destructive">{rack.alarm_count}</span>
: <span className="text-muted-foreground">0</span>}
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<span className={cn("w-2 h-2 rounded-full", s.dot)} />
<span className="capitalize text-muted-foreground">{rack.status}</span>
</div>
</td>
<td className="px-3 py-2 text-muted-foreground">{roomLabels[roomId] ?? roomId}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
);
}
// ── Inventory table ───────────────────────────────────────────────────────────
type SortCol = "name" | "type" | "rack_id" | "room_id" | "u_start" | "power_draw_w";
function InventoryTable({ siteId, onRackClick }: { siteId: string; onRackClick: (rackId: string) => void }) {
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [roomFilter, setRoomFilter] = useState<string>("all");
const [sortCol, setSortCol] = useState<SortCol>("name");
const [sortDir, setSortDir] = useState<SortDir>("asc");
useEffect(() => {
fetchAllDevices(siteId)
.then(setDevices)
.catch(() => {})
.finally(() => setLoading(false));
}, [siteId]);
function toggleSort(col: SortCol) {
if (sortCol === col) setSortDir(d => d === "asc" ? "desc" : "asc");
else { setSortCol(col); setSortDir("asc"); }
}
function SortIcon({ col }: { col: SortCol }) {
if (sortCol !== col) return <span className="opacity-30 ml-0.5"></span>;
return <span className="ml-0.5">{sortDir === "asc" ? "↑" : "↓"}</span>;
}
const filtered = useMemo(() => {
const q = search.toLowerCase();
const base = devices.filter(d => {
if (typeFilter !== "all" && d.type !== typeFilter) return false;
if (roomFilter !== "all" && d.room_id !== roomFilter) return false;
if (q && !d.name.toLowerCase().includes(q) && !d.rack_id.includes(q) && !d.ip.includes(q) && !d.serial.toLowerCase().includes(q)) return false;
return true;
});
return [...base].sort((a, b) => {
let cmp = 0;
if (sortCol === "power_draw_w" || sortCol === "u_start") {
cmp = (a[sortCol] ?? 0) - (b[sortCol] ?? 0);
} else {
cmp = String(a[sortCol]).localeCompare(String(b[sortCol]));
}
return sortDir === "asc" ? cmp : -cmp;
});
}, [devices, search, typeFilter, roomFilter, sortCol, sortDir]);
const totalPower = filtered.reduce((s, d) => s + d.power_draw_w, 0);
const types = Array.from(new Set(devices.map(d => d.type))).sort();
function downloadCsv() {
const headers = ["Device", "Type", "Rack", "Room", "U Start", "U Height", "IP", "Serial", "Power (W)", "Status"];
const rows = filtered.map((d) => [
d.name, TYPE_STYLES[d.type]?.label ?? d.type, d.rack_id.toUpperCase(),
roomLabels[d.room_id] ?? d.room_id, d.u_start, d.u_height,
d.ip !== "-" ? d.ip : "", d.serial, d.power_draw_w, d.status,
]);
const csv = [headers, ...rows]
.map((r) => r.map((v) => `"${String(v ?? "").replace(/"/g, '""')}"`).join(","))
.join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement("a"), {
href: url, download: `bms-inventory-${new Date().toISOString().slice(0, 10)}.csv`,
});
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("Export downloaded");
}
if (loading) {
return (
<div className="space-y-2 mt-4">
{Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
);
}
return (
<div className="space-y-4">
{/* Device type legend */}
<div className="flex flex-wrap gap-3 items-center">
{Object.entries(TYPE_STYLES).map(([key, { dot, label }]) => (
<div key={key} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className={cn("w-2.5 h-2.5 rounded-full shrink-0", dot)} />
{label}
</div>
))}
</div>
{/* Filters */}
<div className="flex flex-wrap gap-2 items-center">
<input
type="text"
placeholder="Search name, rack, IP, serial…"
value={search}
onChange={e => setSearch(e.target.value)}
className="flex-1 min-w-48 h-8 rounded-md border border-border bg-muted/30 px-3 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
/>
<select
value={typeFilter}
onChange={e => setTypeFilter(e.target.value)}
className="h-8 rounded-md border border-border bg-muted/30 px-2 text-xs focus:outline-none"
>
<option value="all">All types</option>
{types.map(t => <option key={t} value={t}>{TYPE_STYLES[t]?.label ?? t}</option>)}
</select>
<select
value={roomFilter}
onChange={e => setRoomFilter(e.target.value)}
className="h-8 rounded-md border border-border bg-muted/30 px-2 text-xs focus:outline-none"
>
<option value="all">All rooms</option>
<option value="hall-a">Hall A</option>
<option value="hall-b">Hall B</option>
</select>
<span className="text-xs text-muted-foreground ml-auto">
{filtered.length} devices · {(totalPower / 1000).toFixed(1)} kW
</span>
<button
onClick={downloadCsv}
className="flex items-center gap-1.5 h-8 px-3 rounded-md border border-border bg-muted/30 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
<Download className="w-3.5 h-3.5" /> Export CSV
</button>
</div>
{/* Table */}
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-muted/40 text-muted-foreground select-none">
{([
{ col: "name" as SortCol, label: "Device", cls: "text-left px-3 py-2" },
{ col: "type" as SortCol, label: "Type", cls: "text-left px-3 py-2" },
{ col: "rack_id" as SortCol, label: "Rack", cls: "text-left px-3 py-2" },
{ col: "room_id" as SortCol, label: "Room", cls: "text-left px-3 py-2 hidden sm:table-cell" },
{ col: "u_start" as SortCol, label: "U", cls: "text-left px-3 py-2 hidden md:table-cell" },
]).map(({ col, label, cls }) => (
<th key={col} className={cls}>
<button
onClick={() => toggleSort(col)}
className="font-semibold hover:text-foreground transition-colors flex items-center gap-0.5"
>
{label}<SortIcon col={col} />
</button>
</th>
))}
<th className="text-left px-3 py-2 font-semibold hidden md:table-cell">IP</th>
<th className="text-right px-3 py-2">
<button
onClick={() => toggleSort("power_draw_w")}
className="font-semibold hover:text-foreground transition-colors flex items-center gap-0.5 ml-auto"
>
Power<SortIcon col="power_draw_w" />
</button>
</th>
<th className="text-left px-3 py-2 font-semibold">Status</th>
<th className="text-left px-3 py-2 font-semibold hidden lg:table-cell">Lifecycle</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td colSpan={9} className="text-center py-8 text-muted-foreground">
No devices match your filters.
</td>
</tr>
) : (
filtered.map(d => {
const ts = TYPE_STYLES[d.type];
return (
<tr key={d.device_id} className="border-b border-border/40 last:border-0 hover:bg-muted/20 transition-colors cursor-pointer" onClick={() => onRackClick(d.rack_id)}>
<td className="px-3 py-2">
<div className="font-medium truncate max-w-[180px]">{d.name}</div>
<div className="text-[10px] text-muted-foreground font-mono">{d.serial}</div>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<span className={cn("w-1.5 h-1.5 rounded-full shrink-0", ts?.dot ?? "bg-muted")} />
<span className="text-muted-foreground">{ts?.label ?? d.type}</span>
</div>
</td>
<td className="px-3 py-2 font-mono">{d.rack_id.toUpperCase()}</td>
<td className="px-3 py-2 hidden sm:table-cell text-muted-foreground">
{roomLabels[d.room_id] ?? d.room_id}
</td>
<td className="px-3 py-2 hidden md:table-cell text-muted-foreground font-mono">
U{d.u_start}{d.u_height > 1 ? `U${d.u_start + d.u_height - 1}` : ""}
</td>
<td className="px-3 py-2 hidden md:table-cell font-mono text-muted-foreground">
{d.ip !== "-" ? d.ip : "—"}
</td>
<td className="px-3 py-2 text-right font-mono">{d.power_draw_w} W</td>
<td className="px-3 py-2">
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-green-500/10 text-green-400">
online
</span>
</td>
<td className="px-3 py-2 hidden lg:table-cell">
<span className={cn(
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full",
d.status === "online" ? "bg-blue-500/10 text-blue-400" :
d.status === "offline" ? "bg-amber-500/10 text-amber-400" :
"bg-muted/50 text-muted-foreground"
)}>
{d.status === "online" ? "Active" : d.status === "offline" ? "Offline" : "Unknown"}
</span>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
);
}
// ── PDU Monitoring ────────────────────────────────────────────────────────────
function PduMonitoringSection({ siteId }: { siteId: string }) {
const [pdus, setPdus] = useState<PduReading[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchPduReadings(siteId)
.then(setPdus)
.catch(() => {})
.finally(() => setLoading(false));
const id = setInterval(() => fetchPduReadings(siteId).then(setPdus).catch(() => {}), 30_000);
return () => clearInterval(id);
}, [siteId]);
const critical = pdus.filter(p => p.status === "critical").length;
const warning = pdus.filter(p => p.status === "warning").length;
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Zap className="w-4 h-4 text-amber-400" /> PDU Phase Monitoring
</CardTitle>
<div className="flex items-center gap-2 text-[10px]">
{critical > 0 && <span className="text-destructive font-semibold">{critical} critical</span>}
{warning > 0 && <span className="text-amber-400 font-semibold">{warning} warning</span>}
{critical === 0 && warning === 0 && !loading && (
<span className="text-green-400 font-semibold">All balanced</span>
)}
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<div className="p-4 space-y-2">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-8 w-full" />)}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-muted/30 text-muted-foreground">
<th className="text-left px-4 py-2 font-semibold">Rack</th>
<th className="text-left px-4 py-2 font-semibold hidden sm:table-cell">Room</th>
<th className="text-right px-4 py-2 font-semibold">Total kW</th>
<th className="text-right px-4 py-2 font-semibold hidden md:table-cell">Ph-A kW</th>
<th className="text-right px-4 py-2 font-semibold hidden md:table-cell">Ph-B kW</th>
<th className="text-right px-4 py-2 font-semibold hidden md:table-cell">Ph-C kW</th>
<th className="text-right px-4 py-2 font-semibold hidden lg:table-cell">Ph-A A</th>
<th className="text-right px-4 py-2 font-semibold hidden lg:table-cell">Ph-B A</th>
<th className="text-right px-4 py-2 font-semibold hidden lg:table-cell">Ph-C A</th>
<th className="text-right px-4 py-2 font-semibold">Imbalance</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{pdus.map(p => (
<tr key={p.rack_id} className={cn(
"hover:bg-muted/20 transition-colors",
p.status === "critical" && "bg-destructive/5",
p.status === "warning" && "bg-amber-500/5",
)}>
<td className="px-4 py-2 font-mono font-medium">{p.rack_id.toUpperCase().replace("RACK-", "")}</td>
<td className="px-4 py-2 hidden sm:table-cell text-muted-foreground">
{roomLabels[p.room_id] ?? p.room_id}
</td>
<td className="px-4 py-2 text-right tabular-nums font-medium">
{p.total_kw !== null ? p.total_kw.toFixed(2) : "—"}
</td>
<td className="px-4 py-2 text-right tabular-nums hidden md:table-cell text-muted-foreground">
{p.phase_a_kw !== null ? p.phase_a_kw.toFixed(2) : "—"}
</td>
<td className="px-4 py-2 text-right tabular-nums hidden md:table-cell text-muted-foreground">
{p.phase_b_kw !== null ? p.phase_b_kw.toFixed(2) : "—"}
</td>
<td className="px-4 py-2 text-right tabular-nums hidden md:table-cell text-muted-foreground">
{p.phase_c_kw !== null ? p.phase_c_kw.toFixed(2) : "—"}
</td>
<td className="px-4 py-2 text-right tabular-nums hidden lg:table-cell text-muted-foreground">
{p.phase_a_a !== null ? p.phase_a_a.toFixed(1) : "—"}
</td>
<td className="px-4 py-2 text-right tabular-nums hidden lg:table-cell text-muted-foreground">
{p.phase_b_a !== null ? p.phase_b_a.toFixed(1) : "—"}
</td>
<td className="px-4 py-2 text-right tabular-nums hidden lg:table-cell text-muted-foreground">
{p.phase_c_a !== null ? p.phase_c_a.toFixed(1) : "—"}
</td>
<td className="px-4 py-2 text-right tabular-nums">
{p.imbalance_pct !== null ? (
<span className={cn(
"font-semibold",
p.status === "critical" ? "text-destructive" :
p.status === "warning" ? "text-amber-400" : "text-green-400",
)}>
{p.imbalance_pct.toFixed(1)}%
</span>
) : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function AssetsPage() {
const [data, setData] = useState<AssetsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [selectedRack, setSelectedRack] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<"all" | "warning" | "critical">("all");
const [view, setView] = useState<"grid" | "inventory">("grid");
async function load() {
try { const d = await fetchAssets(SITE_ID); setData(d); setError(false); }
catch { setError(true); toast.error("Failed to load asset data"); }
finally { setLoading(false); }
}
useEffect(() => {
load();
const id = setInterval(load, 30_000);
return () => clearInterval(id);
}, []);
if (loading) {
return (
<div className="p-6 space-y-4">
<Skeleton className="h-8 w-48" />
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-5 gap-3">
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="h-20" />)}
</div>
</div>
);
}
if (error || !data) {
return (
<div className="p-6 flex items-center justify-center h-64 text-sm text-muted-foreground">
Unable to load asset data.
</div>
);
}
const defaultTab = data.rooms[0]?.room_id ?? "";
const totalRacks = data.rooms.reduce((s, r) => s + r.racks.length, 0);
const critCount = data.rooms.flatMap(r => r.racks).filter(r => r.status === "critical").length;
const warnCount = data.rooms.flatMap(r => r.racks).filter(r => r.status === "warning").length;
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold">Asset Registry</h1>
<p className="text-sm text-muted-foreground">
Singapore DC01 · {totalRacks} racks
{critCount > 0 && <span className="text-destructive ml-2">· {critCount} critical</span>}
{warnCount > 0 && <span className="text-amber-400 ml-2">· {warnCount} warning</span>}
</p>
</div>
{/* View toggle */}
<div className="flex items-center gap-1 rounded-lg border border-border p-1">
<button
onClick={() => setView("grid")}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors",
view === "grid" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
<LayoutGrid className="w-3.5 h-3.5" /> Grid
</button>
<button
onClick={() => setView("inventory")}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors",
view === "inventory" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
<List className="w-3.5 h-3.5" /> Inventory
</button>
</div>
</div>
<RackDetailSheet siteId={SITE_ID} rackId={selectedRack} onClose={() => setSelectedRack(null)} />
{view === "inventory" ? (
<InventoryTable siteId={SITE_ID} onRackClick={setSelectedRack} />
) : (
<>
{/* Compact UPS + CRAC rows */}
<div className="rounded-lg border border-border divide-y divide-border/50">
{data.ups_units.map(ups => <UpsRow key={ups.ups_id} ups={ups} />)}
{data.rooms.map(room => <CracRow key={room.crac.crac_id} crac={room.crac} />)}
</div>
{/* PDU phase monitoring */}
<PduMonitoringSection siteId={SITE_ID} />
{/* Per-room rack table */}
<Tabs defaultValue={defaultTab}>
<TabsList>
{data.rooms.map(room => (
<TabsTrigger key={room.room_id} value={room.room_id}>
{roomLabels[room.room_id] ?? room.room_id}
</TabsTrigger>
))}
</TabsList>
{data.rooms.map(room => {
const rWarn = room.racks.filter(r => r.status === "warning").length;
const rCrit = room.racks.filter(r => r.status === "critical").length;
return (
<TabsContent key={room.room_id} value={room.room_id} className="space-y-4 mt-4">
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Racks {roomLabels[room.room_id] ?? room.room_id}
</h2>
<div className="flex items-center gap-1">
{(["all", "warning", "critical"] as const).map(f => (
<button
key={f}
onClick={() => setStatusFilter(f)}
className={cn(
"px-2.5 py-1 rounded-md text-xs font-medium capitalize transition-colors",
statusFilter === f
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
{f === "all" ? `All (${room.racks.length})`
: f === "warning" ? `Warn (${rWarn})`
: `Crit (${rCrit})`}
</button>
))}
</div>
</div>
<RackTable
racks={room.racks}
roomId={room.room_id}
statusFilter={statusFilter}
onRackClick={setSelectedRack}
/>
</div>
</TabsContent>
);
})}
</Tabs>
</>
)}
</div>
);
}