858 lines
38 KiB
TypeScript
858 lines
38 KiB
TypeScript
"use client";
|
|
|
|
import React from "react";
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import {
|
|
fetchKpis, fetchRackBreakdown, fetchRoomPowerHistory, fetchUpsStatus, fetchCapacitySummary,
|
|
fetchGeneratorStatus, fetchAtsStatus, fetchPowerRedundancy, fetchPhaseBreakdown,
|
|
type KpiData, type RoomPowerBreakdown, type PowerHistoryBucket, type UpsAsset, type CapacitySummary,
|
|
type GeneratorStatus, type AtsStatus, type PowerRedundancy, type RoomPhase,
|
|
} 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 {
|
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
|
|
AreaChart, Area, Cell,
|
|
} from "recharts";
|
|
import { Zap, Battery, AlertTriangle, CheckCircle2, Activity, Fuel, ArrowLeftRight, ShieldCheck, Server, Thermometer, Gauge } from "lucide-react";
|
|
import { UpsDetailSheet } from "@/components/dashboard/ups-detail-sheet";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const SITE_ID = "sg-01";
|
|
|
|
const ROOM_COLORS: Record<string, string> = {
|
|
"hall-a": "oklch(0.62 0.17 212)",
|
|
"hall-b": "oklch(0.7 0.15 162)",
|
|
};
|
|
|
|
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" });
|
|
}
|
|
|
|
// ── Site capacity bar ─────────────────────────────────────────────────────────
|
|
|
|
function SiteCapacityBar({ usedKw, capacityKw }: { usedKw: number; capacityKw: number }) {
|
|
const pct = capacityKw > 0 ? Math.min(100, (usedKw / capacityKw) * 100) : 0;
|
|
const barColor =
|
|
pct >= 85 ? "bg-destructive" :
|
|
pct >= 70 ? "bg-amber-500" :
|
|
"bg-primary";
|
|
const textColor =
|
|
pct >= 85 ? "text-destructive" :
|
|
pct >= 70 ? "text-amber-400" :
|
|
"text-green-400";
|
|
|
|
return (
|
|
<div className="rounded-xl border border-border bg-muted/10 px-5 py-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2 text-sm font-medium">
|
|
<Zap className="w-4 h-4 text-primary" />
|
|
Site IT Load vs Rated Capacity
|
|
</div>
|
|
<span className={cn("text-xs font-semibold", textColor)}>
|
|
{pct.toFixed(1)}% utilised
|
|
</span>
|
|
</div>
|
|
<div className="h-3 rounded-full bg-muted overflow-hidden">
|
|
<div
|
|
className={cn("h-full rounded-full transition-all duration-700", barColor)}
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-between text-xs text-muted-foreground">
|
|
<span>
|
|
<strong className="text-foreground">{usedKw.toFixed(1)} kW</strong> in use
|
|
</span>
|
|
<span>
|
|
<strong className="text-foreground">{(capacityKw - usedKw).toFixed(1)} kW</strong> headroom
|
|
</span>
|
|
<span>
|
|
<strong className="text-foreground">{capacityKw.toFixed(0)} kW</strong> rated
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── KPI card ──────────────────────────────────────────────────────────────────
|
|
|
|
function KpiCard({ label, value, sub, icon: Icon, accent }: {
|
|
label: string; value: string; sub?: string; icon: React.ElementType; accent?: boolean
|
|
}) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="p-4 flex items-center gap-3">
|
|
<div className={cn("p-2 rounded-lg", accent ? "bg-primary/10" : "bg-muted")}>
|
|
<Icon className={cn("w-4 h-4", accent ? "text-primary" : "text-muted-foreground")} />
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-bold">{value}</p>
|
|
<p className="text-xs text-muted-foreground">{label}</p>
|
|
{sub && <p className="text-[10px] text-muted-foreground">{sub}</p>}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ── UPS card ──────────────────────────────────────────────────────────────────
|
|
|
|
function UpsCard({ ups, onClick }: { ups: UpsAsset; onClick: () => void }) {
|
|
const onBattery = ups.state === "battery";
|
|
const overload = ups.state === "overload";
|
|
const abnormal = onBattery || overload;
|
|
const charge = ups.charge_pct ?? 0;
|
|
const runtime = ups.runtime_min ?? null;
|
|
|
|
return (
|
|
<Card
|
|
className={cn("border cursor-pointer hover:border-primary/40 transition-colors",
|
|
overload && "border-destructive/50",
|
|
onBattery && !overload && "border-amber-500/40",
|
|
)}
|
|
onClick={onClick}
|
|
>
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
|
<Battery className="w-4 h-4 text-primary" />
|
|
{ups.ups_id.toUpperCase()}
|
|
</CardTitle>
|
|
<span className={cn(
|
|
"flex items-center gap-1.5 text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
|
|
overload ? "bg-destructive/10 text-destructive" :
|
|
onBattery ? "bg-amber-500/10 text-amber-400" :
|
|
"bg-green-500/10 text-green-400"
|
|
)}>
|
|
{abnormal ? <AlertTriangle className="w-3 h-3" /> : <CheckCircle2 className="w-3 h-3" />}
|
|
{overload ? "Overloaded" : onBattery ? "On Battery" : "Mains"}
|
|
</span>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{/* Battery charge */}
|
|
<div className="space-y-1">
|
|
<div className="flex justify-between text-[11px] text-muted-foreground">
|
|
<span>Battery charge</span>
|
|
<span className="font-medium text-foreground">{ups.charge_pct !== null ? `${ups.charge_pct}%` : "—"}</span>
|
|
</div>
|
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
|
<div
|
|
className={cn("h-full rounded-full transition-all duration-500",
|
|
charge < 50 ? "bg-destructive" : charge < 80 ? "bg-amber-500" : "bg-green-500"
|
|
)}
|
|
style={{ width: `${charge}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-3 text-xs">
|
|
<div>
|
|
<p className="text-muted-foreground">Load</p>
|
|
<p className={cn("font-semibold",
|
|
(ups.load_pct ?? 0) >= 95 ? "text-destructive" :
|
|
(ups.load_pct ?? 0) >= 85 ? "text-amber-400" : "",
|
|
)}>
|
|
{ups.load_pct !== null ? `${ups.load_pct}%` : "—"}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-muted-foreground">Runtime</p>
|
|
<p className={cn(
|
|
"font-semibold",
|
|
runtime !== null && runtime < 5 ? "text-destructive" :
|
|
runtime !== null && runtime < 15 ? "text-amber-400" : ""
|
|
)}>
|
|
{runtime !== null ? `${Math.round(runtime)} min` : "—"}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-muted-foreground">Voltage</p>
|
|
<p className={cn("font-semibold",
|
|
ups.voltage_v !== null && (ups.voltage_v < 210 || ups.voltage_v > 250) ? "text-amber-400" : "",
|
|
)}>
|
|
{ups.voltage_v !== null ? `${ups.voltage_v} V` : "—"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Runtime bar */}
|
|
{runtime !== null && (
|
|
<div className="space-y-1">
|
|
<div className="flex justify-between text-[10px] text-muted-foreground">
|
|
<span>Est. runtime remaining</span>
|
|
<span className={cn(
|
|
"font-medium",
|
|
runtime < 5 ? "text-destructive" :
|
|
runtime < 15 ? "text-amber-400" : "text-green-400"
|
|
)}>
|
|
{runtime < 5 ? "Critical" : runtime < 15 ? "Low" : "OK"}
|
|
</span>
|
|
</div>
|
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
|
<div
|
|
className={cn("h-full rounded-full transition-all duration-500",
|
|
runtime < 5 ? "bg-destructive" :
|
|
runtime < 15 ? "bg-amber-500" : "bg-green-500"
|
|
)}
|
|
style={{ width: `${Math.min(100, (runtime / 120) * 100)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-[10px] text-muted-foreground/50 text-right">Click for details</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ── Rack bar chart ────────────────────────────────────────────────────────────
|
|
|
|
function RackPowerChart({ rooms }: { rooms: RoomPowerBreakdown[] }) {
|
|
const [activeRoom, setActiveRoom] = useState(rooms[0]?.room_id ?? "");
|
|
const room = rooms.find((r) => r.room_id === activeRoom);
|
|
|
|
if (!room) return null;
|
|
|
|
const maxKw = Math.max(...room.racks.map((r) => r.power_kw ?? 0), 1);
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm font-semibold">Per-Rack Power (kW)</CardTitle>
|
|
<Tabs value={activeRoom} onValueChange={setActiveRoom}>
|
|
<TabsList className="h-7">
|
|
{rooms.map((r) => (
|
|
<TabsTrigger key={r.room_id} value={r.room_id} className="text-xs px-2 py-0.5">
|
|
{roomLabels[r.room_id] ?? r.room_id}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center gap-3 mb-3 text-[10px] text-muted-foreground">
|
|
{[
|
|
{ color: "oklch(0.62 0.17 212)", label: "Normal" },
|
|
{ color: "oklch(0.68 0.14 162)", label: "Moderate" },
|
|
{ color: "oklch(0.65 0.20 45)", label: "High (≥7.5 kW)" },
|
|
{ color: "oklch(0.55 0.22 25)", label: "Critical (≥9.5 kW)" },
|
|
].map(({ color, label }) => (
|
|
<span key={label} className="flex items-center gap-1">
|
|
<span className="w-3 h-2 rounded-sm inline-block" style={{ backgroundColor: color }} />
|
|
{label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<ResponsiveContainer width="100%" height={220}>
|
|
<BarChart data={room.racks} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
|
|
<XAxis
|
|
dataKey="rack_id"
|
|
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickFormatter={(v) => v.replace("rack-", "")}
|
|
/>
|
|
<YAxis
|
|
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
domain={[0, Math.ceil(maxKw * 1.2)]}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "12px" }}
|
|
formatter={(v) => [`${v} kW`, "Power"]}
|
|
labelFormatter={(l) => l}
|
|
/>
|
|
<ReferenceLine y={7.5} stroke="oklch(0.72 0.18 84)" strokeDasharray="4 4" strokeWidth={1}
|
|
label={{ value: "Warn 7.5kW", fontSize: 9, fill: "oklch(0.72 0.18 84)", position: "insideTopRight" }} />
|
|
<ReferenceLine y={9.5} stroke="oklch(0.55 0.22 25)" strokeDasharray="4 4" strokeWidth={1}
|
|
label={{ value: "Crit 9.5kW", fontSize: 9, fill: "oklch(0.55 0.22 25)", position: "insideTopRight" }} />
|
|
<Bar dataKey="power_kw" radius={[3, 3, 0, 0]} maxBarSize={32}>
|
|
{room.racks.map((r) => (
|
|
<Cell
|
|
key={r.rack_id}
|
|
fill={
|
|
(r.power_kw ?? 0) >= 9.5 ? "oklch(0.55 0.22 25)" :
|
|
(r.power_kw ?? 0) >= 7.5 ? "oklch(0.65 0.20 45)" :
|
|
(r.power_kw ?? 0) >= 4.0 ? "oklch(0.68 0.14 162)" :
|
|
ROOM_COLORS[room.room_id] ?? "oklch(0.62 0.17 212)"
|
|
}
|
|
/>
|
|
))}
|
|
</Bar>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ── Room power history chart ──────────────────────────────────────────────────
|
|
|
|
function RoomPowerHistoryChart({ data }: { data: PowerHistoryBucket[] }) {
|
|
type Row = { time: string; [room: string]: string | number };
|
|
const bucketMap = new Map<string, Row>();
|
|
for (const row of data) {
|
|
const time = formatTime(row.bucket);
|
|
if (!bucketMap.has(time)) bucketMap.set(time, { time });
|
|
bucketMap.get(time)![row.room_id] = row.total_kw;
|
|
}
|
|
const chartData = Array.from(bucketMap.values());
|
|
const roomIds = [...new Set(data.map((d) => d.room_id))].sort();
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm font-semibold">Power by Room</CardTitle>
|
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
{roomIds.map((id) => (
|
|
<span key={id} className="flex items-center gap-1">
|
|
<span className="w-3 h-0.5 inline-block" style={{ backgroundColor: ROOM_COLORS[id] }} />
|
|
{roomLabels[id] ?? id}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{chartData.length === 0 ? (
|
|
<div className="h-[200px] flex items-center justify-center text-sm text-muted-foreground">
|
|
Waiting for data...
|
|
</div>
|
|
) : (
|
|
<ResponsiveContainer width="100%" height={200}>
|
|
<AreaChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
|
<defs>
|
|
{roomIds.map((id) => (
|
|
<linearGradient key={id} id={`grad-${id}`} x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor={ROOM_COLORS[id]} stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor={ROOM_COLORS[id]} stopOpacity={0} />
|
|
</linearGradient>
|
|
))}
|
|
</defs>
|
|
<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} />
|
|
<YAxis tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
|
|
<Tooltip
|
|
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "12px" }}
|
|
formatter={(v, name) => [`${v} kW`, roomLabels[String(name)] ?? String(name)]}
|
|
/>
|
|
{roomIds.map((id) => (
|
|
<Area key={id} type="monotone" dataKey={id} stroke={ROOM_COLORS[id]} fill={`url(#grad-${id})`} strokeWidth={2} dot={false} />
|
|
))}
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ── Generator card ────────────────────────────────────────────────
|
|
|
|
function GeneratorCard({ gen }: { gen: GeneratorStatus }) {
|
|
const faulted = gen.state === "fault";
|
|
const running = gen.state === "running" || gen.state === "test";
|
|
const fuel = gen.fuel_pct ?? 0;
|
|
const stateLabel = { standby: "Standby", running: "Running", test: "Test Run", fault: "FAULT", unknown: "Unknown" }[gen.state] ?? gen.state;
|
|
|
|
return (
|
|
<Card className={cn("border", faulted && "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">
|
|
<Fuel className="w-4 h-4 text-primary" />
|
|
{gen.gen_id.toUpperCase()}
|
|
</CardTitle>
|
|
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
|
|
faulted ? "bg-destructive/10 text-destructive" :
|
|
running ? "bg-amber-500/10 text-amber-400" :
|
|
"bg-green-500/10 text-green-400",
|
|
)}>
|
|
{faulted ? <AlertTriangle className="w-3 h-3 inline mr-0.5" /> : <CheckCircle2 className="w-3 h-3 inline mr-0.5" />}
|
|
{stateLabel}
|
|
</span>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div className="space-y-1">
|
|
<div className="flex justify-between text-[11px] text-muted-foreground">
|
|
<span>Fuel level</span>
|
|
<span className={cn("font-medium", fuel < 10 ? "text-destructive" : fuel < 25 ? "text-amber-400" : "text-foreground")}>
|
|
{gen.fuel_pct != null ? `${gen.fuel_pct.toFixed(1)}%` : "—"}
|
|
{gen.fuel_litres != null ? ` (${gen.fuel_litres.toFixed(0)} L)` : ""}
|
|
</span>
|
|
</div>
|
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
|
<div className={cn("h-full rounded-full transition-all duration-500",
|
|
fuel < 10 ? "bg-destructive" : fuel < 25 ? "bg-amber-500" : "bg-green-500"
|
|
)} style={{ width: `${fuel}%` }} />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
|
<div><p className="text-muted-foreground">Load</p>
|
|
<p className={cn("font-semibold",
|
|
(gen.load_pct ?? 0) >= 95 ? "text-destructive" :
|
|
(gen.load_pct ?? 0) >= 85 ? "text-amber-400" : ""
|
|
)}>
|
|
{gen.load_kw != null ? `${gen.load_kw} kW (${gen.load_pct}%)` : "—"}
|
|
</p>
|
|
</div>
|
|
<div><p className="text-muted-foreground">Run hours</p>
|
|
<p className="font-semibold">{gen.run_hours != null ? `${gen.run_hours.toFixed(0)} h` : "—"}</p></div>
|
|
<div><p className="text-muted-foreground">Output voltage</p>
|
|
<p className="font-semibold">{gen.voltage_v != null && gen.voltage_v > 0 ? `${gen.voltage_v} V` : "—"}</p></div>
|
|
<div><p className="text-muted-foreground">Frequency</p>
|
|
<p className="font-semibold">{gen.frequency_hz != null && gen.frequency_hz > 0 ? `${gen.frequency_hz} Hz` : "—"}</p></div>
|
|
<div><p className="text-muted-foreground flex items-center gap-1">
|
|
<Thermometer className="w-3 h-3" />Coolant
|
|
</p>
|
|
<p className={cn("font-semibold",
|
|
(gen.coolant_temp_c ?? 0) >= 105 ? "text-destructive" :
|
|
(gen.coolant_temp_c ?? 0) >= 95 ? "text-amber-400" : ""
|
|
)}>
|
|
{gen.coolant_temp_c != null ? `${gen.coolant_temp_c}°C` : "—"}
|
|
</p>
|
|
</div>
|
|
<div><p className="text-muted-foreground flex items-center gap-1">
|
|
<Thermometer className="w-3 h-3" />Exhaust
|
|
</p>
|
|
<p className="font-semibold">{gen.exhaust_temp_c != null && gen.exhaust_temp_c > 0 ? `${gen.exhaust_temp_c}°C` : "—"}</p>
|
|
</div>
|
|
<div><p className="text-muted-foreground flex items-center gap-1">
|
|
<Gauge className="w-3 h-3" />Oil pressure
|
|
</p>
|
|
<p className={cn("font-semibold",
|
|
gen.oil_pressure_bar !== null && gen.oil_pressure_bar < 2 ? "text-destructive" : ""
|
|
)}>
|
|
{gen.oil_pressure_bar != null && gen.oil_pressure_bar > 0 ? `${gen.oil_pressure_bar} bar` : "—"}
|
|
</p>
|
|
</div>
|
|
<div><p className="text-muted-foreground">Battery</p>
|
|
<p className="font-semibold">{gen.battery_v != null ? `${gen.battery_v} V` : "—"}</p></div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ── ATS card ──────────────────────────────────────────────────────
|
|
|
|
function AtsCard({ ats }: { ats: AtsStatus }) {
|
|
const onGenerator = ats.active_feed === "generator";
|
|
const transferring = ats.state === "transferring";
|
|
const feedLabel: Record<string, string> = {
|
|
"utility-a": "Utility A", "utility-b": "Utility B", "generator": "Generator",
|
|
};
|
|
|
|
return (
|
|
<Card className={cn("border", onGenerator && "border-amber-500/40")}>
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
|
<ArrowLeftRight className="w-4 h-4 text-primary" />
|
|
{ats.ats_id.toUpperCase()}
|
|
</CardTitle>
|
|
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
|
|
transferring ? "bg-amber-500/10 text-amber-400 animate-pulse" :
|
|
onGenerator ? "bg-amber-500/10 text-amber-400" :
|
|
"bg-green-500/10 text-green-400",
|
|
)}>
|
|
{transferring ? "Transferring" : onGenerator ? "Generator feed" : "Stable"}
|
|
</span>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div className="text-center rounded-lg bg-muted/20 py-3">
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Active Feed</p>
|
|
<p className={cn("text-xl font-bold", onGenerator ? "text-amber-400" : "text-green-400")}>
|
|
{feedLabel[ats.active_feed] ?? ats.active_feed}
|
|
</p>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-2 text-[11px]">
|
|
{[
|
|
{ label: "Utility A", v: ats.utility_a_v, active: ats.active_feed === "utility-a" },
|
|
{ label: "Utility B", v: ats.utility_b_v, active: ats.active_feed === "utility-b" },
|
|
{ label: "Generator", v: ats.generator_v, active: ats.active_feed === "generator" },
|
|
].map(({ label, v, active }) => (
|
|
<div key={label} className={cn("rounded-md px-2 py-1.5 text-center",
|
|
active ? "bg-primary/10 border border-primary/20" : "bg-muted/20",
|
|
)}>
|
|
<p className="text-muted-foreground">{label}</p>
|
|
<p className={cn("font-semibold", active ? "text-foreground" : "text-muted-foreground")}>
|
|
{v != null && v > 0 ? `${v.toFixed(0)} V` : "—"}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="flex justify-between text-[11px] text-muted-foreground border-t border-border/30 pt-2">
|
|
<span>Transfers: <strong className="text-foreground">{ats.transfer_count}</strong></span>
|
|
{ats.last_transfer_ms != null && (
|
|
<span>Last xfer: <strong className="text-foreground">{ats.last_transfer_ms} ms</strong></span>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ── Redundancy banner ─────────────────────────────────────────────
|
|
|
|
function RedundancyBanner({ r }: { r: PowerRedundancy }) {
|
|
const color =
|
|
r.level === "2N" ? "border-green-500/30 bg-green-500/5 text-green-400" :
|
|
r.level === "N+1" ? "border-amber-500/30 bg-amber-500/5 text-amber-400" :
|
|
"border-destructive/30 bg-destructive/5 text-destructive";
|
|
|
|
return (
|
|
<div className={cn("flex items-center justify-between rounded-xl border px-5 py-3", color)}>
|
|
<div className="flex items-center gap-3">
|
|
<ShieldCheck className="w-5 h-5 shrink-0" />
|
|
<div>
|
|
<p className="text-sm font-bold">Power Redundancy: {r.level}</p>
|
|
<p className="text-xs opacity-70">{r.notes}</p>
|
|
</div>
|
|
</div>
|
|
<div className="text-right text-xs opacity-80 space-y-0.5">
|
|
<p>UPS online: {r.ups_online}/{r.ups_total}</p>
|
|
<p>Generator: {r.generator_ok ? "available" : "unavailable"}</p>
|
|
<p>Feed: {r.ats_active_feed ?? "—"}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Phase imbalance table ──────────────────────────────────────────
|
|
|
|
function PhaseImbalanceTable({ rooms }: { rooms: RoomPhase[] }) {
|
|
const allRacks = rooms.flatMap(r => r.racks).filter(r => (r.imbalance_pct ?? 0) > 5);
|
|
if (allRacks.length === 0) return null;
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
|
<AlertTriangle className="w-4 h-4 text-amber-400" />
|
|
Phase Imbalance — {allRacks.length} rack{allRacks.length !== 1 ? "s" : ""} flagged
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="text-muted-foreground border-b border-border/30">
|
|
<th className="text-left py-1.5 pr-3 font-medium">Rack</th>
|
|
<th className="text-right py-1.5 pr-3 font-medium">Phase A kW</th>
|
|
<th className="text-right py-1.5 pr-3 font-medium">Phase B kW</th>
|
|
<th className="text-right py-1.5 pr-3 font-medium">Phase C kW</th>
|
|
<th className="text-right py-1.5 font-medium">Imbalance</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{allRacks.map(rack => {
|
|
const imb = rack.imbalance_pct ?? 0;
|
|
const crit = imb >= 15;
|
|
return (
|
|
<tr key={rack.rack_id} className="border-b border-border/10">
|
|
<td className="py-1.5 pr-3 font-medium">{rack.rack_id}</td>
|
|
<td className="text-right pr-3 tabular-nums">{rack.phase_a_kw?.toFixed(2) ?? "—"}</td>
|
|
<td className="text-right pr-3 tabular-nums">{rack.phase_b_kw?.toFixed(2) ?? "—"}</td>
|
|
<td className="text-right pr-3 tabular-nums">{rack.phase_c_kw?.toFixed(2) ?? "—"}</td>
|
|
<td className={cn("text-right tabular-nums font-semibold", crit ? "text-destructive" : "text-amber-400")}>
|
|
{imb.toFixed(1)}%
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
|
|
|
export default function PowerPage() {
|
|
const [kpis, setKpis] = useState<KpiData | null>(null);
|
|
const [racks, setRacks] = useState<RoomPowerBreakdown[]>([]);
|
|
const [history, setHistory] = useState<PowerHistoryBucket[]>([]);
|
|
const [ups, setUps] = useState<UpsAsset[]>([]);
|
|
const [capacity, setCapacity] = useState<CapacitySummary | null>(null);
|
|
const [generators, setGenerators] = useState<GeneratorStatus[]>([]);
|
|
const [atsUnits, setAtsUnits] = useState<AtsStatus[]>([]);
|
|
const [redundancy, setRedundancy] = useState<PowerRedundancy | null>(null);
|
|
const [phases, setPhases] = useState<RoomPhase[]>([]);
|
|
const [historyHours, setHistoryHours] = useState(6);
|
|
const [loading, setLoading] = useState(true);
|
|
const [phaseExpanded, setPhaseExpanded] = useState(false);
|
|
const [selectedUps, setSelectedUps] = useState<typeof ups[0] | null>(null);
|
|
|
|
const loadHistory = useCallback(async () => {
|
|
try {
|
|
const h = await fetchRoomPowerHistory(SITE_ID, historyHours);
|
|
setHistory(h);
|
|
} catch { /* keep stale */ }
|
|
}, [historyHours]);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const [k, r, h, u, cap, g, a, red, ph] = await Promise.all([
|
|
fetchKpis(SITE_ID),
|
|
fetchRackBreakdown(SITE_ID),
|
|
fetchRoomPowerHistory(SITE_ID, historyHours),
|
|
fetchUpsStatus(SITE_ID),
|
|
fetchCapacitySummary(SITE_ID),
|
|
fetchGeneratorStatus(SITE_ID).catch(() => []),
|
|
fetchAtsStatus(SITE_ID).catch(() => []),
|
|
fetchPowerRedundancy(SITE_ID).catch(() => null),
|
|
fetchPhaseBreakdown(SITE_ID).catch(() => []),
|
|
]);
|
|
setKpis(k);
|
|
setRacks(r);
|
|
setHistory(h);
|
|
setUps(u);
|
|
setCapacity(cap);
|
|
setGenerators(g);
|
|
setAtsUnits(a);
|
|
setRedundancy(red);
|
|
setPhases(ph);
|
|
} catch {
|
|
// keep stale data
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [historyHours]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
const id = setInterval(load, 30_000);
|
|
return () => clearInterval(id);
|
|
}, [load]);
|
|
|
|
useEffect(() => { loadHistory(); }, [historyHours, loadHistory]);
|
|
|
|
const totalKw = kpis?.total_power_kw ?? 0;
|
|
const hallAKw = racks.find((r) => r.room_id === "hall-a")?.racks.reduce((s, r) => s + (r.power_kw ?? 0), 0) ?? 0;
|
|
const hallBKw = racks.find((r) => r.room_id === "hall-b")?.racks.reduce((s, r) => s + (r.power_kw ?? 0), 0) ?? 0;
|
|
const siteCapacity = capacity ? capacity.rooms.reduce((s, r) => s + r.power.capacity_kw, 0) : 0;
|
|
|
|
// Phase summary data
|
|
const allPhaseRacks = phases.flatMap(r => r.racks);
|
|
const phaseViolations = allPhaseRacks.filter(r => (r.imbalance_pct ?? 0) > 5);
|
|
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
<div>
|
|
<h1 className="text-xl font-semibold">Power Management</h1>
|
|
<p className="text-sm text-muted-foreground">Singapore DC01 — refreshes every 30s</p>
|
|
</div>
|
|
|
|
{/* Internal anchor sub-nav */}
|
|
<div className="sticky top-14 z-20 -mx-6 px-6 py-2 bg-background/95 backdrop-blur-sm border-b border-border/30">
|
|
<nav className="flex gap-1">
|
|
{[
|
|
{ label: "Overview", href: "#power-overview" },
|
|
{ label: "UPS", href: "#power-ups" },
|
|
{ label: "Generator", href: "#power-generator" },
|
|
{ label: "Transfer Switch", href: "#power-ats" },
|
|
{ label: "Phase Analysis", href: "#power-phase" },
|
|
].map(({ label, href }) => (
|
|
<a key={href} href={href} className="px-3 py-1 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors">
|
|
{label}
|
|
</a>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Site capacity bar */}
|
|
<div id="power-overview">
|
|
{!loading && siteCapacity > 0 && (
|
|
<SiteCapacityBar usedKw={totalKw} capacityKw={siteCapacity} />
|
|
)}
|
|
</div>
|
|
|
|
{/* KPIs */}
|
|
{loading ? (
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
|
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20" />)}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
|
<KpiCard label="Total Site Load" value={`${totalKw} kW`} icon={Zap} accent />
|
|
<KpiCard label="PUE" value={kpis?.pue.toFixed(2) ?? "—"} icon={Activity} sub="Target: < 1.4" />
|
|
<KpiCard label="Hall A" value={`${hallAKw.toFixed(1)} kW`} icon={Zap} />
|
|
<KpiCard label="Hall B" value={`${hallBKw.toFixed(1)} kW`} icon={Zap} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Power path diagram */}
|
|
{!loading && redundancy && (
|
|
<div className="rounded-xl border border-border bg-muted/10 px-5 py-3">
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider font-semibold mb-3">Power Path</p>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{[
|
|
{ label: "Grid", icon: Zap, ok: redundancy.ats_active_feed !== "generator" },
|
|
{ label: "ATS", icon: ArrowLeftRight, ok: true },
|
|
{ label: "UPS", icon: Battery, ok: redundancy.ups_online > 0 },
|
|
{ label: "Racks", icon: Server, ok: true },
|
|
].map(({ label, icon: Icon, ok }, i, arr) => (
|
|
<React.Fragment key={label}>
|
|
<div className={cn(
|
|
"flex items-center gap-2 rounded-lg px-3 py-2 border text-xs font-medium",
|
|
ok ? "border-green-500/30 bg-green-500/5 text-green-400" : "border-amber-500/30 bg-amber-500/5 text-amber-400"
|
|
)}>
|
|
<Icon className="w-3.5 h-3.5" />
|
|
{label}
|
|
</div>
|
|
{i < arr.length - 1 && (
|
|
<div className="h-px flex-1 min-w-4 border-t border-dashed border-muted-foreground/30" />
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
{redundancy.ats_active_feed === "generator" && (
|
|
<span className="ml-auto text-[10px] text-amber-400 font-medium flex items-center gap-1">
|
|
<AlertTriangle className="w-3 h-3" /> Running on generator
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Charts */}
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Power History</p>
|
|
<select
|
|
value={historyHours}
|
|
onChange={(e) => setHistoryHours(Number(e.target.value))}
|
|
className="text-xs bg-muted border border-border rounded px-2 py-1 text-foreground"
|
|
>
|
|
{[1, 3, 6, 12, 24].map((h) => (
|
|
<option key={h} value={h}>{h}h</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="space-y-4">
|
|
{loading ? (
|
|
<>
|
|
<Skeleton className="h-64" />
|
|
<Skeleton className="h-64" />
|
|
</>
|
|
) : (
|
|
<>
|
|
{racks.length > 0 && <RackPowerChart rooms={racks} />}
|
|
<RoomPowerHistoryChart data={history} />
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Redundancy banner */}
|
|
{!loading && redundancy && <RedundancyBanner r={redundancy} />}
|
|
|
|
{/* UPS */}
|
|
<div id="power-ups">
|
|
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">UPS Units</h2>
|
|
{loading ? (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<Skeleton className="h-48" />
|
|
<Skeleton className="h-48" />
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
{ups.map((u) => (
|
|
<UpsCard key={u.ups_id} ups={u} onClick={() => setSelectedUps(u)} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Generator */}
|
|
{(loading || generators.length > 0) && (
|
|
<div id="power-generator">
|
|
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">Generators</h2>
|
|
{loading ? (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"><Skeleton className="h-48" /></div>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
{generators.map((g) => <GeneratorCard key={g.gen_id} gen={g} />)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ATS */}
|
|
{(loading || atsUnits.length > 0) && (
|
|
<div id="power-ats">
|
|
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">Transfer Switches</h2>
|
|
{loading ? (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"><Skeleton className="h-40" /></div>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
{atsUnits.map((a) => <AtsCard key={a.ats_id} ats={a} />)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* UPS detail sheet */}
|
|
<UpsDetailSheet ups={selectedUps} onClose={() => setSelectedUps(null)} />
|
|
|
|
{/* Phase analysis — always visible summary */}
|
|
<div id="power-phase">
|
|
{!loading && phases.length > 0 && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">Phase Analysis</h2>
|
|
{phaseViolations.length === 0 ? (
|
|
<span className="flex items-center gap-1.5 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-green-500/10 text-green-400">
|
|
<CheckCircle2 className="w-3 h-3" /> Phase balance OK
|
|
</span>
|
|
) : (
|
|
<button
|
|
onClick={() => setPhaseExpanded(!phaseExpanded)}
|
|
className="flex items-center gap-1.5 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 transition-colors"
|
|
>
|
|
<AlertTriangle className="w-3 h-3" />
|
|
{phaseViolations.length} rack{phaseViolations.length !== 1 ? "s" : ""} flagged
|
|
{phaseExpanded ? " — Hide details" : " — Show details"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Phase summary row */}
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{(["Phase A", "Phase B", "Phase C"] as const).map((phase, idx) => {
|
|
const phaseKey = (["phase_a_kw", "phase_b_kw", "phase_c_kw"] as const)[idx];
|
|
const total = allPhaseRacks.reduce((s, r) => s + (r[phaseKey] ?? 0), 0);
|
|
return (
|
|
<div key={phase} className="rounded-lg bg-muted/20 px-4 py-3 text-center">
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">{phase}</p>
|
|
<p className="text-lg font-bold tabular-nums">{total.toFixed(1)} kW</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Expanded violation table */}
|
|
{phaseExpanded && phaseViolations.length > 0 && (
|
|
<PhaseImbalanceTable rooms={phases} />
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|