first commit
This commit is contained in:
commit
4b98219bf7
144 changed files with 31561 additions and 0 deletions
963
frontend/app/(dashboard)/floor-map/page.tsx
Normal file
963
frontend/app/(dashboard)/floor-map/page.tsx
Normal 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: "1–2" },
|
||||
{ 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 | Critical: {thresholds.temp.critical}°C</span>}
|
||||
{overlay === "power" && <span className="ml-auto">Warn: 75% | Critical: 90%</span>}
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground/50">Click any rack to drill down</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue