412 lines
17 KiB
TypeScript
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 & 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>
|
|
);
|
|
}
|