BMS/frontend/app/(dashboard)/generator/page.tsx
2026-03-19 11:32:17 +00:00

412 lines
17 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback } from "react";
import { toast } from "sonner";
import {
fetchGeneratorStatus, fetchAtsStatus, fetchPhaseBreakdown,
type GeneratorStatus, type AtsStatus, type RoomPhase,
} from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
Fuel, Zap, Activity, RefreshCw, CheckCircle2, AlertTriangle,
ArrowLeftRight, Gauge, Thermometer, Battery,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { GeneratorDetailSheet } from "@/components/dashboard/generator-detail-sheet";
const SITE_ID = "sg-01";
const STATE_COLOR: Record<string, string> = {
running: "bg-green-500/10 text-green-400",
standby: "bg-blue-500/10 text-blue-400",
test: "bg-amber-500/10 text-amber-400",
fault: "bg-destructive/10 text-destructive",
unknown: "bg-muted/30 text-muted-foreground",
};
const ATS_FEED_COLOR: Record<string, string> = {
"utility-a": "bg-blue-500/10 text-blue-400",
"utility-b": "bg-sky-500/10 text-sky-400",
"generator": "bg-amber-500/10 text-amber-400",
};
function FillBar({
value, max, color = "#22c55e", warn, crit,
}: {
value: number | null; max: number; color?: string; warn?: number; crit?: number;
}) {
const pct = value != null ? Math.min(100, (value / max) * 100) : 0;
const bg = crit && value != null && value >= crit ? "#ef4444"
: warn && value != null && value >= warn ? "#f59e0b"
: color;
return (
<div className="rounded-full bg-muted overflow-hidden h-2 w-full">
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${pct}%`, backgroundColor: bg }} />
</div>
);
}
function StatRow({ label, value, warn }: { label: string; value: string; warn?: boolean }) {
return (
<div className="flex justify-between items-baseline text-xs">
<span className="text-muted-foreground">{label}</span>
<span className={cn("font-mono font-medium", warn && "text-amber-400")}>{value}</span>
</div>
);
}
function GeneratorCard({ gen, onClick }: { gen: GeneratorStatus; onClick: () => void }) {
const fuelLow = (gen.fuel_pct ?? 100) < 25;
const fuelCrit = (gen.fuel_pct ?? 100) < 10;
const isFault = gen.state === "fault";
const isRun = gen.state === "running" || gen.state === "test";
return (
<Card
className={cn("border cursor-pointer hover:border-primary/40 transition-colors", isFault && "border-destructive/50")}
onClick={onClick}
>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Activity className={cn("w-4 h-4", isRun ? "text-green-400" : "text-muted-foreground")} />
{gen.gen_id.toUpperCase()}
</CardTitle>
<div className="flex items-center gap-2">
<span className={cn(
"text-[10px] font-semibold px-2.5 py-0.5 rounded-full uppercase tracking-wide",
STATE_COLOR[gen.state] ?? STATE_COLOR.unknown,
)}>
{gen.state}
</span>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Fuel level */}
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="flex items-center gap-1.5 text-[10px] text-muted-foreground uppercase tracking-wide">
<Fuel className="w-3 h-3" /> Fuel Level
</span>
<span className={cn(
"text-sm font-bold tabular-nums",
fuelCrit ? "text-destructive" : fuelLow ? "text-amber-400" : "text-green-400",
)}>
{gen.fuel_pct != null ? `${gen.fuel_pct.toFixed(1)}%` : "—"}
</span>
</div>
<FillBar
value={gen.fuel_pct}
max={100}
color="#22c55e"
warn={25}
crit={10}
/>
{gen.fuel_litres != null && (
<p className="text-[10px] text-muted-foreground text-right mt-1">
{gen.fuel_litres.toFixed(0)} L remaining
</p>
)}
{gen.fuel_litres != null && gen.load_kw != null && gen.load_kw > 0 && (() => {
const runtimeH = gen.fuel_litres / (gen.load_kw * 0.27);
const hours = Math.floor(runtimeH);
const mins = Math.round((runtimeH - hours) * 60);
const cls = runtimeH < 4 ? "text-destructive" : runtimeH < 12 ? "text-amber-400" : "text-green-400";
return (
<p className={cn("text-[10px] text-right mt-0.5", cls)}>
Est. runtime: <strong>{hours}h {mins}m</strong>
</p>
);
})()}
</div>
{/* Load */}
{gen.load_kw != null && (
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="flex items-center gap-1.5 text-[10px] text-muted-foreground uppercase tracking-wide">
<Zap className="w-3 h-3" /> Load
</span>
<span className="text-sm font-bold tabular-nums text-foreground">
{gen.load_kw.toFixed(1)} kW
{gen.load_pct != null && (
<span className="text-muted-foreground font-normal ml-1">({gen.load_pct.toFixed(0)}%)</span>
)}
</span>
</div>
<FillBar value={gen.load_pct} max={100} color="#60a5fa" warn={75} crit={90} />
</div>
)}
{/* Engine stats */}
<div className="rounded-lg bg-muted/20 px-3 py-3 space-y-2">
<p className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold mb-1">Engine</p>
{gen.voltage_v != null && <StatRow label="Output voltage" value={`${gen.voltage_v.toFixed(0)} V`} />}
{gen.frequency_hz != null && <StatRow label="Frequency" value={`${gen.frequency_hz.toFixed(1)} Hz`} warn={Math.abs(gen.frequency_hz - 50) > 0.5} />}
{gen.run_hours != null && <StatRow label="Run hours" value={`${gen.run_hours.toFixed(0)} h`} />}
{gen.oil_pressure_bar != null && <StatRow label="Oil pressure" value={`${gen.oil_pressure_bar.toFixed(1)} bar`} warn={gen.oil_pressure_bar < 2.0} />}
{gen.coolant_temp_c != null && (
<div className="flex justify-between items-baseline text-xs">
<span className="text-muted-foreground flex items-center gap-1">
<Thermometer className="w-3 h-3 inline" /> Coolant temp
</span>
<span className={cn("font-mono font-medium", gen.coolant_temp_c > 95 ? "text-destructive" : gen.coolant_temp_c > 85 ? "text-amber-400" : "")}>
{gen.coolant_temp_c.toFixed(1)}°C
</span>
</div>
)}
{gen.battery_v != null && (
<div className="flex justify-between items-baseline text-xs">
<span className="text-muted-foreground flex items-center gap-1">
<Battery className="w-3 h-3 inline" /> Battery
</span>
<span className={cn("font-mono font-medium", gen.battery_v < 11.5 ? "text-destructive" : gen.battery_v < 12.0 ? "text-amber-400" : "text-green-400")}>
{gen.battery_v.toFixed(1)} V
</span>
</div>
)}
</div>
</CardContent>
</Card>
);
}
function AtsCard({ ats }: { ats: AtsStatus }) {
const feedColor = ATS_FEED_COLOR[ats.active_feed] ?? "bg-muted/30 text-muted-foreground";
const isGen = ats.active_feed === "generator";
return (
<Card className={cn("border", isGen && "border-amber-500/40")}>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<ArrowLeftRight className="w-4 h-4 text-primary" />
{ats.ats_id.toUpperCase()} ATS Transfer Switch
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">Active feed</span>
<span className={cn("text-xs font-bold px-2.5 py-1 rounded-full uppercase tracking-wide", feedColor)}>
{ats.active_feed}
</span>
{isGen && <span className="text-[10px] text-amber-400">Running on generator power</span>}
</div>
<div className="grid grid-cols-3 gap-3 text-xs">
{[
{ label: "Utility A", v: ats.utility_a_v },
{ label: "Utility B", v: ats.utility_b_v },
{ label: "Generator", v: ats.generator_v },
].map(({ label, v }) => (
<div key={label} className="rounded-lg bg-muted/20 px-2 py-2 text-center">
<p className="text-[10px] text-muted-foreground mb-0.5">{label}</p>
<p className="font-bold text-foreground tabular-nums">{v != null ? `${v.toFixed(0)} V` : "—"}</p>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
{ats.transfer_count != null && (
<div className="rounded-lg bg-muted/20 px-3 py-2">
<p className="text-[10px] text-muted-foreground mb-0.5">Transfers (total)</p>
<p className="font-bold">{ats.transfer_count}</p>
</div>
)}
{ats.last_transfer_ms != null && (
<div className="rounded-lg bg-muted/20 px-3 py-2">
<p className="text-[10px] text-muted-foreground mb-0.5">Last transfer time</p>
<p className="font-bold">{ats.last_transfer_ms} ms</p>
</div>
)}
</div>
</CardContent>
</Card>
);
}
function PhaseImbalancePanel({ rooms }: { rooms: RoomPhase[] }) {
const allRacks = rooms.flatMap((r) => r.racks);
const flagged = allRacks
.filter((r) => (r.imbalance_pct ?? 0) >= 5)
.sort((a, b) => (b.imbalance_pct ?? 0) - (a.imbalance_pct ?? 0));
if (flagged.length === 0) return (
<div className="rounded-lg border border-border bg-muted/10 px-4 py-3 flex items-center gap-2 text-sm">
<CheckCircle2 className="w-4 h-4 text-green-400" />
<span className="text-green-400">No PDU phase imbalance detected across all racks</span>
</div>
);
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" />
PDU Phase Imbalance
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{flagged.map((rack) => {
const crit = (rack.imbalance_pct ?? 0) >= 15;
return (
<div key={rack.rack_id} className={cn(
"rounded-lg px-3 py-2 grid grid-cols-5 gap-2 text-xs items-center",
crit ? "bg-destructive/10" : "bg-amber-500/10",
)}>
<span className="font-medium col-span-1">{rack.rack_id.toUpperCase()}</span>
<span className={cn("text-center", crit ? "text-destructive" : "text-amber-400")}>
{rack.imbalance_pct?.toFixed(1)}% imbalance
</span>
<span className="text-muted-foreground text-center">A: {rack.phase_a_kw?.toFixed(2) ?? "—"} kW</span>
<span className="text-muted-foreground text-center">B: {rack.phase_b_kw?.toFixed(2) ?? "—"} kW</span>
<span className="text-muted-foreground text-center">C: {rack.phase_c_kw?.toFixed(2) ?? "—"} kW</span>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
export default function GeneratorPage() {
const [generators, setGenerators] = useState<GeneratorStatus[]>([]);
const [atsUnits, setAtsUnits] = useState<AtsStatus[]>([]);
const [phases, setPhases] = useState<RoomPhase[]>([]);
const [loading, setLoading] = useState(true);
const [selectedGen, setSelectedGen] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const [g, a, p] = await Promise.all([
fetchGeneratorStatus(SITE_ID),
fetchAtsStatus(SITE_ID).catch(() => [] as AtsStatus[]),
fetchPhaseBreakdown(SITE_ID).catch(() => [] as RoomPhase[]),
]);
setGenerators(g);
setAtsUnits(a);
setPhases(p);
} catch { toast.error("Failed to load generator data"); }
finally { setLoading(false); }
}, []);
useEffect(() => {
load();
const id = setInterval(load, 15_000);
return () => clearInterval(id);
}, [load]);
const anyFault = generators.some((g) => g.state === "fault");
const anyRun = generators.some((g) => g.state === "running" || g.state === "test");
const onGen = atsUnits.some((a) => a.active_feed === "generator");
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-xl font-semibold">Generator &amp; Power Path</h1>
<p className="text-sm text-muted-foreground">Singapore DC01 backup power systems · refreshes every 15s</p>
</div>
<div className="flex items-center gap-3">
{!loading && (
<span className={cn(
"flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-full",
anyFault ? "bg-destructive/10 text-destructive" :
onGen ? "bg-amber-500/10 text-amber-400" :
anyRun ? "bg-green-500/10 text-green-400" :
"bg-blue-500/10 text-blue-400",
)}>
{anyFault ? <><AlertTriangle className="w-3.5 h-3.5" /> Generator fault</> :
onGen ? <><AlertTriangle className="w-3.5 h-3.5" /> Running on generator</> :
anyRun ? <><CheckCircle2 className="w-3.5 h-3.5" /> Generator running (test)</> :
<><CheckCircle2 className="w-3.5 h-3.5" /> Utility power all standby</>}
</span>
)}
<button
onClick={load}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" /> Refresh
</button>
</div>
</div>
{/* Site power status bar */}
{!loading && atsUnits.length > 0 && (
<div className={cn(
"rounded-xl border px-5 py-3 flex items-center gap-6 text-sm flex-wrap",
onGen ? "border-amber-500/30 bg-amber-500/5" : "border-border bg-muted/10",
)}>
<Gauge className={cn("w-5 h-5 shrink-0", onGen ? "text-amber-400" : "text-primary")} />
<div>
<span className="text-muted-foreground">Power path: </span>
<strong className="text-foreground capitalize">{onGen ? "Generator (utility lost)" : "Utility mains"}</strong>
</div>
<div>
<span className="text-muted-foreground">Generators: </span>
<strong>{generators.length} total</strong>
<span className="text-muted-foreground ml-1">
({generators.filter((g) => g.state === "standby").length} standby,{" "}
{generators.filter((g) => g.state === "running").length} running,{" "}
{generators.filter((g) => g.state === "fault").length} fault)
</span>
</div>
</div>
)}
{/* Generators */}
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Diesel Generators
</h2>
{loading ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Skeleton className="h-80" />
<Skeleton className="h-80" />
</div>
) : generators.length === 0 ? (
<div className="text-sm text-muted-foreground">No generator data available</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{generators.map((g) => (
<GeneratorCard key={g.gen_id} gen={g} onClick={() => setSelectedGen(g.gen_id)} />
))}
</div>
)}
{/* ATS */}
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Automatic Transfer Switches
</h2>
{loading ? (
<Skeleton className="h-40" />
) : atsUnits.length === 0 ? (
<div className="text-sm text-muted-foreground">No ATS data available</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{atsUnits.map((a) => <AtsCard key={a.ats_id} ats={a} />)}
</div>
)}
<GeneratorDetailSheet
siteId={SITE_ID}
genId={selectedGen}
onClose={() => setSelectedGen(null)}
/>
{/* Phase imbalance */}
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
PDU Phase Balance
</h2>
{loading ? (
<Skeleton className="h-32" />
) : (
<PhaseImbalancePanel rooms={phases} />
)}
</div>
);
}