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

963 lines
43 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, useCallback, useRef } from "react";
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
import {
fetchCapacitySummary, fetchCracStatus, fetchAlarms, fetchLeakStatus,
fetchFloorLayout, saveFloorLayout,
type CapacitySummary, type CracStatus, type Alarm, type LeakSensorStatus,
} 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 { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Wind, WifiOff, Thermometer, Zap, CheckCircle2, AlertTriangle,
Droplets, Cable, Flame, Snowflake, Settings2, Plus, Trash2,
GripVertical, ChevronDown, ChevronUp,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useThresholds } from "@/lib/threshold-context";
const SITE_ID = "sg-01";
// ── Layout type ───────────────────────────────────────────────────────────────
type RowLayout = { label: string; racks: string[] };
type RoomLayout = { label: string; crac_id: string; rows: RowLayout[] };
type FloorLayout = Record<string, RoomLayout>;
const DEFAULT_LAYOUT: FloorLayout = {
"hall-a": {
label: "Hall A",
crac_id: "crac-01",
rows: [
{ label: "Row 1", racks: Array.from({ length: 20 }, (_, i) => `SG1A01.${String(i + 1).padStart(2, "0")}`) },
{ label: "Row 2", racks: Array.from({ length: 20 }, (_, i) => `SG1A02.${String(i + 1).padStart(2, "0")}`) },
],
},
"hall-b": {
label: "Hall B",
crac_id: "crac-02",
rows: [
{ label: "Row 1", racks: Array.from({ length: 20 }, (_, i) => `SG1B01.${String(i + 1).padStart(2, "0")}`) },
{ label: "Row 2", racks: Array.from({ length: 20 }, (_, i) => `SG1B02.${String(i + 1).padStart(2, "0")}`) },
],
},
};
// Derive feed from row index: even index → "A", odd → "B"
function getFeed(layout: FloorLayout, rackId: string): "A" | "B" | undefined {
for (const room of Object.values(layout)) {
for (let i = 0; i < room.rows.length; i++) {
if (room.rows[i].racks.includes(rackId)) return i % 2 === 0 ? "A" : "B";
}
}
return undefined;
}
// ── Colour helpers ────────────────────────────────────────────────────────────
function tempBg(temp: number | null, warn = 26, crit = 28) {
if (temp === null) return "oklch(0.22 0.02 265)";
if (temp >= crit + 4) return "oklch(0.55 0.22 25)";
if (temp >= crit) return "oklch(0.65 0.20 45)";
if (temp >= warn) return "oklch(0.72 0.18 84)";
if (temp >= warn - 2) return "oklch(0.78 0.14 140)";
if (temp >= warn - 4) return "oklch(0.68 0.14 162)";
return "oklch(0.60 0.15 212)";
}
function powerBg(pct: number | null) {
if (pct === null) return "oklch(0.22 0.02 265)";
if (pct >= 90) return "oklch(0.55 0.22 25)";
if (pct >= 75) return "oklch(0.65 0.20 45)";
if (pct >= 55) return "oklch(0.72 0.18 84)";
if (pct >= 35) return "oklch(0.68 0.14 162)";
return "oklch(0.60 0.15 212)";
}
type Overlay = "temp" | "power" | "alarms" | "feed" | "crac";
function alarmBg(count: number): string {
if (count === 0) return "oklch(0.22 0.02 265)";
if (count >= 3) return "oklch(0.55 0.22 25)";
if (count >= 1) return "oklch(0.65 0.20 45)";
return "oklch(0.68 0.14 162)";
}
function feedBg(feed: "A" | "B" | undefined): string {
if (feed === "A") return "oklch(0.55 0.18 255)";
if (feed === "B") return "oklch(0.60 0.18 40)";
return "oklch(0.22 0.02 265)";
}
const CRAC_ZONE_COLORS = [
"oklch(0.55 0.18 255)", // blue — zone 1
"oklch(0.60 0.18 40)", // amber — zone 2
"oklch(0.60 0.16 145)", // teal — zone 3
"oklch(0.58 0.18 310)", // purple — zone 4
];
// ── Rack tile ─────────────────────────────────────────────────────────────────
function RackTile({
rackId, temp, powerPct, alarmCount, overlay, feed, cracColor, onClick, tempWarn = 26, tempCrit = 28,
}: {
rackId: string; temp: number | null; powerPct: number | null;
alarmCount: number; overlay: Overlay; feed?: "A" | "B"; cracColor?: string; onClick: () => void;
tempWarn?: number; tempCrit?: number;
}) {
const offline = temp === null && powerPct === null;
const bg = offline ? "oklch(0.22 0.02 265)"
: overlay === "temp" ? tempBg(temp, tempWarn, tempCrit)
: overlay === "power" ? powerBg(powerPct)
: overlay === "feed" ? feedBg(feed)
: overlay === "crac" ? (cracColor ?? "oklch(0.22 0.02 265)")
: alarmBg(alarmCount);
const shortId = rackId.replace("rack-", "").toUpperCase();
const mainVal = overlay === "temp" ? (temp !== null ? `${temp}°` : null)
: overlay === "power" ? (powerPct !== null ? `${Math.round(powerPct)}%` : null)
: overlay === "feed" ? (feed ?? null)
: (alarmCount > 0 ? String(alarmCount) : null);
const subVal = overlay === "temp" ? (powerPct !== null ? `${Math.round(powerPct)}%` : null)
: overlay === "power" ? (temp !== null ? `${temp}°C` : null)
: overlay === "feed" ? (temp !== null ? `${temp}°C` : null)
: (temp !== null ? `${temp}°C` : null);
return (
<button
onClick={onClick}
title={`${rackId}${temp ?? "—"}°C · ${powerPct != null ? Math.round(powerPct) : "—"}% load · ${alarmCount} alarm${alarmCount !== 1 ? "s" : ""}`}
aria-label={`${rackId}${temp ?? "—"}°C · ${powerPct != null ? Math.round(powerPct) : "—"}% load · ${alarmCount} alarm${alarmCount !== 1 ? "s" : ""}`}
className="group relative flex flex-col items-center justify-center gap-0.5 rounded-lg cursor-pointer select-none transition-all duration-200 hover:ring-2 hover:ring-white/40 hover:scale-105 active:scale-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
style={{
backgroundColor: bg,
width: 76, height: 92,
backgroundImage: offline
? "repeating-linear-gradient(45deg,transparent,transparent 5px,oklch(1 0 0/5%) 5px,oklch(1 0 0/5%) 10px)"
: undefined,
}}
>
<span className="text-[9px] font-bold text-white/60 tracking-widest">{shortId}</span>
{offline ? (
<WifiOff className="w-4 h-4 text-white/30" />
) : overlay === "alarms" && alarmCount === 0 ? (
<CheckCircle2 className="w-4 h-4 text-white/40" />
) : (
<span className="text-[17px] font-bold text-white leading-none">{mainVal ?? "—"}</span>
)}
{subVal && (
<span className="text-[9px] text-white/55 opacity-0 group-hover:opacity-100 transition-opacity">{subVal}</span>
)}
{overlay === "temp" && powerPct !== null && (
<div className="absolute bottom-1.5 left-2 right-2 h-[3px] rounded-full bg-white/15 overflow-hidden">
<div className="h-full rounded-full bg-white/50" style={{ width: `${powerPct}%` }} />
</div>
)}
{overlay !== "alarms" && alarmCount > 0 && (
<div className="absolute top-1 right-1 w-3.5 h-3.5 rounded-full bg-destructive flex items-center justify-center">
<span className="text-[8px] font-bold text-white leading-none">{alarmCount}</span>
</div>
)}
</button>
);
}
// ── CRAC strip ────────────────────────────────────────────────────────────────
function CracStrip({ crac }: { crac: CracStatus | undefined }) {
const online = crac?.state === "online";
return (
<div className={cn(
"flex items-center gap-4 rounded-lg px-4 py-2.5 border text-sm",
online ? "bg-primary/5 border-primary/20" : "bg-destructive/5 border-destructive/20"
)}>
<Wind className={cn("w-4 h-4 shrink-0", online ? "text-primary" : "text-destructive")} />
<div className="flex items-center gap-2">
<span className="font-semibold text-xs">{crac?.crac_id.toUpperCase() ?? "CRAC"}</span>
<span className={cn(
"flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase",
online ? "bg-green-500/10 text-green-400" : "bg-destructive/10 text-destructive"
)}>
{online ? <CheckCircle2 className="w-3 h-3" /> : <AlertTriangle className="w-3 h-3" />}
{online ? "Online" : "Fault"}
</span>
</div>
{crac && online && (
<div className="flex items-center gap-4 ml-2 text-xs text-muted-foreground">
<span>
<Thermometer className="w-3 h-3 inline mr-0.5 text-primary" />
Supply <strong className="text-foreground">{crac.supply_temp ?? "—"}°C</strong>
</span>
<span>
<Thermometer className="w-3 h-3 inline mr-0.5 text-orange-400" />
Return <strong className="text-foreground">{crac.return_temp ?? "—"}°C</strong>
</span>
{crac.delta !== null && (
<span>ΔT <strong className={cn(
crac.delta > 14 ? "text-destructive" : crac.delta > 11 ? "text-amber-400" : "text-green-400"
)}>+{crac.delta}°C</strong></span>
)}
{crac.fan_pct !== null && (
<span>Fan <strong className="text-foreground">{crac.fan_pct}%</strong></span>
)}
{crac.cooling_capacity_pct !== null && (
<span>Cap <strong className={cn(
(crac.cooling_capacity_pct ?? 0) >= 90 ? "text-destructive" :
(crac.cooling_capacity_pct ?? 0) >= 75 ? "text-amber-400" : "text-foreground"
)}>{crac.cooling_capacity_pct?.toFixed(0)}%</strong></span>
)}
</div>
)}
</div>
);
}
// ── Room plan ─────────────────────────────────────────────────────────────────
function RoomPlan({
roomId, layout, data, cracs, overlay, alarmsByRack, onRackClick, tempWarn = 26, tempCrit = 28,
}: {
roomId: string;
layout: FloorLayout;
data: CapacitySummary;
cracs: CracStatus[];
overlay: Overlay;
alarmsByRack: Map<string, number>;
onRackClick: (id: string) => void;
tempWarn?: number;
tempCrit?: number;
}) {
const roomLayout = layout[roomId];
if (!roomLayout) return null;
const rackMap = new Map(data.racks.map((r) => [r.rack_id, r]));
const crac = cracs.find((c) => c.crac_id === roomLayout.crac_id);
const roomRacks = data.racks.filter((r) => r.room_id === roomId);
const offlineCount = roomRacks.filter((r) => r.temp === null && r.power_kw === null).length;
const avgTemp = (() => {
const temps = roomRacks.map((r) => r.temp).filter((t): t is number => t !== null);
return temps.length ? Math.round((temps.reduce((a, b) => a + b, 0) / temps.length) * 10) / 10 : null;
})();
const totalPower = (() => {
const powers = roomRacks.map((r) => r.power_kw).filter((p): p is number => p !== null);
return powers.length ? Math.round(powers.reduce((a, b) => a + b, 0) * 10) / 10 : null;
})();
return (
<div className="space-y-4">
<div className="flex items-center gap-6 text-xs text-muted-foreground">
<span>{roomRacks.length} racks</span>
{avgTemp !== null && (
<span className="flex items-center gap-1">
<Thermometer className="w-3 h-3" />
Avg <strong className="text-foreground">{avgTemp}°C</strong>
</span>
)}
{totalPower !== null && (
<span className="flex items-center gap-1">
<Zap className="w-3 h-3" />
<strong className="text-foreground">{totalPower} kW</strong> IT load
</span>
)}
{offlineCount > 0 && (
<span className="flex items-center gap-1 text-muted-foreground/60">
<WifiOff className="w-3 h-3" />
{offlineCount} offline
</span>
)}
</div>
<TransformWrapper initialScale={1} minScale={0.4} maxScale={3} limitToBounds={false} wheel={{ disabled: true }}>
{({ zoomIn, zoomOut, resetTransform }) => (
<>
<div className="flex items-center gap-1 mb-2">
<button
onClick={() => zoomIn()}
className="px-2 py-1 rounded border border-border text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
title="Zoom in"
>+</button>
<button
onClick={() => resetTransform()}
className="px-2 py-1 rounded border border-border text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
title="Reset zoom"
></button>
<button
onClick={() => zoomOut()}
className="px-2 py-1 rounded border border-border text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
title="Zoom out"
></button>
<span className="text-[10px] text-muted-foreground/50 ml-1">Drag to pan</span>
</div>
<TransformComponent wrapperStyle={{ width: "100%", overflow: "hidden", borderRadius: "0.75rem" }}>
<div className="rounded-xl border border-border bg-muted/10 p-5 space-y-3" style={{ minWidth: "100%" }}>
<CracStrip crac={crac} />
<div
className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-widest px-2 rounded-md py-2"
style={{ backgroundColor: "oklch(0.55 0.22 25 / 6%)", color: "oklch(0.65 0.20 45)" }}
>
<div className="flex-1 h-px" style={{ backgroundColor: "oklch(0.65 0.20 45 / 30%)" }} />
<Flame className="w-3 h-3 shrink-0" style={{ color: "oklch(0.65 0.20 45)" }} />
HOT AISLE
<Flame className="w-3 h-3 shrink-0" style={{ color: "oklch(0.65 0.20 45)" }} />
<div className="flex-1 h-px" style={{ backgroundColor: "oklch(0.65 0.20 45 / 30%)" }} />
</div>
{roomLayout.rows.map((row, rowIdx) => {
const rowCracColor = CRAC_ZONE_COLORS[rowIdx % CRAC_ZONE_COLORS.length];
return (
<div key={rowIdx}>
<div className="flex items-center gap-2">
<span className="text-[9px] text-muted-foreground/40 uppercase tracking-widest w-10 shrink-0 text-right">
{row.label}
</span>
<div className="flex gap-2 flex-wrap">
{row.racks.map((rackId) => {
const rack = rackMap.get(rackId);
return (
<RackTile
key={rackId}
rackId={rackId}
temp={rack?.temp ?? null}
powerPct={rack?.power_pct ?? null}
alarmCount={alarmsByRack.get(rackId) ?? 0}
overlay={overlay}
feed={getFeed(layout, rackId)}
cracColor={rowCracColor}
onClick={() => onRackClick(rackId)}
tempWarn={tempWarn}
tempCrit={tempCrit}
/>
);
})}
</div>
</div>
{rowIdx < roomLayout.rows.length - 1 && (
<div
className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-widest px-2 mt-2 mb-1 rounded-md py-2"
style={{ backgroundColor: "oklch(0.62 0.17 212 / 7%)", color: "oklch(0.62 0.17 212)" }}
>
<div className="flex-1 h-px border-t border-dashed" style={{ borderColor: "oklch(0.62 0.17 212 / 30%)" }} />
<Snowflake className="w-3 h-3 shrink-0" style={{ color: "oklch(0.62 0.17 212)" }} />
COLD AISLE
<Snowflake className="w-3 h-3 shrink-0" style={{ color: "oklch(0.62 0.17 212)" }} />
<div className="flex-1 h-px border-t border-dashed" style={{ borderColor: "oklch(0.62 0.17 212 / 30%)" }} />
</div>
)}
</div>
);
})}
<div
className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-widest px-2 rounded-md py-2"
style={{ backgroundColor: "oklch(0.55 0.22 25 / 6%)", color: "oklch(0.65 0.20 45)" }}
>
<div className="flex-1 h-px" style={{ backgroundColor: "oklch(0.65 0.20 45 / 30%)" }} />
<Flame className="w-3 h-3 shrink-0" style={{ color: "oklch(0.65 0.20 45)" }} />
HOT AISLE
<Flame className="w-3 h-3 shrink-0" style={{ color: "oklch(0.65 0.20 45)" }} />
<div className="flex-1 h-px" style={{ backgroundColor: "oklch(0.65 0.20 45 / 30%)" }} />
</div>
</div>
</TransformComponent>
</>
)}
</TransformWrapper>
</div>
);
}
// ── Leak sensor panel ─────────────────────────────────────────────────────────
function LeakSensorPanel({ sensors }: { sensors: LeakSensorStatus[] }) {
if (sensors.length === 0) return null;
const active = sensors.filter((s) => s.state === "detected");
const byZone = sensors.reduce<Record<string, LeakSensorStatus[]>>((acc, s) => {
const zone = s.floor_zone ?? "unknown";
(acc[zone] ??= []).push(s);
return acc;
}, {});
return (
<Card className={cn("border", active.length > 0 && "border-destructive/50")}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Droplets className={cn("w-4 h-4", active.length > 0 ? "text-destructive" : "text-blue-400")} />
Leak Sensor Status
</CardTitle>
<span className={cn(
"text-[10px] font-semibold px-2.5 py-0.5 rounded-full uppercase",
active.length > 0 ? "bg-destructive/10 text-destructive" : "bg-green-500/10 text-green-400",
)}>
{active.length > 0 ? `${active.length} leak${active.length > 1 ? "s" : ""} detected` : "All clear"}
</span>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{Object.entries(byZone).map(([zone, zoneSensors]) => (
<div key={zone} className="rounded-lg border border-border/50 bg-muted/10 p-3 space-y-2">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{zone}</p>
{zoneSensors.map((s) => {
const detected = s.state === "detected";
return (
<div key={s.sensor_id} className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<div className={cn(
"w-2 h-2 rounded-full shrink-0",
detected ? "bg-destructive animate-pulse" :
s.state === "unknown" ? "bg-muted-foreground/30" : "bg-green-500",
)} />
<span className="text-xs font-medium truncate">{s.sensor_id}</span>
</div>
<p className="text-[9px] text-muted-foreground/60 mt-0.5 truncate">
{[
s.under_floor ? "Under floor" : "Surface mount",
s.near_crac ? "near CRAC" : null,
s.room_id ?? null,
].filter(Boolean).join(" · ")}
</p>
</div>
<span className={cn(
"text-[10px] font-semibold shrink-0",
detected ? "text-destructive" :
s.state === "unknown" ? "text-muted-foreground/50" : "text-green-400",
)}>
{detected ? "LEAK" : s.state === "unknown" ? "unknown" : "clear"}
</span>
</div>
);
})}
</div>
))}
</div>
</CardContent>
</Card>
);
}
// ── Layout editor ─────────────────────────────────────────────────────────────
function LayoutEditor({
layout, onSave, saving,
}: {
layout: FloorLayout;
onSave: (l: FloorLayout) => void;
saving?: boolean;
}) {
const [draft, setDraft] = useState<FloorLayout>(() => JSON.parse(JSON.stringify(layout)));
const [newRoomId, setNewRoomId] = useState("");
const [newRoomLabel, setNewRoomLabel] = useState("");
const [newRoomCrac, setNewRoomCrac] = useState("");
const [expandedRoom, setExpandedRoom] = useState<string | null>(Object.keys(draft)[0] ?? null);
function updateRoom(roomId: string, patch: Partial<RoomLayout>) {
setDraft(d => ({ ...d, [roomId]: { ...d[roomId], ...patch } }));
}
function deleteRoom(roomId: string) {
setDraft(d => { const n = { ...d }; delete n[roomId]; return n; });
if (expandedRoom === roomId) setExpandedRoom(null);
}
function addRoom() {
const id = newRoomId.trim().toLowerCase().replace(/\s+/g, "-");
if (!id || !newRoomLabel.trim() || draft[id]) return;
setDraft(d => ({
...d,
[id]: { label: newRoomLabel.trim(), crac_id: newRoomCrac.trim(), rows: [] },
}));
setNewRoomId(""); setNewRoomLabel(""); setNewRoomCrac("");
setExpandedRoom(id);
}
function addRow(roomId: string) {
const room = draft[roomId];
const label = `Row ${room.rows.length + 1}`;
updateRoom(roomId, { rows: [...room.rows, { label, racks: [] }] });
}
function deleteRow(roomId: string, rowIdx: number) {
const rows = draft[roomId].rows.filter((_, i) => i !== rowIdx);
updateRoom(roomId, { rows });
}
function updateRowLabel(roomId: string, rowIdx: number, label: string) {
const rows = draft[roomId].rows.map((r, i) => i === rowIdx ? { ...r, label } : r);
updateRoom(roomId, { rows });
}
function addRack(roomId: string, rowIdx: number, rackId: string) {
const id = rackId.trim();
if (!id) return;
const rows = draft[roomId].rows.map((r, i) =>
i === rowIdx ? { ...r, racks: [...r.racks, id] } : r
);
updateRoom(roomId, { rows });
}
function removeRack(roomId: string, rowIdx: number, rackIdx: number) {
const rows = draft[roomId].rows.map((r, i) =>
i === rowIdx ? { ...r, racks: r.racks.filter((_, j) => j !== rackIdx) } : r
);
updateRoom(roomId, { rows });
}
function moveRow(roomId: string, rowIdx: number, dir: -1 | 1) {
const rows = [...draft[roomId].rows];
const target = rowIdx + dir;
if (target < 0 || target >= rows.length) return;
[rows[rowIdx], rows[target]] = [rows[target], rows[rowIdx]];
updateRoom(roomId, { rows });
}
return (
<div className="flex flex-col h-full">
<div className="px-6 pt-6 pb-4 border-b border-border shrink-0">
<h2 className="text-base font-semibold flex items-center gap-2">
<Settings2 className="w-4 h-4 text-primary" /> Floor Layout Editor
</h2>
<p className="text-xs text-muted-foreground mt-1">
Configure rooms, rows, and rack positions. Changes are saved for all users.
</p>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{Object.entries(draft).map(([roomId, room]) => (
<div key={roomId} className="rounded-xl border border-border bg-muted/10 overflow-hidden">
{/* Room header */}
<div className="flex items-center gap-2 px-3 py-2.5 bg-muted/20 border-b border-border/60">
<button
onClick={() => setExpandedRoom(expandedRoom === roomId ? null : roomId)}
className="flex items-center gap-2 flex-1 min-w-0 text-left focus-visible:outline-none"
>
{expandedRoom === roomId
? <ChevronUp className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
: <ChevronDown className="w-3.5 h-3.5 text-muted-foreground shrink-0" />}
<span className="text-sm font-semibold truncate">{room.label}</span>
<span className="text-[10px] text-muted-foreground font-mono">{roomId}</span>
<span className="text-[10px] text-muted-foreground ml-1">
· {room.rows.reduce((s, r) => s + r.racks.length, 0)} racks
</span>
</button>
<button
onClick={() => deleteRoom(roomId)}
className="text-muted-foreground hover:text-destructive transition-colors shrink-0 p-1"
aria-label={`Delete ${room.label}`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
{expandedRoom === roomId && (
<div className="p-3 space-y-3">
{/* Room fields */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-[10px] text-muted-foreground font-medium">Room Label</label>
<input
value={room.label}
onChange={e => updateRoom(roomId, { label: e.target.value })}
className="w-full h-7 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] text-muted-foreground font-medium">CRAC ID</label>
<input
value={room.crac_id}
onChange={e => updateRoom(roomId, { crac_id: e.target.value })}
placeholder="e.g. crac-01"
className="w-full h-7 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
{/* Rows */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rows</p>
<button
onClick={() => addRow(roomId)}
className="flex items-center gap-1 text-[10px] text-primary hover:text-primary/80 transition-colors"
>
<Plus className="w-3 h-3" /> Add Row
</button>
</div>
{room.rows.length === 0 && (
<p className="text-[10px] text-muted-foreground/50 py-2 text-center">No rows click Add Row</p>
)}
{room.rows.map((row, rowIdx) => (
<div key={rowIdx} className="rounded-lg border border-border/60 bg-background/50 p-2.5 space-y-2">
<div className="flex items-center gap-1.5">
<GripVertical className="w-3 h-3 text-muted-foreground/30 shrink-0" />
<input
value={row.label}
onChange={e => updateRowLabel(roomId, rowIdx, e.target.value)}
className="flex-1 h-6 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
/>
<button onClick={() => moveRow(roomId, rowIdx, -1)} disabled={rowIdx === 0}
className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-20 transition-colors">
<ChevronUp className="w-3 h-3" />
</button>
<button onClick={() => moveRow(roomId, rowIdx, 1)} disabled={rowIdx === room.rows.length - 1}
className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-20 transition-colors">
<ChevronDown className="w-3 h-3" />
</button>
<button onClick={() => deleteRow(roomId, rowIdx)}
className="p-0.5 text-muted-foreground hover:text-destructive transition-colors">
<Trash2 className="w-3 h-3" />
</button>
</div>
{/* Racks in this row */}
<div className="flex flex-wrap gap-1">
{row.racks.map((rackId, rackIdx) => (
<span key={rackIdx} className="inline-flex items-center gap-0.5 bg-muted rounded px-1.5 py-0.5 text-[10px] font-mono">
{rackId}
<button
onClick={() => removeRack(roomId, rowIdx, rackIdx)}
className="text-muted-foreground hover:text-destructive transition-colors ml-0.5"
aria-label={`Remove ${rackId}`}
>
×
</button>
</span>
))}
<RackAdder onAdd={(id) => addRack(roomId, rowIdx, id)} />
</div>
<p className="text-[9px] text-muted-foreground/40">
Feed: {rowIdx % 2 === 0 ? "A (even rows)" : "B (odd rows)"} · {row.racks.length} rack{row.racks.length !== 1 ? "s" : ""}
</p>
</div>
))}
</div>
</div>
)}
</div>
))}
{/* Add new room */}
<div className="rounded-xl border border-dashed border-border p-3 space-y-2">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Add New Room</p>
<div className="grid grid-cols-3 gap-2">
<input
value={newRoomId}
onChange={e => setNewRoomId(e.target.value)}
placeholder="room-id (e.g. hall-c)"
className="h-7 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
/>
<input
value={newRoomLabel}
onChange={e => setNewRoomLabel(e.target.value)}
placeholder="Label (e.g. Hall C)"
className="h-7 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
/>
<input
value={newRoomCrac}
onChange={e => setNewRoomCrac(e.target.value)}
placeholder="CRAC ID (e.g. crac-03)"
className="h-7 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<button
onClick={addRoom}
disabled={!newRoomId.trim() || !newRoomLabel.trim()}
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<Plus className="w-3.5 h-3.5" /> Add Room
</button>
</div>
</div>
{/* Footer actions */}
<div className="px-6 py-4 border-t border-border shrink-0 flex items-center justify-between gap-3">
<button
onClick={() => { setDraft(JSON.parse(JSON.stringify(DEFAULT_LAYOUT))); setExpandedRoom(Object.keys(DEFAULT_LAYOUT)[0]); }}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Reset to default
</button>
<Button size="sm" onClick={() => onSave(draft)} disabled={saving}>
{saving ? "Saving…" : "Save Layout"}
</Button>
</div>
</div>
);
}
// Small inline input to add a rack ID to a row
function RackAdder({ onAdd }: { onAdd: (id: string) => void }) {
const [val, setVal] = useState("");
function submit() {
if (val.trim()) { onAdd(val.trim()); setVal(""); }
}
return (
<span className="inline-flex items-center gap-0.5">
<input
value={val}
onChange={e => setVal(e.target.value)}
onKeyDown={e => e.key === "Enter" && submit()}
placeholder="rack-id"
className="h-5 w-20 rounded border border-dashed border-border bg-background px-1.5 text-[10px] font-mono focus:outline-none focus:ring-1 focus:ring-primary"
/>
<button
onClick={submit}
className="text-primary hover:text-primary/70 transition-colors"
aria-label="Add rack"
>
<Plus className="w-3 h-3" />
</button>
</span>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function FloorMapPage() {
const { thresholds } = useThresholds();
const [layout, setLayout] = useState<FloorLayout>(DEFAULT_LAYOUT);
const [data, setData] = useState<CapacitySummary | null>(null);
const [cracs, setCracs] = useState<CracStatus[]>([]);
const [alarms, setAlarms] = useState<Alarm[]>([]);
const [leakSensors, setLeakSensors] = useState<LeakSensorStatus[]>([]);
const [loading, setLoading] = useState(true);
const [overlay, setOverlay] = useState<Overlay>("temp");
const [activeRoom, setActiveRoom] = useState<string>("");
const [selectedRack, setSelectedRack] = useState<string | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [layoutSaving, setLayoutSaving] = useState(false);
// Load layout from backend on mount
useEffect(() => {
fetchFloorLayout(SITE_ID)
.then((remote) => {
const parsed = remote as FloorLayout;
setLayout(parsed);
setActiveRoom(Object.keys(parsed)[0] ?? "");
})
.catch(() => {
// No saved layout yet — use default
setActiveRoom(Object.keys(DEFAULT_LAYOUT)[0] ?? "");
});
}, []);
const alarmsByRack = new Map<string, number>();
for (const a of alarms) {
if (a.rack_id) alarmsByRack.set(a.rack_id, (alarmsByRack.get(a.rack_id) ?? 0) + 1);
}
const load = useCallback(async () => {
try {
const [d, c, a, ls] = await Promise.all([
fetchCapacitySummary(SITE_ID),
fetchCracStatus(SITE_ID),
fetchAlarms(SITE_ID, "active", 200),
fetchLeakStatus(SITE_ID).catch(() => [] as LeakSensorStatus[]),
]);
setData(d);
setCracs(c);
setAlarms(a);
setLeakSensors(ls);
} catch { /* keep stale */ }
finally { setLoading(false); }
}, []);
useEffect(() => {
load();
const id = setInterval(load, 30_000);
return () => clearInterval(id);
}, [load]);
async function handleSaveLayout(newLayout: FloorLayout) {
setLayoutSaving(true);
try {
await saveFloorLayout(SITE_ID, newLayout as unknown as Record<string, unknown>);
setLayout(newLayout);
if (!newLayout[activeRoom]) setActiveRoom(Object.keys(newLayout)[0] ?? "");
setEditorOpen(false);
} catch {
// save failed — keep editor open so user can retry
} finally {
setLayoutSaving(false);
}
}
const roomIds = Object.keys(layout);
return (
<div className="p-6 space-y-6">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-xl font-semibold">Floor Map</h1>
<p className="text-sm text-muted-foreground">Singapore DC01 live rack layout · refreshes every 30s</p>
</div>
<div className="flex items-center gap-2">
{/* Overlay selector */}
<div className="flex items-center gap-0.5 rounded-lg bg-muted p-1">
{([
{ val: "temp" as Overlay, icon: Thermometer, label: "Temperature" },
{ val: "power" as Overlay, icon: Zap, label: "Power %" },
{ val: "alarms" as Overlay, icon: AlertTriangle, label: "Alarms" },
{ val: "feed" as Overlay, icon: Cable, label: "Power Feed" },
{ val: "crac" as Overlay, icon: Wind, label: "CRAC Coverage" },
]).map(({ val, icon: Icon, label }) => (
<button
key={val}
onClick={() => setOverlay(val)}
aria-label={label}
aria-pressed={overlay === val}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
overlay === val ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="w-3.5 h-3.5" /> {label}
</button>
))}
</div>
{/* Layout editor trigger */}
<Sheet open={editorOpen} onOpenChange={setEditorOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="sm" className="flex items-center gap-1.5">
<Settings2 className="w-3.5 h-3.5" /> Edit Layout
</Button>
</SheetTrigger>
<SheetContent side="right" className="p-0 w-[420px] sm:w-[480px] flex flex-col">
<LayoutEditor layout={layout} onSave={handleSaveLayout} saving={layoutSaving} />
</SheetContent>
</Sheet>
</div>
</div>
{loading ? (
<Skeleton className="h-96 w-full" />
) : !data ? (
<div className="flex items-center justify-center h-64 text-sm text-muted-foreground">
Unable to load floor map data.
</div>
) : (
<>
<RackDetailSheet siteId={SITE_ID} rackId={selectedRack} onClose={() => setSelectedRack(null)} />
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold">Room View</CardTitle>
{roomIds.length > 0 && (
<Tabs value={activeRoom} onValueChange={setActiveRoom}>
<TabsList className="h-7">
{roomIds.map((id) => (
<TabsTrigger key={id} value={id} className="text-xs px-3 py-0.5">
{layout[id].label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
</div>
</CardHeader>
<CardContent>
{activeRoom && layout[activeRoom] ? (
<RoomPlan
roomId={activeRoom}
layout={layout}
data={data}
cracs={cracs}
overlay={overlay}
alarmsByRack={alarmsByRack}
onRackClick={setSelectedRack}
tempWarn={thresholds.temp.warn}
tempCrit={thresholds.temp.critical}
/>
) : (
<div className="flex flex-col items-center justify-center h-48 gap-3 text-muted-foreground">
<Settings2 className="w-8 h-8 opacity-30" />
<p className="text-sm">No rooms configured</p>
<p className="text-xs">Use Edit Layout to add rooms and racks</p>
</div>
)}
</CardContent>
</Card>
<LeakSensorPanel sensors={leakSensors} />
{/* Legend */}
<div className="flex items-center gap-3 text-[10px] text-muted-foreground flex-wrap">
{overlay === "alarms" ? (
<>
<span className="font-medium">Alarm count:</span>
<div className="flex items-center gap-1">
{([
{ c: "oklch(0.22 0.02 265)", l: "0" },
{ c: "oklch(0.65 0.20 45)", l: "12" },
{ c: "oklch(0.55 0.22 25)", l: "3+" },
]).map(({ c, l }) => (
<span key={l} className="flex items-center gap-0.5">
<span className="w-6 h-4 rounded-sm inline-block" style={{ backgroundColor: c }} />
<span>{l}</span>
</span>
))}
</div>
</>
) : overlay === "feed" ? (
<>
<span className="font-medium">Power feed:</span>
<div className="flex items-center gap-2">
<span className="flex items-center gap-1">
<span className="w-6 h-4 rounded-sm inline-block" style={{ backgroundColor: "oklch(0.55 0.18 255)" }} />
<span>Feed A (even rows)</span>
</span>
<span className="flex items-center gap-1">
<span className="w-6 h-4 rounded-sm inline-block" style={{ backgroundColor: "oklch(0.60 0.18 40)" }} />
<span>Feed B (odd rows)</span>
</span>
</div>
</>
) : overlay === "crac" ? (
<>
<span className="font-medium">CRAC thermal zones:</span>
<div className="flex items-center gap-2 flex-wrap">
{CRAC_ZONE_COLORS.slice(0, layout[activeRoom]?.rows.length ?? 2).map((c, i) => (
<span key={i} className="flex items-center gap-1">
<span className="w-6 h-4 rounded-sm inline-block" style={{ backgroundColor: c }} />
<span>Zone {i + 1}</span>
</span>
))}
</div>
</>
) : (
<>
<span className="font-medium">{overlay === "temp" ? "Temperature:" : "Power utilisation:"}</span>
<div className="flex items-center gap-1">
{overlay === "temp"
? (["oklch(0.60 0.15 212)","oklch(0.68 0.14 162)","oklch(0.78 0.14 140)","oklch(0.72 0.18 84)","oklch(0.65 0.20 45)","oklch(0.55 0.22 25)"] as string[]).map((c, i) => (
<span key={i} className="w-7 h-4 rounded-sm inline-block" style={{ backgroundColor: c }} />
))
: (["oklch(0.60 0.15 212)","oklch(0.68 0.14 162)","oklch(0.72 0.18 84)","oklch(0.65 0.20 45)","oklch(0.55 0.22 25)"] as string[]).map((c, i) => (
<span key={i} className="w-7 h-4 rounded-sm inline-block" style={{ backgroundColor: c }} />
))}
<span className="ml-1">{overlay === "temp" ? "Cool → Hot" : "Low → High"}</span>
</div>
{overlay === "temp" && <span className="ml-auto">Warn: {thresholds.temp.warn}°C &nbsp;|&nbsp; Critical: {thresholds.temp.critical}°C</span>}
{overlay === "power" && <span className="ml-auto">Warn: 75% &nbsp;|&nbsp; Critical: 90%</span>}
</>
)}
<span className="text-muted-foreground/50">Click any rack to drill down</span>
</div>
</>
)}
</div>
);
}