246 lines
9.7 KiB
TypeScript
246 lines
9.7 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useCallback } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { Zap, Thermometer, Wind, AlertTriangle, Wifi, WifiOff, Fuel, Droplets } from "lucide-react";
|
||
import { KpiCard } from "@/components/dashboard/kpi-card";
|
||
import { PowerTrendChart } from "@/components/dashboard/power-trend-chart";
|
||
import { TemperatureTrendChart } from "@/components/dashboard/temperature-trend-chart";
|
||
import { AlarmFeed } from "@/components/dashboard/alarm-feed";
|
||
import { MiniFloorMap } from "@/components/dashboard/mini-floor-map";
|
||
import { RackDetailSheet } from "@/components/dashboard/rack-detail-sheet";
|
||
import {
|
||
fetchKpis, fetchPowerHistory, fetchTempHistory,
|
||
fetchAlarms, fetchGeneratorStatus, fetchLeakStatus,
|
||
fetchCapacitySummary, fetchFloorLayout,
|
||
type KpiData, type PowerBucket, type TempBucket,
|
||
type Alarm, type GeneratorStatus, type LeakSensorStatus,
|
||
type RackCapacity,
|
||
} from "@/lib/api";
|
||
import { TimeRangePicker } from "@/components/ui/time-range-picker";
|
||
import Link from "next/link";
|
||
import { PageShell } from "@/components/layout/page-shell";
|
||
|
||
const SITE_ID = "sg-01";
|
||
const KPI_INTERVAL = 15_000;
|
||
const CHART_INTERVAL = 30_000;
|
||
|
||
// Fallback static data shown when the API is unreachable
|
||
const FALLBACK_KPIS: KpiData = {
|
||
total_power_kw: 0, pue: 0, avg_temperature: 0, active_alarms: 0,
|
||
};
|
||
|
||
export default function DashboardPage() {
|
||
const router = useRouter();
|
||
const [kpis, setKpis] = useState<KpiData>(FALLBACK_KPIS);
|
||
const [prevKpis, setPrevKpis] = useState<KpiData | null>(null);
|
||
const [powerHistory, setPowerHistory] = useState<PowerBucket[]>([]);
|
||
const [tempHistory, setTempHistory] = useState<TempBucket[]>([]);
|
||
const [alarms, setAlarms] = useState<Alarm[]>([]);
|
||
const [generators, setGenerators] = useState<GeneratorStatus[]>([]);
|
||
const [leakSensors, setLeakSensors] = useState<LeakSensorStatus[]>([]);
|
||
const [mapRacks, setMapRacks] = useState<RackCapacity[]>([]);
|
||
const [mapLayout, setMapLayout] = useState<Record<string, { label: string; crac_id: string; rows: { label: string; racks: string[] }[] }> | null>(null);
|
||
const [chartHours, setChartHours] = useState(1);
|
||
const [loading, setLoading] = useState(true);
|
||
const [liveError, setLiveError] = useState(false);
|
||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||
const [selectedRack, setSelectedRack] = useState<string | null>(null);
|
||
|
||
const refreshKpis = useCallback(async () => {
|
||
try {
|
||
const [k, a, g, l, cap] = await Promise.all([
|
||
fetchKpis(SITE_ID),
|
||
fetchAlarms(SITE_ID),
|
||
fetchGeneratorStatus(SITE_ID).catch(() => []),
|
||
fetchLeakStatus(SITE_ID).catch(() => []),
|
||
fetchCapacitySummary(SITE_ID).catch(() => null),
|
||
]);
|
||
setKpis((current) => {
|
||
if (current !== FALLBACK_KPIS) setPrevKpis(current);
|
||
return k;
|
||
});
|
||
setAlarms(a);
|
||
setGenerators(g);
|
||
setLeakSensors(l);
|
||
if (cap) setMapRacks(cap.racks);
|
||
setLiveError(false);
|
||
setLastUpdated(new Date());
|
||
} catch {
|
||
setLiveError(true);
|
||
}
|
||
}, []);
|
||
|
||
const refreshCharts = useCallback(async () => {
|
||
try {
|
||
const [p, t] = await Promise.all([
|
||
fetchPowerHistory(SITE_ID, chartHours),
|
||
fetchTempHistory(SITE_ID, chartHours),
|
||
]);
|
||
setPowerHistory(p);
|
||
setTempHistory(t);
|
||
} catch {
|
||
// keep previous chart data on failure
|
||
}
|
||
}, []);
|
||
|
||
// Initial load
|
||
useEffect(() => {
|
||
Promise.all([refreshKpis(), refreshCharts()]).finally(() => setLoading(false));
|
||
fetchFloorLayout(SITE_ID)
|
||
.then(l => setMapLayout(l as typeof mapLayout))
|
||
.catch(() => {});
|
||
}, [refreshKpis, refreshCharts]);
|
||
|
||
// Re-fetch charts when time range changes
|
||
useEffect(() => { refreshCharts(); }, [chartHours, refreshCharts]);
|
||
|
||
// Polling
|
||
useEffect(() => {
|
||
const kpiTimer = setInterval(refreshKpis, KPI_INTERVAL);
|
||
const chartTimer = setInterval(refreshCharts, CHART_INTERVAL);
|
||
return () => { clearInterval(kpiTimer); clearInterval(chartTimer); };
|
||
}, [refreshKpis, refreshCharts]);
|
||
|
||
function handleAlarmClick(alarm: Alarm) {
|
||
if (alarm.rack_id) {
|
||
setSelectedRack(alarm.rack_id);
|
||
} else if (alarm.room_id) {
|
||
router.push("/environmental");
|
||
} else {
|
||
router.push("/alarms");
|
||
}
|
||
}
|
||
|
||
// Derived KPI display values
|
||
const alarmStatus = kpis.active_alarms === 0 ? "ok"
|
||
: kpis.active_alarms <= 2 ? "warning" : "critical";
|
||
|
||
const tempStatus = kpis.avg_temperature === 0 ? "ok"
|
||
: kpis.avg_temperature >= 28 ? "critical"
|
||
: kpis.avg_temperature >= 25 ? "warning" : "ok";
|
||
|
||
// Trends vs previous poll
|
||
const powerTrend = prevKpis ? Math.round((kpis.total_power_kw - prevKpis.total_power_kw) * 10) / 10 : null;
|
||
const tempTrend = prevKpis ? Math.round((kpis.avg_temperature - prevKpis.avg_temperature) * 10) / 10 : null;
|
||
const alarmTrend = prevKpis ? kpis.active_alarms - prevKpis.active_alarms : null;
|
||
|
||
// Generator derived
|
||
const gen = generators[0] ?? null;
|
||
const genFuel = gen?.fuel_pct ?? null;
|
||
const genState = gen?.state ?? "unknown";
|
||
const genStatus: "ok" | "warning" | "critical" =
|
||
genState === "fault" ? "critical" :
|
||
genState === "running" ? "warning" :
|
||
genFuel !== null && genFuel < 25 ? "warning" : "ok";
|
||
|
||
// Leak derived
|
||
const activeLeaks = leakSensors.filter(s => s.state === "detected").length;
|
||
const leakStatus: "ok" | "warning" | "critical" = activeLeaks > 0 ? "critical" : "ok";
|
||
|
||
return (
|
||
<PageShell>
|
||
<RackDetailSheet siteId="sg-01" rackId={selectedRack} onClose={() => setSelectedRack(null)} />
|
||
{/* Live status bar */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
{liveError ? (
|
||
<><WifiOff className="w-3 h-3 text-destructive" /> Live data unavailable</>
|
||
) : (
|
||
<><Wifi className="w-3 h-3 text-green-400" /> Live · updates every 15s</>
|
||
)}
|
||
</div>
|
||
{lastUpdated && (
|
||
<span className="text-xs text-muted-foreground">
|
||
Last updated {lastUpdated.toLocaleTimeString()}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Unified KPI grid — 3×2 on desktop */}
|
||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-3">
|
||
<KpiCard
|
||
title="Total Power"
|
||
value={loading ? "—" : `${kpis.total_power_kw} kW`}
|
||
icon={Zap}
|
||
iconColor="text-amber-400"
|
||
status="ok"
|
||
loading={loading}
|
||
trend={powerTrend}
|
||
trendLabel={powerTrend !== null ? `${powerTrend > 0 ? "+" : ""}${powerTrend} kW` : undefined}
|
||
href="/power"
|
||
/>
|
||
<KpiCard
|
||
title="PUE"
|
||
value={loading ? "—" : kpis.pue.toFixed(2)}
|
||
hint="Lower is better"
|
||
icon={Wind}
|
||
iconColor="text-primary"
|
||
status="ok"
|
||
loading={loading}
|
||
href="/capacity"
|
||
/>
|
||
<KpiCard
|
||
title="Avg Temperature"
|
||
value={loading ? "—" : `${kpis.avg_temperature}°C`}
|
||
icon={Thermometer}
|
||
iconColor="text-green-400"
|
||
status={loading ? "ok" : tempStatus}
|
||
loading={loading}
|
||
trend={tempTrend}
|
||
trendLabel={tempTrend !== null ? `${tempTrend > 0 ? "+" : ""}${tempTrend}°C` : undefined}
|
||
trendInvert
|
||
href="/environmental"
|
||
/>
|
||
<KpiCard
|
||
title="Active Alarms"
|
||
value={loading ? "—" : String(kpis.active_alarms)}
|
||
icon={AlertTriangle}
|
||
iconColor="text-destructive"
|
||
status={loading ? "ok" : alarmStatus}
|
||
loading={loading}
|
||
trend={alarmTrend}
|
||
trendLabel={alarmTrend !== null ? `${alarmTrend > 0 ? "+" : ""}${alarmTrend}` : undefined}
|
||
trendInvert
|
||
href="/alarms"
|
||
/>
|
||
<KpiCard
|
||
title="Generator"
|
||
value={loading ? "—" : genFuel !== null ? `${genFuel.toFixed(1)}% fuel` : "—"}
|
||
hint={genState === "standby" ? "Standby — ready" : genState === "running" ? "Running under load" : genState === "test" ? "Test run" : genState === "fault" ? "FAULT — check generator" : "—"}
|
||
icon={Fuel}
|
||
iconColor={genStatus === "critical" ? "text-destructive" : genStatus === "warning" ? "text-amber-400" : "text-green-400"}
|
||
status={loading ? "ok" : genStatus}
|
||
loading={loading}
|
||
href="/power"
|
||
/>
|
||
<KpiCard
|
||
title="Leak Detection"
|
||
value={loading ? "—" : activeLeaks > 0 ? `${activeLeaks} active` : "All clear"}
|
||
hint={activeLeaks > 0 ? "Water detected — investigate immediately" : `${leakSensors.length} sensors monitoring`}
|
||
icon={Droplets}
|
||
iconColor={leakStatus === "critical" ? "text-destructive" : "text-blue-400"}
|
||
status={loading ? "ok" : leakStatus}
|
||
loading={loading}
|
||
href="/environmental"
|
||
/>
|
||
</div>
|
||
|
||
{/* Charts row */}
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Trends</p>
|
||
<TimeRangePicker value={chartHours} onChange={setChartHours} />
|
||
</div>
|
||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||
<PowerTrendChart data={powerHistory} loading={loading} />
|
||
<TemperatureTrendChart data={tempHistory} loading={loading} />
|
||
</div>
|
||
|
||
{/* Bottom row — 50/50 */}
|
||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||
<MiniFloorMap layout={mapLayout} racks={mapRacks} loading={loading} />
|
||
<AlarmFeed alarms={alarms} loading={loading} onAcknowledge={refreshKpis} onAlarmClick={handleAlarmClick} />
|
||
</div>
|
||
</PageShell>
|
||
);
|
||
}
|