first commit

This commit is contained in:
mega 2026-03-19 11:32:17 +00:00
commit 4b98219bf7
144 changed files with 31561 additions and 0 deletions

View file

@ -0,0 +1,963 @@
"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>
);
}