610 lines
29 KiB
TypeScript
610 lines
29 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import { toast } from "sonner";
|
|
import { fetchCracStatus, fetchChillerStatus, type CracStatus, type ChillerStatus } from "@/lib/api";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { CracDetailSheet } from "@/components/dashboard/crac-detail-sheet";
|
|
import {
|
|
Wind, AlertTriangle, CheckCircle2, Zap, ChevronRight, ArrowRight, Waves, Filter,
|
|
ChevronUp, ChevronDown,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const SITE_ID = "sg-01";
|
|
const roomLabels: Record<string, string> = { "hall-a": "Hall A", "hall-b": "Hall B" };
|
|
|
|
function fmt(v: number | null | undefined, dec = 1, unit = "") {
|
|
if (v == null) return "—";
|
|
return `${v.toFixed(dec)}${unit}`;
|
|
}
|
|
|
|
function FillBar({
|
|
value, max, color, warn, crit, height = "h-2",
|
|
}: {
|
|
value: number | null; max: number; color: string;
|
|
warn?: number; crit?: number; height?: string;
|
|
}) {
|
|
const pct = value != null ? Math.min(100, (value / max) * 100) : 0;
|
|
const barColor =
|
|
crit && value != null && value >= crit ? "#ef4444" :
|
|
warn && value != null && value >= warn ? "#f59e0b" :
|
|
color;
|
|
return (
|
|
<div className={cn("rounded-full bg-muted overflow-hidden w-full", height)}>
|
|
<div
|
|
className="h-full rounded-full transition-all duration-500"
|
|
style={{ width: `${pct}%`, backgroundColor: barColor }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function KpiTile({ label, value, sub, warn }: {
|
|
label: string; value: string; sub?: string; warn?: boolean;
|
|
}) {
|
|
return (
|
|
<div className="bg-muted/30 rounded-lg px-4 py-3 flex-1 min-w-0">
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">{label}</p>
|
|
<p className={cn("text-xl font-bold tabular-nums", warn && "text-amber-400")}>{value}</p>
|
|
{sub && <p className="text-[10px] text-muted-foreground mt-0.5">{sub}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CracCard({ crac, onOpen }: { crac: CracStatus; onOpen: () => void }) {
|
|
const [showCompressor, setShowCompressor] = useState(false);
|
|
const online = crac.state === "online";
|
|
|
|
const deltaWarn = (crac.delta ?? 0) > 11;
|
|
const deltaCrit = (crac.delta ?? 0) > 14;
|
|
const capWarn = (crac.cooling_capacity_pct ?? 0) > 75;
|
|
const capCrit = (crac.cooling_capacity_pct ?? 0) > 90;
|
|
const copWarn = (crac.cop ?? 99) < 1.5;
|
|
const filterWarn = (crac.filter_dp_pa ?? 0) > 80;
|
|
const filterCrit = (crac.filter_dp_pa ?? 0) > 120;
|
|
const compWarn = (crac.compressor_load_pct ?? 0) > 95;
|
|
const hiPWarn = (crac.high_pressure_bar ?? 0) > 22;
|
|
const loPWarn = (crac.low_pressure_bar ?? 99) < 3;
|
|
|
|
return (
|
|
<Card
|
|
className={cn(
|
|
"border cursor-pointer hover:border-primary/50 transition-colors",
|
|
!online && "border-destructive/40",
|
|
)}
|
|
onClick={onOpen}
|
|
>
|
|
{/* ── Header ───────────────────────────────────────────────── */}
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Wind className={cn("w-4 h-4", online ? "text-primary" : "text-destructive")} />
|
|
<div>
|
|
<CardTitle className="text-base font-semibold leading-none">
|
|
{crac.crac_id.toUpperCase()}
|
|
</CardTitle>
|
|
{crac.room_id && (
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{roomLabels[crac.room_id] ?? crac.room_id}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{online && (
|
|
<span className={cn(
|
|
"text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
|
|
deltaCrit || capCrit ? "bg-destructive/10 text-destructive" :
|
|
deltaWarn || capWarn || filterWarn || copWarn ? "bg-amber-500/10 text-amber-400" :
|
|
"bg-green-500/10 text-green-400",
|
|
)}>
|
|
{deltaCrit || capCrit ? "Critical" : deltaWarn || capWarn || filterWarn || copWarn ? "Warning" : "Normal"}
|
|
</span>
|
|
)}
|
|
<span className={cn(
|
|
"flex items-center gap-1 text-[10px] font-semibold px-2.5 py-1 rounded-full uppercase tracking-wide",
|
|
online ? "bg-green-500/10 text-green-400" : "bg-destructive/10 text-destructive",
|
|
)}>
|
|
{online
|
|
? <><CheckCircle2 className="w-3 h-3" /> Online</>
|
|
: <><AlertTriangle className="w-3 h-3" /> Fault</>}
|
|
</span>
|
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="space-y-4">
|
|
{!online ? (
|
|
<div className="rounded-md px-3 py-2 text-xs bg-destructive/10 text-destructive">
|
|
Unit offline — cooling capacity in this room is degraded.
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* ── Thermal hero ─────────────────────────────────────── */}
|
|
<div className="rounded-lg bg-muted/20 px-4 py-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-center">
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Supply</p>
|
|
<p className="text-3xl font-bold tabular-nums text-blue-400">
|
|
{fmt(crac.supply_temp, 1)}°C
|
|
</p>
|
|
</div>
|
|
<div className="flex-1 flex flex-col items-center gap-1 px-4">
|
|
<p className={cn(
|
|
"text-base font-bold tabular-nums",
|
|
deltaCrit ? "text-destructive" : deltaWarn ? "text-amber-400" : "text-muted-foreground",
|
|
)}>
|
|
ΔT {fmt(crac.delta, 1)}°C
|
|
</p>
|
|
<div className="flex items-center gap-1 w-full">
|
|
<div className="flex-1 h-px bg-muted-foreground/30" />
|
|
<ArrowRight className="w-3 h-3 text-muted-foreground/50 shrink-0" />
|
|
<div className="flex-1 h-px bg-muted-foreground/30" />
|
|
</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Return</p>
|
|
<p className={cn(
|
|
"text-3xl font-bold tabular-nums",
|
|
deltaCrit ? "text-destructive" : deltaWarn ? "text-amber-400" : "text-orange-400",
|
|
)}>
|
|
{fmt(crac.return_temp, 1)}°C
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Cooling capacity ─────────────────────────────────── */}
|
|
<div>
|
|
<div className="flex justify-between items-baseline mb-1.5">
|
|
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">Cooling Capacity</span>
|
|
<span className="text-xs font-mono">
|
|
<span className={cn(capCrit ? "text-destructive" : capWarn ? "text-amber-400" : "text-foreground")}>
|
|
{fmt(crac.cooling_capacity_kw, 1)} / {crac.rated_capacity_kw} kW
|
|
</span>
|
|
<span className="text-muted-foreground mx-1.5">·</span>
|
|
<span className={cn(copWarn ? "text-amber-400" : "text-foreground")}>
|
|
COP {fmt(crac.cop, 2)}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
<FillBar value={crac.cooling_capacity_pct} max={100} color="#34d399" warn={75} crit={90} />
|
|
<p className={cn(
|
|
"text-[10px] mt-1 text-right",
|
|
capCrit ? "text-destructive" : capWarn ? "text-amber-400" : "text-muted-foreground",
|
|
)}>
|
|
{fmt(crac.cooling_capacity_pct, 1)}% utilised
|
|
</p>
|
|
</div>
|
|
|
|
{/* ── Fan + Filter ─────────────────────────────────────── */}
|
|
<div className="space-y-2.5">
|
|
<div>
|
|
<div className="flex justify-between text-[10px] mb-1">
|
|
<span className="text-muted-foreground">Fan</span>
|
|
<span className="font-mono text-foreground">
|
|
{fmt(crac.fan_pct, 1)}%
|
|
{crac.fan_rpm != null ? ` · ${Math.round(crac.fan_rpm).toLocaleString()} rpm` : ""}
|
|
</span>
|
|
</div>
|
|
<FillBar value={crac.fan_pct} max={100} color="#60a5fa" height="h-1.5" />
|
|
</div>
|
|
<div>
|
|
<div className="flex justify-between text-[10px] mb-1">
|
|
<span className="text-muted-foreground">Filter ΔP</span>
|
|
<span className={cn(
|
|
"font-mono",
|
|
filterCrit ? "text-destructive" : filterWarn ? "text-amber-400" : "text-foreground",
|
|
)}>
|
|
{fmt(crac.filter_dp_pa, 0)} Pa
|
|
{!filterWarn && <span className="text-green-400 ml-1.5">✓</span>}
|
|
</span>
|
|
</div>
|
|
<FillBar value={crac.filter_dp_pa} max={150} color="#94a3b8" warn={80} crit={120} height="h-1.5" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Compressor (collapsible) ─────────────────────────── */}
|
|
<div className="rounded-md bg-muted/20 px-3 py-2.5 space-y-2">
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setShowCompressor(!showCompressor); }}
|
|
className="flex items-center justify-between w-full text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<span className="uppercase tracking-wide font-semibold">Compressor</span>
|
|
<span className="flex items-center gap-2">
|
|
<span className="font-mono">{fmt(crac.compressor_load_pct, 1)}% · {fmt(crac.compressor_power_kw, 2)} kW</span>
|
|
{showCompressor ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
|
</span>
|
|
</button>
|
|
{showCompressor && (
|
|
<div className="pt-2 space-y-2">
|
|
<FillBar value={crac.compressor_load_pct} max={100} color="#e879f9" warn={80} crit={95} height="h-1.5" />
|
|
<div className="flex justify-between text-[10px] text-muted-foreground pt-0.5">
|
|
<span className={cn(hiPWarn ? "text-destructive" : "")}>
|
|
Hi {fmt(crac.high_pressure_bar, 1)} bar
|
|
</span>
|
|
<span className={cn(loPWarn ? "text-destructive" : "")}>
|
|
Lo {fmt(crac.low_pressure_bar, 2)} bar
|
|
</span>
|
|
<span>SH {fmt(crac.discharge_superheat_c, 1)}°C</span>
|
|
<span>SC {fmt(crac.liquid_subcooling_c, 1)}°C</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Electrical (one line) ────────────────────────────── */}
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground border-t border-border/30 pt-3">
|
|
<Zap className="w-3 h-3 shrink-0" />
|
|
<span className="font-mono font-medium text-foreground">{fmt(crac.total_unit_power_kw, 2)} kW</span>
|
|
<span>·</span>
|
|
<span className="font-mono">{fmt(crac.input_voltage_v, 0)} V</span>
|
|
<span>·</span>
|
|
<span className="font-mono">{fmt(crac.input_current_a, 1)} A</span>
|
|
<span>·</span>
|
|
<span className="font-mono">PF {fmt(crac.power_factor, 3)}</span>
|
|
</div>
|
|
|
|
{/* ── Status banner ────────────────────────────────────── */}
|
|
<div className={cn(
|
|
"rounded-md px-3 py-2 text-xs",
|
|
deltaCrit || capCrit
|
|
? "bg-destructive/10 text-destructive"
|
|
: deltaWarn || capWarn || filterWarn || copWarn
|
|
? "bg-amber-500/10 text-amber-400"
|
|
: "bg-green-500/10 text-green-400",
|
|
)}>
|
|
{deltaCrit || capCrit
|
|
? "Heat load is high — check airflow or redistribute rack density."
|
|
: deltaWarn || capWarn
|
|
? "Heat load is elevated — monitor for further rises."
|
|
: filterWarn
|
|
? "Filter requires attention — airflow may be restricted."
|
|
: copWarn
|
|
? "Running inefficiently — check refrigerant charge."
|
|
: "Operating efficiently within normal parameters."}
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ── Filter replacement estimate ────────────────────────────────────
|
|
// Assumes ~1.2 Pa/day rate of rise — replace at 120 Pa threshold
|
|
|
|
const FILTER_REPLACE_PA = 120;
|
|
const FILTER_RATE_PA_DAY = 1.2;
|
|
|
|
function FilterEstimate({ cracs }: { cracs: CracStatus[] }) {
|
|
const units = cracs
|
|
.filter((c) => c.state === "online" && c.filter_dp_pa != null)
|
|
.map((c) => {
|
|
const dp = c.filter_dp_pa!;
|
|
const days = Math.max(0, Math.round((FILTER_REPLACE_PA - dp) / FILTER_RATE_PA_DAY));
|
|
const urgent = dp >= 120;
|
|
const warn = dp >= 80;
|
|
return { crac_id: c.crac_id, dp, days, urgent, warn };
|
|
})
|
|
.sort((a, b) => a.days - b.days);
|
|
|
|
if (units.length === 0) return null;
|
|
|
|
const anyUrgent = units.some((u) => u.urgent);
|
|
const anyWarn = units.some((u) => u.warn);
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
|
<Filter className="w-4 h-4 text-muted-foreground" />
|
|
Predictive Filter Replacement
|
|
</CardTitle>
|
|
<span className={cn(
|
|
"text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase",
|
|
anyUrgent ? "bg-destructive/10 text-destructive" :
|
|
anyWarn ? "bg-amber-500/10 text-amber-400" :
|
|
"bg-green-500/10 text-green-400",
|
|
)}>
|
|
{anyUrgent ? "Overdue" : anyWarn ? "Attention needed" : "All filters OK"}
|
|
</span>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
{units.map((u) => (
|
|
<div key={u.crac_id}>
|
|
<div className="flex items-center justify-between text-xs mb-1">
|
|
<span className="font-medium">{u.crac_id.toUpperCase()}</span>
|
|
<div className="flex items-center gap-3">
|
|
<span className={cn(
|
|
"font-mono",
|
|
u.urgent ? "text-destructive" : u.warn ? "text-amber-400" : "text-muted-foreground",
|
|
)}>
|
|
{u.dp} Pa
|
|
</span>
|
|
<span className={cn(
|
|
"text-[10px] px-2 py-0.5 rounded-full font-semibold",
|
|
u.urgent ? "bg-destructive/10 text-destructive" :
|
|
u.warn ? "bg-amber-500/10 text-amber-400" :
|
|
"bg-green-500/10 text-green-400",
|
|
)}>
|
|
{u.urgent ? "Replace now" : `~${u.days}d`}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="rounded-full bg-muted overflow-hidden h-1.5">
|
|
<div
|
|
className="h-full rounded-full transition-all duration-500"
|
|
style={{
|
|
width: `${Math.min(100, (u.dp / FILTER_REPLACE_PA) * 100)}%`,
|
|
backgroundColor: u.urgent ? "#ef4444" : u.warn ? "#f59e0b" : "#94a3b8",
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<p className="text-[10px] text-muted-foreground pt-1">
|
|
Estimated at {FILTER_RATE_PA_DAY} Pa/day increase · replace at {FILTER_REPLACE_PA} Pa threshold
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ── Chiller card ──────────────────────────────────────────────────
|
|
|
|
function ChillerCard({ chiller }: { chiller: ChillerStatus }) {
|
|
const online = chiller.state === "online";
|
|
const loadWarn = (chiller.cooling_load_pct ?? 0) > 80;
|
|
|
|
return (
|
|
<Card className={cn("border", !online && "border-destructive/40")}>
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
|
<Waves className="w-4 h-4 text-blue-400" />
|
|
{chiller.chiller_id.toUpperCase()} — Chiller Plant
|
|
</CardTitle>
|
|
<span className={cn("text-[10px] font-semibold px-2.5 py-1 rounded-full uppercase tracking-wide",
|
|
online ? "bg-green-500/10 text-green-400" : "bg-destructive/10 text-destructive",
|
|
)}>
|
|
{online ? <><CheckCircle2 className="w-3 h-3 inline mr-0.5" /> Online</> : <><AlertTriangle className="w-3 h-3 inline mr-0.5" /> Fault</>}
|
|
</span>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{!online ? (
|
|
<div className="rounded-md px-3 py-2 text-xs bg-destructive/10 text-destructive">
|
|
Chiller fault — CHW supply lost. CRAC/CRAH units relying on local refrigerant circuits only.
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* CHW temps */}
|
|
<div className="rounded-lg bg-muted/20 px-4 py-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-center">
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">CHW Supply</p>
|
|
<p className="text-2xl font-bold tabular-nums text-blue-400">{fmt(chiller.chw_supply_c, 1)}°C</p>
|
|
</div>
|
|
<div className="flex-1 flex flex-col items-center gap-1 px-4">
|
|
<p className="text-sm font-bold tabular-nums text-muted-foreground">ΔT {fmt(chiller.chw_delta_c, 1)}°C</p>
|
|
<div className="flex items-center gap-1 w-full">
|
|
<div className="flex-1 h-px bg-muted-foreground/30" />
|
|
<ArrowRight className="w-3 h-3 text-muted-foreground/50 shrink-0" />
|
|
<div className="flex-1 h-px bg-muted-foreground/30" />
|
|
</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">CHW Return</p>
|
|
<p className="text-2xl font-bold tabular-nums text-orange-400">{fmt(chiller.chw_return_c, 1)}°C</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* Load */}
|
|
<div>
|
|
<div className="flex justify-between items-baseline mb-1.5">
|
|
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">Cooling Load</span>
|
|
<span className="text-xs font-mono">
|
|
<span className={cn(loadWarn ? "text-amber-400" : "")}>{fmt(chiller.cooling_load_kw, 1)} kW</span>
|
|
<span className="text-muted-foreground mx-1.5">·</span>
|
|
<span>COP {fmt(chiller.cop, 2)}</span>
|
|
</span>
|
|
</div>
|
|
<FillBar value={chiller.cooling_load_pct} max={100} color="#34d399" warn={80} crit={95} />
|
|
<p className="text-[10px] mt-1 text-right text-muted-foreground">{fmt(chiller.cooling_load_pct, 1)}% load</p>
|
|
</div>
|
|
{/* Details */}
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px]">
|
|
<div className="flex justify-between"><span className="text-muted-foreground">Flow rate</span><span className="font-mono">{fmt(chiller.flow_gpm, 0)} GPM</span></div>
|
|
<div className="flex justify-between"><span className="text-muted-foreground">Comp load</span><span className="font-mono">{fmt(chiller.compressor_load_pct, 1)}%</span></div>
|
|
<div className="flex justify-between"><span className="text-muted-foreground">Cond press</span><span className="font-mono">{fmt(chiller.condenser_pressure_bar, 2)} bar</span></div>
|
|
<div className="flex justify-between"><span className="text-muted-foreground">Evap press</span><span className="font-mono">{fmt(chiller.evaporator_pressure_bar, 2)} bar</span></div>
|
|
<div className="flex justify-between"><span className="text-muted-foreground">CW supply</span><span className="font-mono">{fmt(chiller.cw_supply_c, 1)}°C</span></div>
|
|
<div className="flex justify-between"><span className="text-muted-foreground">CW return</span><span className="font-mono">{fmt(chiller.cw_return_c, 1)}°C</span></div>
|
|
</div>
|
|
<div className="text-[10px] text-muted-foreground border-t border-border/30 pt-2">
|
|
Run hours: <strong className="text-foreground">{chiller.run_hours != null ? chiller.run_hours.toFixed(0) : "—"} h</strong>
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
|
|
|
export default function CoolingPage() {
|
|
const [cracs, setCracs] = useState<CracStatus[]>([]);
|
|
const [chillers, setChillers] = useState<ChillerStatus[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedCrac, setSelected] = useState<string | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const [c, ch] = await Promise.all([
|
|
fetchCracStatus(SITE_ID),
|
|
fetchChillerStatus(SITE_ID).catch(() => []),
|
|
]);
|
|
setCracs(c);
|
|
setChillers(ch);
|
|
}
|
|
catch { toast.error("Failed to load cooling data"); }
|
|
finally { setLoading(false); }
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
const id = setInterval(load, 30_000);
|
|
return () => clearInterval(id);
|
|
}, [load]);
|
|
|
|
const online = cracs.filter(c => c.state === "online");
|
|
const anyFaulted = cracs.some(c => c.state === "fault");
|
|
const totalCoolingKw = online.reduce((s, c) => s + (c.cooling_capacity_kw ?? 0), 0);
|
|
const totalRatedKw = cracs.reduce((s, c) => s + (c.rated_capacity_kw ?? 0), 0);
|
|
const copUnits = online.filter(c => c.cop != null);
|
|
const avgCop = copUnits.length > 0
|
|
? copUnits.reduce((s, c) => s + (c.cop ?? 0), 0) / copUnits.length
|
|
: null;
|
|
const totalUnitPower = online.reduce((s, c) => s + (c.total_unit_power_kw ?? 0), 0);
|
|
const totalAirflowCfm = online.reduce((s, c) => s + (c.airflow_cfm ?? 0), 0);
|
|
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
{/* ── Page header ───────────────────────────────────────────── */}
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-semibold">Cooling Systems</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Singapore DC01 · click a unit to drill down · refreshes every 30s
|
|
</p>
|
|
</div>
|
|
{!loading && (
|
|
<span className={cn(
|
|
"flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-full",
|
|
anyFaulted ? "bg-destructive/10 text-destructive" : "bg-green-500/10 text-green-400",
|
|
)}>
|
|
{anyFaulted
|
|
? <><AlertTriangle className="w-3.5 h-3.5" /> Cooling fault detected</>
|
|
: <><CheckCircle2 className="w-3.5 h-3.5" /> All {cracs.length} units operational</>}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Filter alert banner ───────────────────────────────────── */}
|
|
{!loading && (() => {
|
|
const urgent = cracs
|
|
.filter(c => c.state === "online" && c.filter_dp_pa != null)
|
|
.map(c => ({ id: c.crac_id, days: Math.max(0, Math.round((120 - c.filter_dp_pa!) / 1.2)) }))
|
|
.filter(c => c.days < 14)
|
|
.sort((a, b) => a.days - b.days);
|
|
if (urgent.length === 0) return null;
|
|
return (
|
|
<div className="flex items-center gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm">
|
|
<Filter className="w-4 h-4 text-amber-400 shrink-0" />
|
|
<span className="text-amber-400">
|
|
<strong>Filter replacement due:</strong>{" "}
|
|
{urgent.map(u => `${u.id.toUpperCase()} in ${u.days === 0 ? "now" : `~${u.days}d`}`).join(", ")}
|
|
</span>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* ── Fleet summary KPI cards ───────────────────────────────── */}
|
|
{loading && (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
|
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-20" />)}
|
|
</div>
|
|
)}
|
|
{!loading && cracs.length > 0 && (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Cooling Load</p>
|
|
<p className="text-2xl font-bold tabular-nums">{totalCoolingKw.toFixed(1)} kW</p>
|
|
<p className="text-[10px] text-muted-foreground mt-0.5">of {totalRatedKw} kW rated</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Avg COP</p>
|
|
<p className={cn("text-2xl font-bold tabular-nums", avgCop != null && avgCop < 1.5 && "text-amber-400")}>
|
|
{avgCop != null ? avgCop.toFixed(2) : "—"}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Unit Power Draw</p>
|
|
<p className="text-2xl font-bold tabular-nums">{totalUnitPower.toFixed(1)} kW</p>
|
|
<p className="text-[10px] text-muted-foreground mt-0.5">total electrical input</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Units Online</p>
|
|
<p className={cn("text-2xl font-bold tabular-nums", anyFaulted && "text-amber-400")}>
|
|
{online.length} / {cracs.length}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
{totalAirflowCfm > 0 && (
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Total Airflow</p>
|
|
<p className="text-2xl font-bold tabular-nums">{Math.round(totalAirflowCfm).toLocaleString()}</p>
|
|
<p className="text-[10px] text-muted-foreground mt-0.5">CFM combined output</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Chiller plant ─────────────────────────────────────────── */}
|
|
{(loading || chillers.length > 0) && (
|
|
<>
|
|
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">Chiller Plant</h2>
|
|
{loading ? (
|
|
<Skeleton className="h-56" />
|
|
) : (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{chillers.map(ch => <ChillerCard key={ch.chiller_id} chiller={ch} />)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* ── Filter health (moved before CRAC cards) ───────────────── */}
|
|
{!loading && <FilterEstimate cracs={cracs} />}
|
|
|
|
{/* ── CRAC cards ────────────────────────────────────────────── */}
|
|
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">CRAC / CRAH Units</h2>
|
|
{loading ? (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<Skeleton className="h-72" />
|
|
<Skeleton className="h-72" />
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{cracs.map(crac => (
|
|
<CracCard key={crac.crac_id} crac={crac} onOpen={() => setSelected(crac.crac_id)} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<CracDetailSheet
|
|
siteId={SITE_ID}
|
|
cracId={selectedCrac}
|
|
onClose={() => setSelected(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|