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

846 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState, useCallback } 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<string, { temp: string; hum: string }> = {
"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<string, string> = { "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<HeatmapOverlay>("temp");
const room = rooms.find((r) => r.room_id === activeRoom);
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
{overlay === "temp"
? <Thermometer className="w-4 h-4 text-primary" />
: <Droplets className="w-4 h-4 text-blue-400" />}
{overlay === "temp" ? "Temperature" : "Humidity"} Heatmap
</CardTitle>
<div className="flex items-center gap-2">
{/* Overlay toggle */}
<div className="flex items-center gap-0.5 rounded-md border border-border p-0.5">
<button
onClick={() => setOverlay("temp")}
aria-label="Temperature overlay"
aria-pressed={overlay === "temp"}
className={cn(
"flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
overlay === "temp" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
>
<Thermometer className="w-3 h-3" /> Temp
</button>
<button
onClick={() => setOverlay("humidity")}
aria-label="Humidity overlay"
aria-pressed={overlay === "humidity"}
className={cn(
"flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
overlay === "humidity" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
>
<Droplets className="w-3 h-3" /> Humidity
</button>
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* 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 (
<div className={cn(
"flex items-center gap-2 rounded-lg px-3 py-2 text-xs",
hottest.temperature >= tempCrit ? "bg-destructive/10 text-destructive" :
isHot ? "bg-amber-500/10 text-amber-400" : "bg-muted/40 text-muted-foreground"
)}>
<Thermometer className="w-3.5 h-3.5 shrink-0" />
<span>
Hottest: <strong>{hottest.rack_id.toUpperCase()}</strong> at <strong>{hottest.temperature}°C</strong>
{hottest.temperature >= tempCrit ? " — above critical threshold" : isHot ? " — above warning threshold" : " — within normal range"}
</span>
</div>
);
} 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 (
<div className={cn(
"flex items-center gap-2 rounded-lg px-3 py-2 text-xs",
humid.humidity > humCrit ? "bg-destructive/10 text-destructive" :
isHigh ? "bg-amber-500/10 text-amber-400" : "bg-muted/40 text-muted-foreground"
)}>
<Droplets className="w-3.5 h-3.5 shrink-0" />
<span>
Highest humidity: <strong>{humid.rack_id.toUpperCase()}</strong> at <strong>{humid.humidity}%</strong>
{humid.humidity > humCrit ? " — above critical threshold" : isHigh ? " — above warning threshold" : " — within normal range"}
</span>
</div>
);
}
})()}
{/* Rack grid */}
<div className="grid grid-cols-5 gap-2">
{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 (
<div
key={rack.rack_id}
onClick={() => 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,
}}
>
<span className="text-[10px] font-semibold text-white/70">
{rack.rack_id.replace("rack-", "").toUpperCase()}
</span>
{offline ? (
<WifiOff className="w-3.5 h-3.5 text-white/40" />
) : (
<span className="text-base font-bold text-white">{mainVal ?? "—"}</span>
)}
{subVal && (
<span className="text-[10px] text-white/60">{subVal}</span>
)}
</div>
);
})}
</div>
{/* Legend */}
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
{overlay === "temp" ? (
<>
<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-6 h-3 rounded-sm inline-block" style={{ backgroundColor: c }} />
))}
<span>Hot</span>
<span className="ml-auto">Warn: {tempWarn}°C &nbsp;|&nbsp; Crit: {tempCrit}°C · Tiles show dew point (DP)</span>
</>
) : (
<>
<span>Dry</span>
{(["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) => (
<span key={i} className="w-6 h-3 rounded-sm inline-block" style={{ backgroundColor: c }} />
))}
<span>Humid</span>
<span className="ml-auto">Optimal: 3065% &nbsp;|&nbsp; ASHRAE A1 max: 80%</span>
</>
)}
</div>
</CardContent>
</Card>
);
}
// ── 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<string, ComboRow>();
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 (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Thermometer className="w-4 h-4 text-primary" />
<Droplets className="w-4 h-4 text-blue-400" />
Temp &amp; Humidity last {labelSuffix}
</CardTitle>
</div>
{/* Legend */}
<div className="flex items-center gap-4 text-[10px] text-muted-foreground mt-1 flex-wrap">
<span className="flex items-center gap-1.5">
<span className="w-4 h-0.5 inline-block rounded" style={{ backgroundColor: colors.temp }} />
Temp (°C, left axis)
</span>
<span className="flex items-center gap-1.5">
<span className="w-4 h-0.5 inline-block rounded border-t-2 border-dashed" style={{ borderColor: colors.hum }} />
Humidity (%, right axis)
</span>
<span className="flex items-center gap-1.5 ml-auto">
<span className="w-4 h-3 inline-block rounded opacity-40" style={{ backgroundColor: "#22c55e" }} />
ASHRAE A1 safe zone
</span>
</div>
</CardHeader>
<CardContent>
{chartData.length === 0 ? (
<div className="h-[240px] flex items-center justify-center text-sm text-muted-foreground">
Waiting for data...
</div>
) : (
<ResponsiveContainer width="100%" height={240}>
<ComposedChart data={chartData} margin={{ top: 4, right: 30, left: -16, bottom: 0 }}>
{/* ASHRAE A1 safe zones */}
<ReferenceArea yAxisId="temp" y1={18} y2={27} fill="#22c55e" fillOpacity={0.07} />
<ReferenceArea yAxisId="hum" y1={20} y2={80} fill="#3b82f6" fillOpacity={0.05} />
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
<XAxis
dataKey="time"
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
tickLine={false}
axisLine={false}
/>
{/* Left axis — temperature */}
<YAxis
yAxisId="temp"
domain={[16, 36]}
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => `${v}°`}
/>
{/* Right axis — humidity */}
<YAxis
yAxisId="hum"
orientation="right"
domain={[0, 100]}
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => `${v}%`}
/>
<Tooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "12px" }}
formatter={(v, name) =>
name === "temp" ? [`${Number(v).toFixed(1)}°C`, "Temperature"] :
[`${Number(v).toFixed(0)}%`, "Humidity"]
}
/>
{/* Temp reference lines */}
<ReferenceLine yAxisId="temp" y={tempWarn} stroke="oklch(0.72 0.18 84)" strokeDasharray="4 4" strokeWidth={1}
label={{ value: `Warn ${tempWarn}°`, fontSize: 9, fill: "oklch(0.72 0.18 84)", position: "right" }} />
<ReferenceLine yAxisId="temp" y={tempCrit} stroke="oklch(0.55 0.22 25)" strokeDasharray="4 4" strokeWidth={1}
label={{ value: `Crit ${tempCrit}°`, fontSize: 9, fill: "oklch(0.55 0.22 25)", position: "right" }} />
{/* Humidity reference line */}
<ReferenceLine yAxisId="hum" y={humWarn} stroke="oklch(0.62 0.17 212)" strokeDasharray="4 4" strokeWidth={1} />
{/* Lines */}
<Line
yAxisId="temp"
type="monotone"
dataKey="temp"
stroke={colors.temp}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
connectNulls
/>
<Line
yAxisId="hum"
type="monotone"
dataKey="hum"
stroke={colors.hum}
strokeWidth={2}
strokeDasharray="6 3"
dot={false}
activeDot={{ r: 4 }}
connectNulls
/>
</ComposedChart>
</ResponsiveContainer>
)}
<p className="text-[10px] text-muted-foreground mt-2">
Green shaded band = ASHRAE A1 thermal envelope (1827°C / 2080% RH)
</p>
</CardContent>
</Card>
);
}
// ── ASHRAE A1 Compliance Table ────────────────────────────────────────────────
// ASHRAE A1: 1532°C, 2080% 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 (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-primary" /> ASHRAE A1 Compliance
</CardTitle>
<span className={cn(
"text-[10px] font-semibold px-2 py-0.5 rounded-full",
violations.length > 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" : ""}`}
</span>
</div>
</CardHeader>
<CardContent>
{violations.length === 0 ? (
<p className="text-sm text-green-400 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" />
All racks within ASHRAE A1 envelope (1532°C, 2080% RH)
</p>
) : (
<div className="space-y-2">
{violations.map(({ rack, issues, dp }) => (
<div key={rack.rack_id} className="flex items-start gap-3 rounded-lg bg-destructive/5 border border-destructive/20 px-3 py-2 text-xs">
<AlertTriangle className="w-3.5 h-3.5 text-destructive mt-0.5 shrink-0" />
<div className="flex-1">
<span className="font-semibold">{rack.rack_id.toUpperCase()}</span>
<span className="text-muted-foreground ml-2">{roomLabels[rack.room_id] ?? rack.room_id}</span>
<div className="flex flex-wrap gap-x-4 gap-y-0.5 mt-0.5 text-destructive">
{issues.map((iss, i) => <span key={i}>{iss.type}: {iss.detail}</span>)}
{dp !== null && <span className="text-muted-foreground">DP: {dp}°C</span>}
</div>
</div>
</div>
))}
<p className="text-[10px] text-muted-foreground pt-1">
ASHRAE A1 envelope: 1532°C dry bulb, 2080% relative humidity
</p>
</div>
)}
</CardContent>
</Card>
);
}
// ── 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 (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Droplets className="w-4 h-4 text-blue-400" /> Dew Point by Rack
</CardTitle>
{supplyTemp !== null && (
<span className="text-[10px] text-muted-foreground">
CRAC supply: <strong className="text-blue-400">{supplyTemp}°C</strong>
{rackDps.some(r => r.dp >= supplyTemp - 1) && (
<span className="text-destructive ml-1"> condensation risk!</span>
)}
</span>
)}
</div>
</CardHeader>
<CardContent>
{rackDps.length === 0 ? (
<p className="text-sm text-muted-foreground">No data available</p>
) : (
<div className="space-y-1.5">
{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 (
<div key={rack_id} className="flex items-center gap-3 text-xs">
<span className="font-mono w-16 shrink-0 text-muted-foreground">
{rack_id.replace("rack-", "").toUpperCase()}
</span>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className={cn("h-full rounded-full transition-all", nearCondensation ? "bg-destructive" : dp > 15 ? "bg-amber-500" : "bg-blue-500")}
style={{ width: `${Math.min(100, Math.max(0, (dp / 30) * 100))}%` }}
/>
</div>
<span className={cn("font-mono font-semibold w-16 text-right", dpColor)}>
{dp}°C DP
</span>
<span className="text-muted-foreground w-20 text-right hidden sm:block">
{temp}° / {hum}%
</span>
</div>
);
})}
<p className="text-[10px] text-muted-foreground pt-1">
Dew point approaching CRAC supply temp = condensation risk on cold surfaces
</p>
</div>
)}
</CardContent>
</Card>
);
}
// ── Leak sensor panel ─────────────────────────────────────────────────────────
function LeakPanel({ sensors }: { sensors: LeakSensorStatus[] }) {
const detected = sensors.filter(s => s.state === "detected");
const anyDetected = detected.length > 0;
return (
<Card className={cn("border", anyDetected && "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="w-4 h-4 text-blue-400" /> Water / Leak Detection
</CardTitle>
<div className="flex items-center gap-2">
<Link href="/leak" className="text-[10px] text-primary hover:underline">View full page </Link>
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
anyDetected ? "bg-destructive/10 text-destructive animate-pulse" : "bg-green-500/10 text-green-400",
)}>
{anyDetected ? `${detected.length} leak detected` : "All clear"}
</span>
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
{sensors.map(s => {
const detected = s.state === "detected";
return (
<div key={s.sensor_id} className={cn(
"flex items-start justify-between rounded-lg px-3 py-2 text-xs",
detected ? "bg-destructive/10" : "bg-muted/30",
)}>
<div>
<p className={cn("font-semibold", detected ? "text-destructive" : "text-foreground")}>
{detected ? <AlertTriangle className="w-3 h-3 inline mr-1" /> : <CheckCircle2 className="w-3 h-3 inline mr-1 text-green-400" />}
{s.sensor_id}
</p>
<p className="text-muted-foreground mt-0.5">
Zone: {s.floor_zone}
{s.under_floor ? " · under-floor" : ""}
{s.near_crac ? " · near CRAC" : ""}
{s.room_id ? ` · ${s.room_id}` : ""}
</p>
</div>
<span className={cn("font-semibold shrink-0 ml-2", detected ? "text-destructive" : "text-green-400")}>
{s.state === "detected" ? "DETECTED" : s.state === "clear" ? "Clear" : "Unknown"}
</span>
</div>
);
})}
{sensors.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-2">No sensors configured</p>
)}
</CardContent>
</Card>
);
}
// ── VESDA / Fire panel ────────────────────────────────────────────────────────
const VESDA_LEVEL_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
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 (
<Card className={cn("border", elevated.length > 0 && elevated.some(z => z.level === "fire") && "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">
<Flame className="w-4 h-4 text-orange-400" /> VESDA / Smoke Detection
</CardTitle>
<div className="flex items-center gap-2">
<Link href="/fire" className="text-[10px] text-primary hover:underline">View full page </Link>
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
elevated.length === 0 ? "bg-green-500/10 text-green-400" :
zones.some(z => 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`}
</span>
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
{zones.map(zone => {
const cfg = VESDA_LEVEL_CONFIG[zone.level] ?? VESDA_LEVEL_CONFIG.normal;
return (
<div key={zone.zone_id} className={cn("rounded-lg px-3 py-2 text-xs", cfg.bg)}>
<div className="flex items-center justify-between mb-1.5">
<div>
<p className="font-semibold">{zone.zone_id}</p>
{zone.room_id && <p className="text-muted-foreground">{zone.room_id}</p>}
</div>
<span className={cn("font-bold text-sm uppercase", cfg.color)}>{cfg.label}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
Obscuration: <strong className={cn(zone.level !== "normal" ? cfg.color : "")}>{zone.obscuration_pct_m != null ? `${zone.obscuration_pct_m.toFixed(3)} %/m` : "—"}</strong>
</span>
<div className="flex gap-2 text-[10px]">
{!zone.detector_1_ok && <span className="text-destructive">Det1 fault</span>}
{!zone.detector_2_ok && <span className="text-destructive">Det2 fault</span>}
{!zone.power_ok && <span className="text-destructive">Power fault</span>}
{!zone.flow_ok && <span className="text-destructive">Flow fault</span>}
{zone.detector_1_ok && zone.detector_2_ok && zone.power_ok && zone.flow_ok && (
<span className="text-green-400">Systems OK</span>
)}
</div>
</div>
</div>
);
})}
{zones.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-2">No VESDA zones configured</p>
)}
</CardContent>
</Card>
);
}
// ── Particle count panel (ISO 14644) ──────────────────────────────────────────
const ISO_LABELS: Record<number, { label: string; color: string }> = {
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 (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Wind className="w-4 h-4 text-primary" />
Air Quality ISO 14644
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{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 (
<div key={r.room_id} className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{r.room_id === "hall-a" ? "Hall A" : r.room_id === "hall-b" ? "Hall B" : r.room_id}</span>
{cls ? (
<span className={cn("text-xs font-semibold px-2 py-0.5 rounded-full bg-muted/40", cls.color)}>
{cls.label}
</span>
) : (
<span className="text-xs text-muted-foreground">No data</span>
)}
</div>
<div className="space-y-1.5 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span className="w-28 shrink-0">0.5 µm</span>
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
{p05pct !== null && (
<div
className={cn("h-full rounded-full transition-all", p05pct >= 100 ? "bg-destructive" : p05pct >= 70 ? "bg-amber-500" : "bg-green-500")}
style={{ width: `${p05pct}%` }}
/>
)}
</div>
<span className="w-32 text-right font-mono">
{r.particles_0_5um !== null ? r.particles_0_5um.toLocaleString() : "—"} /m³
</span>
</div>
<div className="flex items-center gap-2">
<span className="w-28 shrink-0">5 µm</span>
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
{p5pct !== null && (
<div
className={cn("h-full rounded-full transition-all", p5pct >= 100 ? "bg-destructive" : p5pct >= 70 ? "bg-amber-500" : "bg-green-500")}
style={{ width: `${p5pct}%` }}
/>
)}
</div>
<span className="w-32 text-right font-mono">
{r.particles_5um !== null ? r.particles_5um.toLocaleString() : "—"} /m³
</span>
</div>
</div>
</div>
);
})}
<p className="text-[10px] text-muted-foreground pt-1">
DC target: ISO 8 (3,520,000 particles 0.5 µm/m³ · 29,300 5 µm/m³)
</p>
</CardContent>
</Card>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function EnvironmentalPage() {
const { thresholds } = useThresholds();
const [rooms, setRooms] = useState<RoomEnvReadings[]>([]);
const [tempHist, setTempHist] = useState<TempBucket[]>([]);
const [humHist, setHumHist] = useState<HumidityBucket[]>([]);
const [cracs, setCracs] = useState<CracStatus[]>([]);
const [leakSensors, setLeak] = useState<LeakSensorStatus[]>([]);
const [fireZones, setFire] = useState<FireZoneStatus[]>([]);
const [particles, setParticles] = useState<ParticleStatus[]>([]);
const [hours, setHours] = useState(6);
const [loading, setLoading] = useState(true);
const [selectedRack, setSelectedRack] = useState<string | null>(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 (
<div className="p-6 space-y-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold">Environmental Monitoring</h1>
<p className="text-sm text-muted-foreground">Singapore DC01 refreshes every 30s</p>
</div>
<TimeRangePicker value={hours} onChange={setHours} />
</div>
{loading ? (
<div className="space-y-4">
<Skeleton className="h-64 w-full" />
<Skeleton className="h-48 w-full" />
</div>
) : (
<>
<RackDetailSheet siteId={SITE_ID} rackId={selectedRack} onClose={() => setSelectedRack(null)} />
{/* Page-level room tab selector */}
{rooms.length > 0 && (
<Tabs value={activeRoom} onValueChange={setActiveRoom}>
<TabsList>
{rooms.map(r => (
<TabsTrigger key={r.room_id} value={r.room_id} className="px-6">
{roomLabels[r.room_id] ?? r.room_id}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
{rooms.length > 0 && (
<TempHeatmap rooms={rooms} onRackClick={setSelectedRack} activeRoom={activeRoom}
tempWarn={thresholds.temp.warn} tempCrit={thresholds.temp.critical}
humWarn={thresholds.humidity.warn} humCrit={thresholds.humidity.critical} />
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{rooms.length > 0 && <AshraeTable rooms={rooms} />}
{rooms.length > 0 && (
<DewPointPanel rooms={rooms} cracs={cracs} activeRoom={activeRoom} />
)}
</div>
{/* Leak + VESDA panels */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<LeakPanel sensors={leakSensors} />
<FirePanel zones={fireZones} />
</div>
<EnvTrendChart tempData={tempHist} humData={humHist} hours={hours} activeRoom={activeRoom}
tempWarn={thresholds.temp.warn} tempCrit={thresholds.temp.critical}
humWarn={thresholds.humidity.warn} />
<ParticlePanel rooms={particles} />
</>
)}
</div>
);
}