first commit
This commit is contained in:
commit
4b98219bf7
144 changed files with 31561 additions and 0 deletions
846
frontend/app/(dashboard)/environmental/page.tsx
Normal file
846
frontend/app/(dashboard)/environmental/page.tsx
Normal file
|
|
@ -0,0 +1,846 @@
|
|||
"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 | 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: 30–65% | 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 & 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 (18–27°C / 20–80% RH)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<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 (15–32°C, 20–80% 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: 15–32°C dry bulb, 20–80% 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue