703 lines
32 KiB
TypeScript
703 lines
32 KiB
TypeScript
"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>
|
||
);
|
||
}
|