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