first commit
This commit is contained in:
commit
4b98219bf7
144 changed files with 31561 additions and 0 deletions
426
frontend/app/(dashboard)/reports/page.tsx
Normal file
426
frontend/app/(dashboard)/reports/page.tsx
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { fetchReportSummary, fetchKpis, fetchAlarmStats, fetchEnergyReport, reportExportUrl, type ReportSummary, type KpiData, type AlarmStats, type EnergyReport } from "@/lib/api";
|
||||
import { PageShell } from "@/components/layout/page-shell";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Zap, Thermometer, Bell, AlertTriangle, CheckCircle2, Clock,
|
||||
Download, Wind, Battery, RefreshCw, Activity, DollarSign,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
function UptimeBar({ pct }: { pct: number }) {
|
||||
const color = pct >= 99 ? "bg-green-500" : pct >= 95 ? "bg-amber-500" : "bg-destructive";
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div className={cn("h-full rounded-full transition-all", color)} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-sm font-bold tabular-nums w-16 text-right",
|
||||
pct >= 99 ? "text-green-400" : pct >= 95 ? "text-amber-400" : "text-destructive"
|
||||
)}>
|
||||
{pct.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExportCard({ hours, setHours }: { hours: number; setHours: (h: number) => void }) {
|
||||
const exports: { label: string; type: "power" | "temperature" | "alarms"; icon: React.ElementType }[] = [
|
||||
{ label: "Power History", type: "power", icon: Zap },
|
||||
{ label: "Temperature History", type: "temperature", icon: Thermometer },
|
||||
{ label: "Alarm Log", type: "alarms", icon: Bell },
|
||||
];
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Download className="w-4 h-4 text-primary" /> Export Data
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
{([24, 48, 168] as const).map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => setHours(h)}
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded text-xs font-medium transition-colors",
|
||||
hours === h ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{h === 168 ? "7d" : `${h}h`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{exports.map(({ label, type, icon: Icon }) => (
|
||||
<a
|
||||
key={type}
|
||||
href={reportExportUrl(type, SITE_ID, hours)}
|
||||
download
|
||||
className="flex items-center justify-between rounded-lg border border-border px-3 py-2.5 hover:bg-muted/40 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Icon className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
<span>{label}</span>
|
||||
{type !== "alarms" && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(last {hours === 168 ? "7 days" : `${hours}h`})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Download className="w-3.5 h-3.5 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
</a>
|
||||
))}
|
||||
<p className="text-[10px] text-muted-foreground pt-1">CSV format — 5-minute bucketed averages</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const RANGE_HOURS: Record<"24h" | "7d" | "30d", number> = { "24h": 24, "7d": 168, "30d": 720 };
|
||||
const RANGE_DAYS: Record<"24h" | "7d" | "30d", number> = { "24h": 1, "7d": 7, "30d": 30 };
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [summary, setSummary] = useState<ReportSummary | null>(null);
|
||||
const [kpis, setKpis] = useState<KpiData | null>(null);
|
||||
const [alarmStats, setAlarmStats] = useState<AlarmStats | null>(null);
|
||||
const [energy, setEnergy] = useState<EnergyReport | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [exportHours, setExportHours] = useState(720);
|
||||
const [dateRange, setDateRange] = useState<"24h" | "7d" | "30d">("30d");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [s, k, a, e] = await Promise.all([
|
||||
fetchReportSummary(SITE_ID),
|
||||
fetchKpis(SITE_ID),
|
||||
fetchAlarmStats(SITE_ID),
|
||||
fetchEnergyReport(SITE_ID, RANGE_DAYS[dateRange]).catch(() => null),
|
||||
]);
|
||||
setSummary(s);
|
||||
setKpis(k);
|
||||
setAlarmStats(a);
|
||||
setEnergy(e);
|
||||
} catch { toast.error("Failed to load report data"); }
|
||||
finally { setLoading(false); }
|
||||
}, [dateRange]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
function handleRangeChange(r: "24h" | "7d" | "30d") {
|
||||
setDateRange(r);
|
||||
setExportHours(RANGE_HOURS[r]);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell className="p-6">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Reports</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — site summary & data exports</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Date range picker */}
|
||||
<div className="flex items-center gap-0.5 rounded-lg border border-border p-0.5 text-xs">
|
||||
{(["24h", "7d", "30d"] as const).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => handleRangeChange(r)}
|
||||
className={cn(
|
||||
"px-2.5 py-1 rounded-md font-medium transition-colors",
|
||||
dateRange === r ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* Export always visible at top */}
|
||||
<ExportCard hours={exportHours} setHours={setExportHours} />
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-48" />)}
|
||||
</div>
|
||||
) : !summary ? (
|
||||
<div className="flex items-center justify-center h-48 text-sm text-muted-foreground">
|
||||
Unable to load report data.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Generated {new Date(summary.generated_at).toLocaleString()} · Showing last {dateRange}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* KPI snapshot — expanded */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-primary" /> Site KPI Snapshot
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" /> Total Power
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{summary.kpis.total_power_kw} <span className="text-sm font-normal text-muted-foreground">kW</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Activity className="w-3 h-3" /> PUE
|
||||
</p>
|
||||
<p className={cn(
|
||||
"text-2xl font-bold",
|
||||
kpis && kpis.pue > 1.5 ? "text-amber-400" : ""
|
||||
)}>
|
||||
{kpis?.pue.toFixed(2) ?? "—"}
|
||||
<span className="text-xs font-normal text-muted-foreground ml-1">target <1.4</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Thermometer className="w-3 h-3" /> Avg Temp
|
||||
</p>
|
||||
<p className={cn(
|
||||
"text-2xl font-bold",
|
||||
summary.kpis.avg_temperature >= 28 ? "text-destructive" :
|
||||
summary.kpis.avg_temperature >= 25 ? "text-amber-400" : ""
|
||||
)}>
|
||||
{summary.kpis.avg_temperature}°<span className="text-sm font-normal text-muted-foreground">C</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Bell className="w-3 h-3" /> Active Alarms
|
||||
</p>
|
||||
<p className={cn("text-2xl font-bold", (alarmStats?.active ?? 0) > 0 ? "text-destructive" : "")}>
|
||||
{alarmStats?.active ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3 text-destructive" /> Critical
|
||||
</p>
|
||||
<p className={cn("text-2xl font-bold", (alarmStats?.critical ?? 0) > 0 ? "text-destructive" : "")}>
|
||||
{alarmStats?.critical ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3 text-green-400" /> Resolved
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-green-400">
|
||||
{alarmStats?.resolved ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Alarm breakdown */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Bell className="w-4 h-4 text-primary" /> Alarm Breakdown
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Active", value: summary.alarm_stats.active, icon: AlertTriangle, color: "text-destructive" },
|
||||
{ label: "Acknowledged", value: summary.alarm_stats.acknowledged, icon: Clock, color: "text-amber-400" },
|
||||
{ label: "Resolved", value: summary.alarm_stats.resolved, icon: CheckCircle2, color: "text-green-400" },
|
||||
].map(({ label, value, icon: Icon, color }) => (
|
||||
<div key={label} className="text-center space-y-1 rounded-lg bg-muted/30 p-3">
|
||||
<Icon className={cn("w-4 h-4 mx-auto", color)} />
|
||||
<p className={cn("text-xl font-bold", value > 0 && label === "Active" ? "text-destructive" : "")}>{value}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-4 pt-3 border-t border-border text-xs">
|
||||
<span className="text-muted-foreground">By severity:</span>
|
||||
<span className="flex items-center gap-1 text-destructive font-medium">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-destructive" />
|
||||
{summary.alarm_stats.critical} critical
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-amber-400 font-medium">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
|
||||
{summary.alarm_stats.warning} warning
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* CRAC uptime */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Wind className="w-4 h-4 text-primary" /> CRAC Uptime (last 24h)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{summary.crac_uptime.map((crac) => (
|
||||
<div key={crac.crac_id} className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="font-medium">{crac.crac_id.toUpperCase()}</span>
|
||||
<span className="text-muted-foreground">{crac.room_id}</span>
|
||||
</div>
|
||||
<UptimeBar pct={crac.uptime_pct} />
|
||||
</div>
|
||||
))}
|
||||
{summary.crac_uptime.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No CRAC data available</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* UPS uptime */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Battery className="w-4 h-4 text-primary" /> UPS Uptime (last 24h)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{summary.ups_uptime.map((ups) => (
|
||||
<div key={ups.ups_id} className="space-y-1.5">
|
||||
<div className="text-xs font-medium">{ups.ups_id.toUpperCase()}</div>
|
||||
<UptimeBar pct={ups.uptime_pct} />
|
||||
</div>
|
||||
))}
|
||||
{summary.ups_uptime.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No UPS data available</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Energy cost section */}
|
||||
{energy && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-primary" /> Energy Cost — Last {energy.period_days} Days
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Total kWh</p>
|
||||
<p className="text-2xl font-bold">{energy.kwh_total.toFixed(0)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{energy.from_date} → {energy.to_date}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Cost ({energy.currency})</p>
|
||||
<p className="text-2xl font-bold">${energy.cost_sgd.toLocaleString()}</p>
|
||||
<p className="text-[10px] text-muted-foreground">@ ${energy.tariff_sgd_kwh}/kWh</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Est. Annual kWh</p>
|
||||
<p className="text-2xl font-bold">{(energy.pue_trend.length > 0 ? energy.kwh_total / energy.period_days * 365 : 0).toFixed(0)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">at current pace</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">PUE (estimated)</p>
|
||||
<p className={cn("text-2xl font-bold", energy.pue_estimated > 1.5 ? "text-amber-400" : "")}>{energy.pue_estimated.toFixed(2)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">target < 1.4</p>
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const trend = energy.pue_trend ?? [];
|
||||
const thisWeek = trend.slice(-7);
|
||||
const lastWeek = trend.slice(-14, -7);
|
||||
const thisKwh = thisWeek.reduce((s, d) => s + d.avg_it_kw * 24, 0);
|
||||
const lastKwh = lastWeek.reduce((s, d) => s + d.avg_it_kw * 24, 0);
|
||||
const kwhDelta = lastKwh > 0 ? ((thisKwh - lastKwh) / lastKwh * 100) : 0;
|
||||
const thisAvgKw = thisWeek.length > 0 ? thisWeek.reduce((s, d) => s + d.avg_it_kw, 0) / thisWeek.length : 0;
|
||||
const lastAvgKw = lastWeek.length > 0 ? lastWeek.reduce((s, d) => s + d.avg_it_kw, 0) / lastWeek.length : 0;
|
||||
const kwDelta = lastAvgKw > 0 ? ((thisAvgKw - lastAvgKw) / lastAvgKw * 100) : 0;
|
||||
if (thisWeek.length === 0 || lastWeek.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-muted/10 px-4 py-3 flex items-center gap-6 text-xs flex-wrap mb-6">
|
||||
<span className="text-muted-foreground font-medium">This week vs last week:</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">kWh:</span>
|
||||
<span className="font-bold">{thisKwh.toFixed(0)}</span>
|
||||
<span className={cn(
|
||||
"font-semibold",
|
||||
kwhDelta > 5 ? "text-destructive" : kwhDelta < -5 ? "text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
({kwhDelta > 0 ? "+" : ""}{kwhDelta.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">Avg IT load:</span>
|
||||
<span className="font-bold">{thisAvgKw.toFixed(1)} kW</span>
|
||||
<span className={cn(
|
||||
"font-semibold",
|
||||
kwDelta > 5 ? "text-amber-400" : kwDelta < -5 ? "text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
({kwDelta > 0 ? "+" : ""}{kwDelta.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-[10px] ml-auto">based on last {thisWeek.length + lastWeek.length} days of data</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{energy.pue_trend.length > 0 && (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground mb-2 uppercase font-medium tracking-wider">Daily IT Load (kW)</p>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<AreaChart data={energy.pue_trend} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="energy-grad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="oklch(0.62 0.17 212)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="oklch(0.62 0.17 212)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
|
||||
<XAxis dataKey="day" tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false}
|
||||
tickFormatter={(v) => new Date(v).toLocaleDateString([], { month: "short", day: "numeric" })} />
|
||||
<YAxis tick={{ fontSize: 9, 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: "11px" }}
|
||||
formatter={(v) => [`${Number(v).toFixed(1)} kW`, "Avg IT Load"]}
|
||||
labelFormatter={(l) => new Date(l).toLocaleDateString()}
|
||||
/>
|
||||
<Area type="monotone" dataKey="avg_it_kw" stroke="oklch(0.62 0.17 212)" fill="url(#energy-grad)" strokeWidth={2} dot={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
</>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue