330 lines
14 KiB
TypeScript
330 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useCallback } from "react";
|
||
import { toast } from "sonner";
|
||
import {
|
||
fetchEnergyReport, fetchUtilityPower,
|
||
type EnergyReport, type UtilityPower,
|
||
} from "@/lib/api";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import {
|
||
AreaChart, Area, LineChart, Line,
|
||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
|
||
} from "recharts";
|
||
import { Zap, Leaf, RefreshCw, TrendingDown, DollarSign, Activity } from "lucide-react";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
const SITE_ID = "sg-01";
|
||
|
||
// Singapore grid emission factor (kgCO2e/kWh) — Energy Market Authority 2023
|
||
const GRID_EF_KG_CO2_KWH = 0.4168;
|
||
// Approximate WUE for air-cooled DC in Singapore climate
|
||
const WUE_EST = 1.4;
|
||
|
||
function KpiTile({
|
||
label, value, sub, icon: Icon, iconClass, warn,
|
||
}: {
|
||
label: string; value: string; sub?: string;
|
||
icon?: React.ElementType; iconClass?: string; warn?: boolean;
|
||
}) {
|
||
return (
|
||
<div className="rounded-xl border border-border bg-muted/10 px-4 py-4 space-y-1">
|
||
<div className="flex items-center gap-2">
|
||
{Icon && <Icon className={cn("w-4 h-4 shrink-0", iconClass ?? "text-primary")} />}
|
||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">{label}</p>
|
||
</div>
|
||
<p className={cn("text-2xl font-bold tabular-nums leading-none", warn && "text-amber-400")}>{value}</p>
|
||
{sub && <p className="text-[10px] text-muted-foreground">{sub}</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{children}</h2>
|
||
);
|
||
}
|
||
|
||
export default function EnergyPage() {
|
||
const [energy, setEnergy] = useState<EnergyReport | null>(null);
|
||
const [utility, setUtility] = useState<UtilityPower | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
const load = useCallback(async () => {
|
||
try {
|
||
const [e, u] = await Promise.all([
|
||
fetchEnergyReport(SITE_ID, 30),
|
||
fetchUtilityPower(SITE_ID).catch(() => null),
|
||
]);
|
||
setEnergy(e);
|
||
setUtility(u);
|
||
} catch { toast.error("Failed to load energy data"); }
|
||
finally { setLoading(false); }
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
load();
|
||
const id = setInterval(load, 60_000);
|
||
return () => clearInterval(id);
|
||
}, [load]);
|
||
|
||
const co2e_kg = energy ? Math.round(energy.kwh_total * GRID_EF_KG_CO2_KWH) : null;
|
||
const co2e_t = co2e_kg ? (co2e_kg / 1000).toFixed(2) : null;
|
||
const wue_water = energy ? (energy.kwh_total * (WUE_EST - 1)).toFixed(0) : null;
|
||
|
||
const itKwChart = (energy?.pue_trend ?? []).map((d) => ({
|
||
day: new Date(d.day).toLocaleDateString("en-GB", { month: "short", day: "numeric" }),
|
||
kw: d.avg_it_kw,
|
||
pue: d.pue_est,
|
||
}));
|
||
|
||
const avgPue30 = energy?.pue_estimated ?? null;
|
||
const pueWarn = avgPue30 != null && avgPue30 > 1.5;
|
||
|
||
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">Energy & Sustainability</h1>
|
||
<p className="text-sm text-muted-foreground">Singapore DC01 — 30-day energy analysis · refreshes every 60s</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
{!loading && (
|
||
<div className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full bg-green-500/10 text-green-400 font-semibold">
|
||
<Leaf className="w-3.5 h-3.5" /> {co2e_t ? `${co2e_t} tCO₂e this month` : "—"}
|
||
</div>
|
||
)}
|
||
<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 energy banner */}
|
||
{!loading && utility && (
|
||
<div className="rounded-xl border border-border bg-muted/10 px-5 py-3 flex items-center gap-8 text-sm flex-wrap">
|
||
<div>
|
||
<span className="text-muted-foreground">Current IT load: </span>
|
||
<strong>{utility.total_kw.toFixed(1)} kW</strong>
|
||
</div>
|
||
<div>
|
||
<span className="text-muted-foreground">Tariff: </span>
|
||
<strong>SGD {utility.tariff_sgd_kwh.toFixed(3)}/kWh</strong>
|
||
</div>
|
||
<div>
|
||
<span className="text-muted-foreground">Month-to-date: </span>
|
||
<strong>{utility.kwh_month_to_date.toFixed(0)} kWh</strong>
|
||
<span className="text-muted-foreground ml-1">
|
||
(SGD {utility.cost_sgd_mtd.toFixed(0)})
|
||
</span>
|
||
</div>
|
||
<div className="ml-auto text-xs text-muted-foreground">
|
||
Singapore · SP Group grid
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 30-day KPIs */}
|
||
<SectionHeader>30-Day Energy Summary</SectionHeader>
|
||
{loading ? (
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24" />)}
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||
<KpiTile
|
||
label="Total Consumption"
|
||
value={energy ? `${energy.kwh_total.toFixed(0)} kWh` : "—"}
|
||
sub="last 30 days"
|
||
icon={Zap}
|
||
iconClass="text-amber-400"
|
||
/>
|
||
<KpiTile
|
||
label="Energy Cost"
|
||
value={energy ? `SGD ${energy.cost_sgd.toFixed(0)}` : "—"}
|
||
sub={`@ SGD ${energy?.tariff_sgd_kwh.toFixed(3) ?? "—"}/kWh`}
|
||
icon={DollarSign}
|
||
iconClass="text-green-400"
|
||
/>
|
||
<KpiTile
|
||
label="Avg PUE"
|
||
value={avgPue30 != null ? avgPue30.toFixed(3) : "—"}
|
||
sub={avgPue30 != null && avgPue30 < 1.4 ? "Excellent" : avgPue30 != null && avgPue30 < 1.6 ? "Good" : "Room to improve"}
|
||
icon={Activity}
|
||
iconClass={pueWarn ? "text-amber-400" : "text-primary"}
|
||
warn={pueWarn}
|
||
/>
|
||
<KpiTile
|
||
label="Annual Estimate"
|
||
value={utility ? `SGD ${(utility.cost_sgd_annual_est).toFixed(0)}` : "—"}
|
||
sub={utility ? `${utility.kwh_annual_est.toFixed(0)} kWh/yr` : undefined}
|
||
icon={TrendingDown}
|
||
iconClass="text-muted-foreground"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* IT Load trend */}
|
||
{(loading || itKwChart.length > 0) && (
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||
<Zap className="w-4 h-4 text-amber-400" />
|
||
Daily IT Load — 30 Days
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{loading ? (
|
||
<Skeleton className="h-48" />
|
||
) : (
|
||
<ResponsiveContainer width="100%" height={200}>
|
||
<AreaChart data={itKwChart} margin={{ top: 4, right: 16, left: -10, bottom: 0 }}>
|
||
<defs>
|
||
<linearGradient id="itKwGrad" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="5%" stopColor="oklch(0.78 0.17 84)" stopOpacity={0.3} />
|
||
<stop offset="95%" stopColor="oklch(0.78 0.17 84)" stopOpacity={0} />
|
||
</linearGradient>
|
||
</defs>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" vertical={false} />
|
||
<XAxis
|
||
dataKey="day"
|
||
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
|
||
tickLine={false} axisLine={false}
|
||
interval={4}
|
||
/>
|
||
<YAxis
|
||
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
|
||
tickLine={false} axisLine={false}
|
||
tickFormatter={(v) => `${v} kW`}
|
||
domain={["auto", "auto"]}
|
||
/>
|
||
<Tooltip
|
||
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0/9%)", borderRadius: "6px", fontSize: "12px" }}
|
||
formatter={(v) => [`${Number(v).toFixed(1)} kW`, "IT Load"]}
|
||
/>
|
||
<Area type="monotone" dataKey="kw" stroke="oklch(0.78 0.17 84)" fill="url(#itKwGrad)" strokeWidth={2} dot={false} />
|
||
</AreaChart>
|
||
</ResponsiveContainer>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* PUE trend */}
|
||
{(loading || itKwChart.length > 0) && (
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||
<Activity className="w-4 h-4 text-primary" />
|
||
PUE Trend — 30 Days
|
||
<span className="text-[10px] font-normal text-muted-foreground ml-1">(target: < 1.4)</span>
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{loading ? (
|
||
<Skeleton className="h-48" />
|
||
) : (
|
||
<ResponsiveContainer width="100%" height={200}>
|
||
<LineChart data={itKwChart} margin={{ top: 4, right: 16, left: -10, bottom: 0 }}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" vertical={false} />
|
||
<XAxis
|
||
dataKey="day"
|
||
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
|
||
tickLine={false} axisLine={false}
|
||
interval={4}
|
||
/>
|
||
<YAxis
|
||
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
|
||
tickLine={false} axisLine={false}
|
||
domain={[1.0, "auto"]}
|
||
/>
|
||
<Tooltip
|
||
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0/9%)", borderRadius: "6px", fontSize: "12px" }}
|
||
formatter={(v) => [Number(v).toFixed(3), "PUE"]}
|
||
/>
|
||
<ReferenceLine y={1.4} stroke="oklch(0.68 0.14 162)" strokeDasharray="4 4" strokeWidth={1}
|
||
label={{ value: "Target 1.4", fontSize: 9, fill: "oklch(0.68 0.14 162)", position: "insideTopRight" }} />
|
||
<ReferenceLine y={1.6} stroke="oklch(0.65 0.20 45)" strokeDasharray="4 4" strokeWidth={1}
|
||
label={{ value: "Warn 1.6", fontSize: 9, fill: "oklch(0.65 0.20 45)", position: "insideTopRight" }} />
|
||
<Line type="monotone" dataKey="pue" stroke="oklch(0.62 0.17 212)" strokeWidth={2} dot={false} activeDot={{ r: 3 }} />
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Sustainability */}
|
||
<SectionHeader>Sustainability Metrics</SectionHeader>
|
||
{loading ? (
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-24" />)}
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||
<div className="rounded-xl border border-green-500/20 bg-green-500/5 px-4 py-4 space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<Leaf className="w-4 h-4 text-green-400" />
|
||
<p className="text-xs font-semibold text-green-400 uppercase tracking-wider">Carbon Footprint</p>
|
||
</div>
|
||
<p className="text-2xl font-bold tabular-nums">{co2e_t ?? "—"} tCO₂e</p>
|
||
<p className="text-[10px] text-muted-foreground">
|
||
30-day estimate · {energy?.kwh_total.toFixed(0) ?? "—"} kWh × {GRID_EF_KG_CO2_KWH} kgCO₂e/kWh
|
||
</p>
|
||
<p className="text-[10px] text-muted-foreground">
|
||
Singapore grid emission factor (EMA 2023)
|
||
</p>
|
||
</div>
|
||
|
||
<div className="rounded-xl border border-blue-500/20 bg-blue-500/5 px-4 py-4 space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<Activity className="w-4 h-4 text-blue-400" />
|
||
<p className="text-xs font-semibold text-blue-400 uppercase tracking-wider">Water Usage (WUE)</p>
|
||
</div>
|
||
<p className="text-2xl font-bold tabular-nums">{WUE_EST.toFixed(1)}</p>
|
||
<p className="text-[10px] text-muted-foreground">
|
||
Estimated WUE (L/kWh) · air-cooled DC
|
||
</p>
|
||
<p className="text-[10px] text-muted-foreground">
|
||
Est. {wue_water ? `${Number(wue_water).toLocaleString()} L` : "—"} consumed (30d)
|
||
</p>
|
||
</div>
|
||
|
||
<div className="rounded-xl border border-primary/20 bg-primary/5 px-4 py-4 space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<TrendingDown className="w-4 h-4 text-primary" />
|
||
<p className="text-xs font-semibold text-primary uppercase tracking-wider">Efficiency</p>
|
||
</div>
|
||
<p className={cn("text-2xl font-bold tabular-nums", pueWarn ? "text-amber-400" : "text-green-400")}>
|
||
{avgPue30?.toFixed(3) ?? "—"}
|
||
</p>
|
||
<p className="text-[10px] text-muted-foreground">
|
||
Avg PUE · {avgPue30 != null && avgPue30 < 1.4 ? "Excellent — Tier IV class" :
|
||
avgPue30 != null && avgPue30 < 1.6 ? "Good — industry average" :
|
||
"Above average — optimise cooling"}
|
||
</p>
|
||
<p className="text-[10px] text-muted-foreground">
|
||
IT energy efficiency: {avgPue30 != null ? `${(1 / avgPue30 * 100).toFixed(1)}%` : "—"} of total power to IT
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Reference info */}
|
||
<div className="rounded-xl border border-border bg-muted/5 px-5 py-4 text-xs text-muted-foreground space-y-1.5">
|
||
<p className="font-semibold text-foreground/80">Singapore Energy Context</p>
|
||
<p>Grid emission factor: {GRID_EF_KG_CO2_KWH} kgCO₂e/kWh (EMA 2023, predominantly natural gas + growing solar)</p>
|
||
<p>Electricity tariff: SGD {utility?.tariff_sgd_kwh.toFixed(3) ?? "0.298"}/kWh (SP Group commercial rate)</p>
|
||
<p>BCA Green Mark: Targeting GoldPLUS certification · PUE target < 1.4</p>
|
||
<p className="text-muted-foreground/50 text-[10px] pt-1">
|
||
CO₂e and WUE estimates are indicative. Actual values depend on metered chilled water and cooling tower data.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|