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