first commit

This commit is contained in:
mega 2026-03-19 11:32:17 +00:00
commit 4b98219bf7
144 changed files with 31561 additions and 0 deletions

View 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>
);
}