144 lines
6 KiB
TypeScript
144 lines
6 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import Link from "next/link";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Map as MapIcon, Wind } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { useThresholds } from "@/lib/threshold-context";
|
|
import type { RackCapacity } from "@/lib/api";
|
|
|
|
type RowLayout = { label: string; racks: string[] };
|
|
type RoomLayout = { label: string; crac_id: string; rows: RowLayout[] };
|
|
type FloorLayout = Record<string, RoomLayout>;
|
|
|
|
interface Props {
|
|
layout: FloorLayout | null;
|
|
racks: RackCapacity[];
|
|
loading: boolean;
|
|
}
|
|
|
|
function tempBg(temp: number | null, warn: number, crit: number): string {
|
|
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)";
|
|
}
|
|
|
|
export function MiniFloorMap({ layout, racks, loading }: Props) {
|
|
const { thresholds } = useThresholds();
|
|
const warn = thresholds.temp.warn;
|
|
const crit = thresholds.temp.critical;
|
|
|
|
const rackMap: globalThis.Map<string, RackCapacity> = new globalThis.Map(racks.map(r => [r.rack_id, r] as [string, RackCapacity]));
|
|
const roomIds = layout ? Object.keys(layout) : [];
|
|
const [activeRoom, setActiveRoom] = useState<string>(() => roomIds[0] ?? "");
|
|
const currentRoomId = activeRoom || roomIds[0] || "";
|
|
|
|
return (
|
|
<Card className="flex flex-col">
|
|
<CardHeader className="pb-2 shrink-0">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
|
<MapIcon className="w-4 h-4 text-primary" />
|
|
Floor Map — Temperature
|
|
</CardTitle>
|
|
<Link
|
|
href="/floor-map"
|
|
className="text-[10px] text-primary hover:underline"
|
|
>
|
|
Full map →
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Room tabs */}
|
|
{roomIds.length > 1 && (
|
|
<div className="flex gap-1 mt-2">
|
|
{roomIds.map(id => (
|
|
<button
|
|
key={id}
|
|
onClick={() => setActiveRoom(id)}
|
|
className={cn(
|
|
"px-3 py-1 rounded text-[11px] font-medium transition-colors",
|
|
currentRoomId === id
|
|
? "bg-primary text-primary-foreground"
|
|
: "bg-muted/40 text-muted-foreground hover:bg-muted/60"
|
|
)}
|
|
>
|
|
{layout?.[id]?.label ?? id}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardHeader>
|
|
|
|
<CardContent className="flex-1 min-h-0">
|
|
{loading ? (
|
|
<Skeleton className="h-40 w-full rounded-lg" />
|
|
) : !layout || roomIds.length === 0 ? (
|
|
<div className="h-40 flex items-center justify-center text-sm text-muted-foreground">
|
|
No layout configured
|
|
</div>
|
|
) : (
|
|
<Link href="/floor-map" className="block group">
|
|
<div className="space-y-2">
|
|
{(layout[currentRoomId]?.rows ?? []).map((row, rowIdx, allRows) => (
|
|
<div key={row.label}>
|
|
{/* Rack row */}
|
|
<div className="flex flex-wrap gap-[3px]">
|
|
{row.racks.map(rackId => {
|
|
const rack = rackMap.get(rackId);
|
|
const bg = tempBg(rack?.temp ?? null, warn, crit);
|
|
return (
|
|
<div
|
|
key={rackId}
|
|
title={`${rackId}${rack?.temp != null ? ` · ${rack.temp}°C` : " · offline"}`}
|
|
className="rounded-[2px] shrink-0"
|
|
style={{ width: 14, height: 20, backgroundColor: bg }}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Cold aisle separator between rows */}
|
|
{rowIdx < allRows.length - 1 && (
|
|
<div className="flex items-center gap-2 my-1.5 text-[9px] font-semibold uppercase tracking-widest"
|
|
style={{ color: "oklch(0.62 0.17 212 / 70%)" }}>
|
|
<div className="flex-1 h-px border-t border-dashed" style={{ borderColor: "oklch(0.62 0.17 212 / 25%)" }} />
|
|
<Wind className="w-2.5 h-2.5 shrink-0" />
|
|
<span>Cold Aisle</span>
|
|
<div className="flex-1 h-px border-t border-dashed" style={{ borderColor: "oklch(0.62 0.17 212 / 25%)" }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{/* CRAC label */}
|
|
{layout[currentRoomId]?.crac_id && (
|
|
<div className="flex items-center justify-center gap-1.5 rounded-md py-1 text-[10px] font-medium"
|
|
style={{ backgroundColor: "oklch(0.62 0.17 212 / 8%)", color: "oklch(0.62 0.17 212)" }}>
|
|
<Wind className="w-3 h-3" />
|
|
{layout[currentRoomId].crac_id.toUpperCase()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Temp legend */}
|
|
<div className="flex items-center gap-1 mt-3 text-[10px] text-muted-foreground">
|
|
<span>Cool</span>
|
|
{(["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-5 h-2.5 rounded-sm inline-block" style={{ backgroundColor: c }} />
|
|
))}
|
|
<span>Hot</span>
|
|
<span className="ml-auto opacity-60 group-hover:opacity-100 transition-opacity">Click to open full map</span>
|
|
</div>
|
|
</Link>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|