"use client"; import { useEffect, useState, useCallback } from "react"; import { toast } from "sonner"; import { fetchRackEnvReadings, fetchHumidityHistory, fetchTempHistory as fetchRoomTempHistory, fetchCracStatus, fetchLeakStatus, fetchFireStatus, fetchParticleStatus, type RoomEnvReadings, type HumidityBucket, type TempBucket, type CracStatus, type LeakSensorStatus, type FireZoneStatus, type ParticleStatus, } from "@/lib/api"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useThresholds } from "@/lib/threshold-context"; import { TimeRangePicker } from "@/components/ui/time-range-picker"; import { ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, ReferenceArea, } from "recharts"; import { Thermometer, Droplets, WifiOff, CheckCircle2, AlertTriangle, Flame, Wind } from "lucide-react"; import { RackDetailSheet } from "@/components/dashboard/rack-detail-sheet"; import { cn } from "@/lib/utils"; import Link from "next/link"; const SITE_ID = "sg-01"; const ROOM_COLORS: Record = { "hall-a": { temp: "oklch(0.62 0.17 212)", hum: "oklch(0.55 0.18 270)" }, "hall-b": { temp: "oklch(0.7 0.15 162)", hum: "oklch(0.60 0.15 145)" }, }; const roomLabels: Record = { "hall-a": "Hall A", "hall-b": "Hall B" }; function formatTime(iso: string) { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } // ── Utility functions ───────────────────────────────────────────────────────── /** Magnus formula dew point (°C) from temperature (°C) and relative humidity (%) */ function dewPoint(temp: number, rh: number): number { const gamma = Math.log(rh / 100) + (17.625 * temp) / (243.04 + temp); return Math.round((243.04 * gamma / (17.625 - gamma)) * 10) / 10; } function humidityColor(hum: number | null): string { if (hum === null) return "oklch(0.25 0.02 265)"; if (hum > 80) return "oklch(0.55 0.22 25)"; // critical high if (hum > 65) return "oklch(0.65 0.20 45)"; // warning high if (hum > 50) return "oklch(0.72 0.18 84)"; // elevated if (hum >= 30) return "oklch(0.68 0.14 162)"; // optimal return "oklch(0.62 0.17 212)"; // low (static risk) } // ── Temperature heatmap ─────────────────────────────────────────────────────── function tempColor(temp: number | null, warn = 26, crit = 28): string { if (temp === null) return "oklch(0.25 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)"; } type HeatmapOverlay = "temp" | "humidity"; function TempHeatmap({ rooms, onRackClick, activeRoom, tempWarn = 26, tempCrit = 28, humWarn = 65, humCrit = 80, }: { rooms: RoomEnvReadings[]; onRackClick: (rackId: string) => void; activeRoom: string; tempWarn?: number; tempCrit?: number; humWarn?: number; humCrit?: number; }) { const [overlay, setOverlay] = useState("temp"); const room = rooms.find((r) => r.room_id === activeRoom); return (
{overlay === "temp" ? : } {overlay === "temp" ? "Temperature" : "Humidity"} Heatmap
{/* Overlay toggle */}
{/* Callout — hottest or most humid */} {(() => { if (overlay === "temp") { const hottest = room?.racks.reduce((a, b) => (a.temperature ?? 0) > (b.temperature ?? 0) ? a : b ); if (!hottest || hottest.temperature === null) return null; const isHot = hottest.temperature >= tempWarn; return (
= tempCrit ? "bg-destructive/10 text-destructive" : isHot ? "bg-amber-500/10 text-amber-400" : "bg-muted/40 text-muted-foreground" )}> Hottest: {hottest.rack_id.toUpperCase()} at {hottest.temperature}°C {hottest.temperature >= tempCrit ? " — above critical threshold" : isHot ? " — above warning threshold" : " — within normal range"}
); } else { const humid = room?.racks.reduce((a, b) => (a.humidity ?? 0) > (b.humidity ?? 0) ? a : b ); if (!humid || humid.humidity === null) return null; const isHigh = humid.humidity > humWarn; return (
humCrit ? "bg-destructive/10 text-destructive" : isHigh ? "bg-amber-500/10 text-amber-400" : "bg-muted/40 text-muted-foreground" )}> Highest humidity: {humid.rack_id.toUpperCase()} at {humid.humidity}% {humid.humidity > humCrit ? " — above critical threshold" : isHigh ? " — above warning threshold" : " — within normal range"}
); } })()} {/* Rack grid */}
{room?.racks.map((rack) => { const offline = rack.temperature === null && rack.humidity === null; const bg = overlay === "temp" ? tempColor(rack.temperature, tempWarn, tempCrit) : humidityColor(rack.humidity); const mainVal = overlay === "temp" ? (rack.temperature !== null ? `${rack.temperature}°` : null) : (rack.humidity !== null ? `${rack.humidity}%` : null); const subVal = overlay === "temp" ? (rack.humidity !== null && rack.temperature !== null ? `DP ${dewPoint(rack.temperature, rack.humidity)}°` : null) : (rack.temperature !== null ? `${rack.temperature}°C` : null); return (
onRackClick(rack.rack_id)} className={cn( "relative rounded-lg p-3 flex flex-col items-center justify-center gap-0.5 min-h-[72px] transition-all cursor-pointer hover:ring-2 hover:ring-white/20", offline ? "hover:opacity-70" : "hover:opacity-80" )} style={{ backgroundColor: offline ? "oklch(0.22 0.02 265)" : bg, backgroundImage: offline ? "repeating-linear-gradient(45deg, transparent, transparent 4px, oklch(1 0 0 / 4%) 4px, oklch(1 0 0 / 4%) 8px)" : undefined, }} > {rack.rack_id.replace("rack-", "").toUpperCase()} {offline ? ( ) : ( {mainVal ?? "—"} )} {subVal && ( {subVal} )}
); })}
{/* Legend */}
{overlay === "temp" ? ( <> Cool {(["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) => ( ))} Hot Warn: {tempWarn}°C  |  Crit: {tempCrit}°C · Tiles show dew point (DP) ) : ( <> Dry {(["oklch(0.62 0.17 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) => ( ))} Humid Optimal: 30–65%  |  ASHRAE A1 max: 80% )}
); } // ── Dual-axis trend chart ───────────────────────────────────────────────────── function EnvTrendChart({ tempData, humData, hours, activeRoom, tempWarn = 26, tempCrit = 28, humWarn = 65, }: { tempData: TempBucket[]; humData: HumidityBucket[]; hours: number; activeRoom: string; tempWarn?: number; tempCrit?: number; humWarn?: number; }) { const roomIds = [...new Set(tempData.map((d) => d.room_id))].sort(); // Build combined rows for the active room type ComboRow = { time: string; temp: number | null; hum: number | null }; const buckets = new Map(); for (const d of tempData.filter((d) => d.room_id === activeRoom)) { const time = formatTime(d.bucket); if (!buckets.has(time)) buckets.set(time, { time, temp: null, hum: null }); buckets.get(time)!.temp = d.avg_temp; } for (const d of humData.filter((d) => d.room_id === activeRoom)) { const time = formatTime(d.bucket); if (!buckets.has(time)) buckets.set(time, { time, temp: null, hum: null }); buckets.get(time)!.hum = d.avg_humidity; } const chartData = Array.from(buckets.values()); const colors = ROOM_COLORS[activeRoom] ?? ROOM_COLORS["hall-a"]; const labelSuffix = hours <= 1 ? "1h" : hours <= 6 ? "6h" : hours <= 24 ? "24h" : "7d"; return (
Temp & Humidity — last {labelSuffix}
{/* Legend */}
Temp (°C, left axis) Humidity (%, right axis) ASHRAE A1 safe zone
{chartData.length === 0 ? (
Waiting for data...
) : ( {/* ASHRAE A1 safe zones */} {/* Left axis — temperature */} `${v}°`} /> {/* Right axis — humidity */} `${v}%`} /> name === "temp" ? [`${Number(v).toFixed(1)}°C`, "Temperature"] : [`${Number(v).toFixed(0)}%`, "Humidity"] } /> {/* Temp reference lines */} {/* Humidity reference line */} {/* Lines */} )}

Green shaded band = ASHRAE A1 thermal envelope (18–27°C / 20–80% RH)

); } // ── ASHRAE A1 Compliance Table ──────────────────────────────────────────────── // ASHRAE A1: 15–32°C, 20–80% RH function AshraeTable({ rooms }: { rooms: RoomEnvReadings[] }) { const allRacks = rooms.flatMap(r => r.racks.map(rack => ({ ...rack, room_id: r.room_id })) ).filter(r => r.temperature !== null || r.humidity !== null); type Issue = { type: string; detail: string }; const rows = allRacks.map(rack => { const issues: Issue[] = []; if (rack.temperature !== null) { if (rack.temperature < 15) issues.push({ type: "Temp", detail: `${rack.temperature}°C — below 15°C min` }); if (rack.temperature > 32) issues.push({ type: "Temp", detail: `${rack.temperature}°C — above 32°C max` }); } if (rack.humidity !== null) { if (rack.humidity < 20) issues.push({ type: "RH", detail: `${rack.humidity}% — below 20% min` }); if (rack.humidity > 80) issues.push({ type: "RH", detail: `${rack.humidity}% — above 80% max` }); } const dp = rack.temperature !== null && rack.humidity !== null ? dewPoint(rack.temperature, rack.humidity) : null; return { rack, issues, dp }; }); const violations = rows.filter(r => r.issues.length > 0); const compliant = rows.filter(r => r.issues.length === 0); return (
ASHRAE A1 Compliance 0 ? "bg-destructive/10 text-destructive" : "bg-green-500/10 text-green-400" )}> {violations.length === 0 ? `All ${compliant.length} racks compliant` : `${violations.length} violation${violations.length > 1 ? "s" : ""}`}
{violations.length === 0 ? (

All racks within ASHRAE A1 envelope (15–32°C, 20–80% RH)

) : (
{violations.map(({ rack, issues, dp }) => (
{rack.rack_id.toUpperCase()} {roomLabels[rack.room_id] ?? rack.room_id}
{issues.map((iss, i) => {iss.type}: {iss.detail})} {dp !== null && DP: {dp}°C}
))}

ASHRAE A1 envelope: 15–32°C dry bulb, 20–80% relative humidity

)}
); } // ── Dew Point Panel ─────────────────────────────────────────────────────────── function DewPointPanel({ rooms, cracs, activeRoom, }: { rooms: RoomEnvReadings[]; cracs: CracStatus[]; activeRoom: string; }) { const room = rooms.find(r => r.room_id === activeRoom); const crac = cracs.find(c => c.room_id === activeRoom); const supplyTemp = crac?.supply_temp ?? null; const rackDps = (room?.racks ?? []) .filter(r => r.temperature !== null && r.humidity !== null) .map(r => ({ rack_id: r.rack_id, dp: dewPoint(r.temperature!, r.humidity!), temp: r.temperature!, hum: r.humidity!, })) .sort((a, b) => b.dp - a.dp); return (
Dew Point by Rack {supplyTemp !== null && ( CRAC supply: {supplyTemp}°C {rackDps.some(r => r.dp >= supplyTemp - 1) && ( — condensation risk! )} )}
{rackDps.length === 0 ? (

No data available

) : (
{rackDps.map(({ rack_id, dp, temp, hum }) => { const nearCondensation = supplyTemp !== null && dp >= supplyTemp - 1; const dpColor = nearCondensation ? "text-destructive" : dp > 15 ? "text-amber-400" : "text-foreground"; return (
{rack_id.replace("rack-", "").toUpperCase()}
15 ? "bg-amber-500" : "bg-blue-500")} style={{ width: `${Math.min(100, Math.max(0, (dp / 30) * 100))}%` }} />
{dp}°C DP {temp}° / {hum}%
); })}

Dew point approaching CRAC supply temp = condensation risk on cold surfaces

)} ); } // ── Leak sensor panel ───────────────────────────────────────────────────────── function LeakPanel({ sensors }: { sensors: LeakSensorStatus[] }) { const detected = sensors.filter(s => s.state === "detected"); const anyDetected = detected.length > 0; return (
Water / Leak Detection
View full page → {anyDetected ? `${detected.length} leak detected` : "All clear"}
{sensors.map(s => { const detected = s.state === "detected"; return (

{detected ? : } {s.sensor_id}

Zone: {s.floor_zone} {s.under_floor ? " · under-floor" : ""} {s.near_crac ? " · near CRAC" : ""} {s.room_id ? ` · ${s.room_id}` : ""}

{s.state === "detected" ? "DETECTED" : s.state === "clear" ? "Clear" : "Unknown"}
); })} {sensors.length === 0 && (

No sensors configured

)}
); } // ── VESDA / Fire panel ──────────────────────────────────────────────────────── const VESDA_LEVEL_CONFIG: Record = { normal: { label: "Normal", color: "text-green-400", bg: "bg-green-500/10" }, alert: { label: "Alert", color: "text-amber-400", bg: "bg-amber-500/10" }, action: { label: "Action", color: "text-orange-400", bg: "bg-orange-500/10" }, fire: { label: "FIRE", color: "text-destructive", bg: "bg-destructive/10" }, }; function FirePanel({ zones }: { zones: FireZoneStatus[] }) { const elevated = zones.filter(z => z.level !== "normal"); return ( 0 && elevated.some(z => z.level === "fire") && "border-destructive/50")}>
VESDA / Smoke Detection
View full page → z.level === "fire") ? "bg-destructive/10 text-destructive animate-pulse" : "bg-amber-500/10 text-amber-400", )}> {elevated.length === 0 ? "All normal" : `${elevated.length} zone${elevated.length !== 1 ? "s" : ""} elevated`}
{zones.map(zone => { const cfg = VESDA_LEVEL_CONFIG[zone.level] ?? VESDA_LEVEL_CONFIG.normal; return (

{zone.zone_id}

{zone.room_id &&

{zone.room_id}

}
{cfg.label}
Obscuration: {zone.obscuration_pct_m != null ? `${zone.obscuration_pct_m.toFixed(3)} %/m` : "—"}
{!zone.detector_1_ok && Det1 fault} {!zone.detector_2_ok && Det2 fault} {!zone.power_ok && Power fault} {!zone.flow_ok && Flow fault} {zone.detector_1_ok && zone.detector_2_ok && zone.power_ok && zone.flow_ok && ( Systems OK )}
); })} {zones.length === 0 && (

No VESDA zones configured

)}
); } // ── Particle count panel (ISO 14644) ────────────────────────────────────────── const ISO_LABELS: Record = { 5: { label: "ISO 5", color: "text-green-400" }, 6: { label: "ISO 6", color: "text-green-400" }, 7: { label: "ISO 7", color: "text-green-400" }, 8: { label: "ISO 8", color: "text-amber-400" }, 9: { label: "ISO 9", color: "text-destructive" }, }; const ISO8_0_5UM = 3_520_000; const ISO8_5UM = 29_300; function ParticlePanel({ rooms }: { rooms: ParticleStatus[] }) { if (rooms.length === 0) return null; return ( Air Quality — ISO 14644 {rooms.map(r => { const cls = r.iso_class ? ISO_LABELS[r.iso_class] : null; const p05pct = r.particles_0_5um !== null ? Math.min(100, (r.particles_0_5um / ISO8_0_5UM) * 100) : null; const p5pct = r.particles_5um !== null ? Math.min(100, (r.particles_5um / ISO8_5UM) * 100) : null; return (
{r.room_id === "hall-a" ? "Hall A" : r.room_id === "hall-b" ? "Hall B" : r.room_id} {cls ? ( {cls.label} ) : ( No data )}
≥0.5 µm
{p05pct !== null && (
= 100 ? "bg-destructive" : p05pct >= 70 ? "bg-amber-500" : "bg-green-500")} style={{ width: `${p05pct}%` }} /> )}
{r.particles_0_5um !== null ? r.particles_0_5um.toLocaleString() : "—"} /m³
≥5 µm
{p5pct !== null && (
= 100 ? "bg-destructive" : p5pct >= 70 ? "bg-amber-500" : "bg-green-500")} style={{ width: `${p5pct}%` }} /> )}
{r.particles_5um !== null ? r.particles_5um.toLocaleString() : "—"} /m³
); })}

DC target: ISO 8 (≤3,520,000 particles ≥0.5 µm/m³ · ≤29,300 ≥5 µm/m³)

); } // ── Page ────────────────────────────────────────────────────────────────────── export default function EnvironmentalPage() { const { thresholds } = useThresholds(); const [rooms, setRooms] = useState([]); const [tempHist, setTempHist] = useState([]); const [humHist, setHumHist] = useState([]); const [cracs, setCracs] = useState([]); const [leakSensors, setLeak] = useState([]); const [fireZones, setFire] = useState([]); const [particles, setParticles] = useState([]); const [hours, setHours] = useState(6); const [loading, setLoading] = useState(true); const [selectedRack, setSelectedRack] = useState(null); const [activeRoom, setActiveRoom] = useState("hall-a"); const load = useCallback(async () => { try { const [r, t, h, c, l, f, p] = await Promise.all([ fetchRackEnvReadings(SITE_ID), fetchRoomTempHistory(SITE_ID, hours), fetchHumidityHistory(SITE_ID, hours), fetchCracStatus(SITE_ID), fetchLeakStatus(SITE_ID).catch(() => []), fetchFireStatus(SITE_ID).catch(() => []), fetchParticleStatus(SITE_ID).catch(() => []), ]); setRooms(r); setTempHist(t); setHumHist(h); setCracs(c); setLeak(l); setFire(f); setParticles(p); } catch { toast.error("Failed to load environmental data"); } finally { setLoading(false); } }, [hours]); useEffect(() => { load(); const id = setInterval(load, 30_000); return () => clearInterval(id); }, [load]); return (

Environmental Monitoring

Singapore DC01 — refreshes every 30s

{loading ? (
) : ( <> setSelectedRack(null)} /> {/* Page-level room tab selector */} {rooms.length > 0 && ( {rooms.map(r => ( {roomLabels[r.room_id] ?? r.room_id} ))} )} {rooms.length > 0 && ( )}
{rooms.length > 0 && } {rooms.length > 0 && ( )}
{/* Leak + VESDA panels */}
)}
); }