first commit
This commit is contained in:
commit
4b98219bf7
144 changed files with 31561 additions and 0 deletions
858
frontend/app/(dashboard)/power/page.tsx
Normal file
858
frontend/app/(dashboard)/power/page.tsx
Normal file
|
|
@ -0,0 +1,858 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue