first commit
This commit is contained in:
commit
4b98219bf7
144 changed files with 31561 additions and 0 deletions
753
frontend/app/(dashboard)/alarms/page.tsx
Normal file
753
frontend/app/(dashboard)/alarms/page.tsx
Normal file
|
|
@ -0,0 +1,753 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
fetchAlarms, fetchAlarmStats, acknowledgeAlarm, resolveAlarm,
|
||||
type Alarm, type AlarmStats,
|
||||
} from "@/lib/api";
|
||||
import { RackDetailSheet } from "@/components/dashboard/rack-detail-sheet";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
AlertTriangle, CheckCircle2, Clock, XCircle, Bell,
|
||||
ChevronsUpDown, ChevronUp, ChevronDown, Activity,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
BarChart, Bar, XAxis, Tooltip, ResponsiveContainer, Cell,
|
||||
} from "recharts";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
type StateFilter = "active" | "acknowledged" | "resolved" | "all";
|
||||
type SeverityFilter = "all" | "critical" | "warning" | "info";
|
||||
type SortKey = "severity" | "triggered_at" | "state";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h`;
|
||||
return `${Math.floor(h / 24)}d`;
|
||||
}
|
||||
|
||||
function useNow(intervalMs = 30_000): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [intervalMs]);
|
||||
return now;
|
||||
}
|
||||
|
||||
function escalationMinutes(triggeredAt: string, now: number): number {
|
||||
return Math.floor((now - new Date(triggeredAt).getTime()) / 60_000);
|
||||
}
|
||||
|
||||
function EscalationTimer({ triggeredAt, now }: { triggeredAt: string; now: number }) {
|
||||
const mins = escalationMinutes(triggeredAt, now);
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = mins % 60;
|
||||
const label = h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
|
||||
const colorClass =
|
||||
mins >= 60 ? "text-destructive" :
|
||||
mins >= 15 ? "text-amber-400" :
|
||||
mins >= 5 ? "text-amber-300" :
|
||||
"text-muted-foreground";
|
||||
|
||||
const pulse = mins >= 60;
|
||||
|
||||
return (
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1 text-xs font-mono font-semibold tabular-nums",
|
||||
colorClass,
|
||||
pulse && "animate-pulse",
|
||||
)}>
|
||||
<Clock className="w-3 h-3 shrink-0" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function alarmCategory(sensorId: string | null | undefined): { label: string; className: string } {
|
||||
if (!sensorId) return { label: "System", className: "bg-muted/50 text-muted-foreground" };
|
||||
const s = sensorId.toLowerCase();
|
||||
if (s.includes("cooling") || s.includes("crac") || s.includes("refrigerant") || s.includes("cop"))
|
||||
return { label: "Refrigerant", className: "bg-cyan-500/10 text-cyan-400" };
|
||||
if (s.includes("temp") || s.includes("thermal") || s.includes("humidity") || s.includes("hum"))
|
||||
return { label: "Thermal", className: "bg-orange-500/10 text-orange-400" };
|
||||
if (s.includes("power") || s.includes("ups") || s.includes("pdu") || s.includes("kw") || s.includes("watt"))
|
||||
return { label: "Power", className: "bg-yellow-500/10 text-yellow-400" };
|
||||
if (s.includes("leak") || s.includes("water") || s.includes("flood"))
|
||||
return { label: "Leak", className: "bg-blue-500/10 text-blue-400" };
|
||||
return { label: "System", className: "bg-muted/50 text-muted-foreground" };
|
||||
}
|
||||
|
||||
const severityConfig: Record<string, { label: string; bg: string; dot: string }> = {
|
||||
critical: { label: "Critical", bg: "bg-destructive/15 text-destructive border-destructive/30", dot: "bg-destructive" },
|
||||
warning: { label: "Warning", bg: "bg-amber-500/15 text-amber-400 border-amber-500/30", dot: "bg-amber-500" },
|
||||
info: { label: "Info", bg: "bg-blue-500/15 text-blue-400 border-blue-500/30", dot: "bg-blue-500" },
|
||||
};
|
||||
|
||||
const stateConfig: Record<string, { label: string; className: string }> = {
|
||||
active: { label: "Active", className: "bg-destructive/10 text-destructive" },
|
||||
acknowledged: { label: "Acknowledged", className: "bg-amber-500/10 text-amber-400" },
|
||||
resolved: { label: "Resolved", className: "bg-green-500/10 text-green-400" },
|
||||
};
|
||||
|
||||
function SeverityBadge({ severity }: { severity: string }) {
|
||||
const c = severityConfig[severity] ?? severityConfig.info;
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wide border", c.bg)}>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", c.dot)} />
|
||||
{c.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, icon: Icon, highlight }: { label: string; value: number; icon: React.ElementType; highlight?: boolean }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className={cn("p-2 rounded-lg", highlight && value > 0 ? "bg-destructive/10" : "bg-muted")}>
|
||||
<Icon className={cn("w-4 h-4", highlight && value > 0 ? "text-destructive" : "text-muted-foreground")} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={cn("text-2xl font-bold", highlight && value > 0 ? "text-destructive" : "")}>{value}</p>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AvgAgeCard({ alarms }: { alarms: Alarm[] }) {
|
||||
const activeAlarms = alarms.filter(a => a.state === "active");
|
||||
const avgMins = useMemo(() => {
|
||||
if (activeAlarms.length === 0) return 0;
|
||||
const now = Date.now();
|
||||
const totalMins = activeAlarms.reduce((sum, a) => {
|
||||
return sum + Math.floor((now - new Date(a.triggered_at).getTime()) / 60_000);
|
||||
}, 0);
|
||||
return Math.round(totalMins / activeAlarms.length);
|
||||
}, [activeAlarms]);
|
||||
|
||||
const label = avgMins >= 60
|
||||
? `${Math.floor(avgMins / 60)}h ${avgMins % 60}m`
|
||||
: `${avgMins}m`;
|
||||
|
||||
const colorClass = avgMins > 60 ? "text-destructive"
|
||||
: avgMins > 15 ? "text-amber-400"
|
||||
: "text-green-400";
|
||||
|
||||
const iconColor = avgMins > 60 ? "text-destructive"
|
||||
: avgMins > 15 ? "text-amber-400"
|
||||
: "text-muted-foreground";
|
||||
|
||||
const bgColor = avgMins > 60 ? "bg-destructive/10"
|
||||
: avgMins > 15 ? "bg-amber-500/10"
|
||||
: "bg-muted";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className={cn("p-2 rounded-lg", bgColor)}>
|
||||
<Clock className={cn("w-4 h-4", iconColor)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={cn("text-2xl font-bold", activeAlarms.length > 0 ? colorClass : "")}>{activeAlarms.length > 0 ? label : "—"}</p>
|
||||
<p className="text-xs text-muted-foreground">Avg Age</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type Correlation = {
|
||||
id: string
|
||||
title: string
|
||||
severity: "critical" | "warning"
|
||||
description: string
|
||||
alarmIds: number[]
|
||||
}
|
||||
|
||||
function correlateAlarms(alarms: Alarm[]): Correlation[] {
|
||||
const active = alarms.filter(a => a.state === "active");
|
||||
const results: Correlation[] = [];
|
||||
|
||||
// Rule 1: ≥2 thermal alarms in the same room → probable CRAC issue
|
||||
const thermalByRoom = new Map<string, Alarm[]>();
|
||||
for (const a of active) {
|
||||
const isThermal = a.sensor_id
|
||||
? /temp|thermal|humidity|hum/i.test(a.sensor_id)
|
||||
: /temp|thermal|hot|cool/i.test(a.message);
|
||||
const room = a.room_id;
|
||||
if (isThermal && room) {
|
||||
if (!thermalByRoom.has(room)) thermalByRoom.set(room, []);
|
||||
thermalByRoom.get(room)!.push(a);
|
||||
}
|
||||
}
|
||||
for (const [room, roomAlarms] of thermalByRoom.entries()) {
|
||||
if (roomAlarms.length >= 2) {
|
||||
results.push({
|
||||
id: `thermal-${room}`,
|
||||
title: `Thermal event — ${room.replace("hall-", "Hall ")}`,
|
||||
severity: roomAlarms.some(a => a.severity === "critical") ? "critical" : "warning",
|
||||
description: `${roomAlarms.length} thermal alarms in the same room. Probable cause: CRAC cooling degradation or containment breach.`,
|
||||
alarmIds: roomAlarms.map(a => a.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 2: ≥3 power alarms across different racks → PDU or UPS path issue
|
||||
const powerAlarms = active.filter(a =>
|
||||
a.sensor_id ? /power|pdu|ups|kw|watt/i.test(a.sensor_id) : /power|overload|circuit/i.test(a.message)
|
||||
);
|
||||
const powerRacks = new Set(powerAlarms.map(a => a.rack_id).filter(Boolean));
|
||||
if (powerRacks.size >= 2) {
|
||||
results.push({
|
||||
id: "power-multi-rack",
|
||||
title: "Multi-rack power event",
|
||||
severity: powerAlarms.some(a => a.severity === "critical") ? "critical" : "warning",
|
||||
description: `Power alarms on ${powerRacks.size} racks simultaneously. Probable cause: upstream PDU, busway tap, or UPS transfer.`,
|
||||
alarmIds: powerAlarms.map(a => a.id),
|
||||
});
|
||||
}
|
||||
|
||||
// Rule 3: Generator + ATS alarms together → power path / utility failure
|
||||
const genAlarm = active.find(a => a.sensor_id ? /gen/i.test(a.sensor_id) : /generator/i.test(a.message));
|
||||
const atsAlarm = active.find(a => a.sensor_id ? /ats/i.test(a.sensor_id) : /transfer|utility/i.test(a.message));
|
||||
if (genAlarm && atsAlarm) {
|
||||
results.push({
|
||||
id: "gen-ats-event",
|
||||
title: "Power path event — generator + ATS",
|
||||
severity: "critical",
|
||||
description: "Generator and ATS alarms are co-active. Possible utility failure with generator transfer in progress.",
|
||||
alarmIds: [genAlarm.id, atsAlarm.id],
|
||||
});
|
||||
}
|
||||
|
||||
// Rule 4: ≥2 leak alarms → site-wide leak / pipe burst
|
||||
const leakAlarms = active.filter(a =>
|
||||
a.sensor_id ? /leak|water|flood/i.test(a.sensor_id) : /leak|water/i.test(a.message)
|
||||
);
|
||||
if (leakAlarms.length >= 2) {
|
||||
results.push({
|
||||
id: "multi-leak",
|
||||
title: "Multiple leak sensors triggered",
|
||||
severity: "critical",
|
||||
description: `${leakAlarms.length} leak sensors active. Probable cause: pipe burst, chilled water leak, or CRAC drain overflow.`,
|
||||
alarmIds: leakAlarms.map(a => a.id),
|
||||
});
|
||||
}
|
||||
|
||||
// Rule 5: VESDA + high temp in same room → fire / smoke event
|
||||
const vesdaAlarm = active.find(a => a.sensor_id ? /vesda|fire/i.test(a.sensor_id) : /fire|smoke|vesda/i.test(a.message));
|
||||
const hotRooms = new Set(active.filter(a => a.severity === "critical" && a.room_id && /temp/i.test(a.message + (a.sensor_id ?? ""))).map(a => a.room_id));
|
||||
if (vesdaAlarm && hotRooms.size > 0) {
|
||||
results.push({
|
||||
id: "fire-temp-event",
|
||||
title: "Fire / smoke event suspected",
|
||||
severity: "critical",
|
||||
description: "VESDA alarm co-active with critical temperature alarms. Possible fire or smoke event — check fire safety systems immediately.",
|
||||
alarmIds: active.filter(a => hotRooms.has(a.room_id)).map(a => a.id).concat(vesdaAlarm.id),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function RootCausePanel({ alarms }: { alarms: Alarm[] }) {
|
||||
const correlations = correlateAlarms(alarms);
|
||||
if (correlations.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card className="border-amber-500/30 bg-amber-500/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-amber-400" />
|
||||
Root Cause Analysis
|
||||
<span className="text-[10px] font-normal text-muted-foreground ml-1">
|
||||
{correlations.length} pattern{correlations.length > 1 ? "s" : ""} detected
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{correlations.map(c => (
|
||||
<div key={c.id} className={cn(
|
||||
"flex items-start gap-3 rounded-lg px-3 py-2.5 border",
|
||||
c.severity === "critical"
|
||||
? "bg-destructive/10 border-destructive/20"
|
||||
: "bg-amber-500/10 border-amber-500/20",
|
||||
)}>
|
||||
<AlertTriangle className={cn(
|
||||
"w-3.5 h-3.5 shrink-0 mt-0.5",
|
||||
c.severity === "critical" ? "text-destructive" : "text-amber-400",
|
||||
)} />
|
||||
<div className="space-y-0.5 min-w-0">
|
||||
<p className={cn(
|
||||
"text-xs font-semibold",
|
||||
c.severity === "critical" ? "text-destructive" : "text-amber-400",
|
||||
)}>
|
||||
{c.title}
|
||||
<span className="text-muted-foreground font-normal ml-2">
|
||||
({c.alarmIds.length} alarm{c.alarmIds.length !== 1 ? "s" : ""})
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">{c.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AlarmsPage() {
|
||||
const router = useRouter();
|
||||
const now = useNow(30_000);
|
||||
const [alarms, setAlarms] = useState<Alarm[]>([]);
|
||||
const [allAlarms, setAllAlarms] = useState<Alarm[]>([]);
|
||||
const [stats, setStats] = useState<AlarmStats | null>(null);
|
||||
const [stateFilter, setStateFilter] = useState<StateFilter>("active");
|
||||
const [sevFilter, setSevFilter] = useState<SeverityFilter>("all");
|
||||
const [sortKey, setSortKey] = useState<SortKey>("triggered_at");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [acting, setActing] = useState<number | null>(null);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [bulkActing, setBulkActing] = useState(false);
|
||||
const [selectedRack, setSelectedRack] = useState<string | null>(null);
|
||||
const [assignments, setAssignments] = useState<Record<number, string>>({});
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setAssignments(JSON.parse(localStorage.getItem("alarm-assignments") ?? "{}"));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
function setAssignment(id: number, assignee: string) {
|
||||
const next = { ...assignments, [id]: assignee };
|
||||
setAssignments(next);
|
||||
localStorage.setItem("alarm-assignments", JSON.stringify(next));
|
||||
}
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [a, s, all] = await Promise.all([
|
||||
fetchAlarms(SITE_ID, stateFilter),
|
||||
fetchAlarmStats(SITE_ID),
|
||||
fetchAlarms(SITE_ID, "all", 200),
|
||||
]);
|
||||
setAlarms(a);
|
||||
setStats(s);
|
||||
setAllAlarms(all);
|
||||
} catch {
|
||||
toast.error("Failed to load alarms");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
load();
|
||||
const id = setInterval(load, 15_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
// Reset page when filters change
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [stateFilter, sevFilter]);
|
||||
|
||||
async function handleAcknowledge(id: number) {
|
||||
setActing(id);
|
||||
try { await acknowledgeAlarm(id); toast.success("Alarm acknowledged"); await load(); } finally { setActing(null); }
|
||||
}
|
||||
|
||||
async function handleResolve(id: number) {
|
||||
setActing(id);
|
||||
try { await resolveAlarm(id); toast.success("Alarm resolved"); await load(); } finally { setActing(null); }
|
||||
}
|
||||
|
||||
async function handleBulkResolve() {
|
||||
setBulkActing(true);
|
||||
const count = selected.size;
|
||||
try {
|
||||
await Promise.all(Array.from(selected).map((id) => resolveAlarm(id)));
|
||||
toast.success(`${count} alarm${count !== 1 ? "s" : ""} resolved`);
|
||||
setSelected(new Set());
|
||||
await load();
|
||||
} finally { setBulkActing(false); }
|
||||
}
|
||||
|
||||
function toggleSelect(id: number) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
const resolvable = visible.filter((a) => a.state !== "resolved").map((a) => a.id);
|
||||
if (resolvable.every((id) => selected.has(id))) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(resolvable));
|
||||
}
|
||||
}
|
||||
|
||||
const sevOrder: Record<string, number> = { critical: 0, warning: 1, info: 2 };
|
||||
const stateOrder: Record<string, number> = { active: 0, acknowledged: 1, resolved: 2 };
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) setSortDir((d) => d === "asc" ? "desc" : "asc");
|
||||
else { setSortKey(key); setSortDir("desc"); }
|
||||
}
|
||||
|
||||
function SortIcon({ col }: { col: SortKey }) {
|
||||
if (sortKey !== col) return <ChevronsUpDown className="w-3 h-3 opacity-40" />;
|
||||
return sortDir === "asc" ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />;
|
||||
}
|
||||
|
||||
const visible = (sevFilter === "all" ? alarms : alarms.filter((a) => a.severity === sevFilter))
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortKey === "severity") cmp = (sevOrder[a.severity] ?? 9) - (sevOrder[b.severity] ?? 9);
|
||||
if (sortKey === "triggered_at") cmp = new Date(a.triggered_at).getTime() - new Date(b.triggered_at).getTime();
|
||||
if (sortKey === "state") cmp = (stateOrder[a.state] ?? 9) - (stateOrder[b.state] ?? 9);
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
|
||||
const pageCount = Math.ceil(visible.length / PAGE_SIZE);
|
||||
const paginated = visible.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<RackDetailSheet siteId={SITE_ID} rackId={selectedRack} onClose={() => setSelectedRack(null)} />
|
||||
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Alarms & Events</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — refreshes every 15s</p>
|
||||
</div>
|
||||
|
||||
{/* Escalation banner — longest unacknowledged critical */}
|
||||
{(() => {
|
||||
const critActive = alarms.filter(a => a.severity === "critical" && a.state === "active");
|
||||
if (critActive.length === 0) return null;
|
||||
const oldest = critActive.reduce((a, b) =>
|
||||
new Date(a.triggered_at) < new Date(b.triggered_at) ? a : b
|
||||
);
|
||||
const mins = escalationMinutes(oldest.triggered_at, now);
|
||||
const urgency = mins >= 60 ? "bg-destructive/10 border-destructive/30 text-destructive"
|
||||
: mins >= 15 ? "bg-amber-500/10 border-amber-500/30 text-amber-400"
|
||||
: "bg-amber-500/5 border-amber-500/20 text-amber-300";
|
||||
return (
|
||||
<div className={cn("flex items-center gap-3 rounded-lg border px-4 py-2.5 text-xs", urgency)}>
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>
|
||||
<strong>{critActive.length} critical alarm{critActive.length > 1 ? "s" : ""}</strong> unacknowledged
|
||||
{" — "}longest open for <strong><EscalationTimer triggeredAt={oldest.triggered_at} now={now} /></strong>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Root cause correlation panel */}
|
||||
{!loading && <RootCausePanel alarms={allAlarms} />}
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{stats ? (
|
||||
<>
|
||||
<StatCard label="Active" value={stats.active} icon={Bell} highlight />
|
||||
<AvgAgeCard alarms={allAlarms} />
|
||||
<StatCard label="Acknowledged" value={stats.acknowledged} icon={Clock} />
|
||||
<StatCard label="Resolved" value={stats.resolved} icon={CheckCircle2} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sticky filter bar */}
|
||||
<div className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm -mx-6 px-6 py-3 border-b border-border/30">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Tabs value={stateFilter} onValueChange={(v) => setStateFilter(v as StateFilter)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="active">Active</TabsTrigger>
|
||||
<TabsTrigger value="acknowledged">Acknowledged</TabsTrigger>
|
||||
<TabsTrigger value="resolved">Resolved</TabsTrigger>
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{(["all", "critical", "warning", "info"] as SeverityFilter[]).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSevFilter(s)}
|
||||
className={cn(
|
||||
"px-2.5 py-1 rounded-md text-xs font-medium capitalize transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
||||
sevFilter === s
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bulk actions inline in filter row */}
|
||||
{selected.size > 0 && (
|
||||
<div className="flex items-center gap-3 ml-auto">
|
||||
<span className="text-xs text-muted-foreground">{selected.size} selected</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs text-green-400 border-green-500/30 hover:bg-green-500/10"
|
||||
disabled={bulkActing}
|
||||
onClick={handleBulkResolve}
|
||||
>
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
Resolve selected ({selected.size})
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => setSelected(new Set())}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row count */}
|
||||
{!loading && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{visible.length} alarm{visible.length !== 1 ? "s" : ""} matching filter
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-4 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-12 w-full" />)}
|
||||
</div>
|
||||
) : visible.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-500/50" />
|
||||
<p className="text-sm">No alarms matching this filter</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground uppercase tracking-wide">
|
||||
<th className="px-4 py-3 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={visible.filter((a) => a.state !== "resolved").every((a) => selected.has(a.id))}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium">
|
||||
<button onClick={() => toggleSort("severity")} className="flex items-center gap-1 hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded">
|
||||
Severity <SortIcon col="severity" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Message</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Location</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden xl:table-cell">Sensor</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Category</th>
|
||||
<th className="text-left px-4 py-3 font-medium">
|
||||
<button onClick={() => toggleSort("state")} className="flex items-center gap-1 hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded">
|
||||
State <SortIcon col="state" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium">
|
||||
<button onClick={() => toggleSort("triggered_at")} className="flex items-center gap-1 hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded">
|
||||
Age <SortIcon col="triggered_at" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Escalation</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Assigned</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{paginated.map((alarm) => {
|
||||
const sc = stateConfig[alarm.state] ?? stateConfig.active;
|
||||
const cat = alarmCategory(alarm.sensor_id);
|
||||
return (
|
||||
<tr key={alarm.id} className={cn("hover:bg-muted/30 transition-colors", selected.has(alarm.id) && "bg-muted/20")}>
|
||||
<td className="px-4 py-3 w-8">
|
||||
{alarm.state !== "resolved" && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={selected.has(alarm.id)}
|
||||
onChange={() => toggleSelect(alarm.id)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<SeverityBadge severity={alarm.severity} />
|
||||
</td>
|
||||
<td className="px-4 py-3 max-w-xs">
|
||||
<span className="line-clamp-2">{alarm.message}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs">
|
||||
{(alarm.room_id || alarm.rack_id) ? (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{alarm.room_id && (
|
||||
<button
|
||||
onClick={() => router.push("/environmental")}
|
||||
className="text-muted-foreground hover:text-primary transition-colors underline-offset-2 hover:underline"
|
||||
>
|
||||
{alarm.room_id}
|
||||
</button>
|
||||
)}
|
||||
{alarm.room_id && alarm.rack_id && <span className="text-muted-foreground/40">/</span>}
|
||||
{alarm.rack_id && (
|
||||
<button
|
||||
onClick={() => setSelectedRack(alarm.rack_id)}
|
||||
className="text-muted-foreground hover:text-primary transition-colors underline-offset-2 hover:underline"
|
||||
>
|
||||
{alarm.rack_id}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : <span className="text-muted-foreground">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden xl:table-cell">
|
||||
{alarm.sensor_id ? (
|
||||
<span className="font-mono text-[10px] text-muted-foreground bg-muted/40 px-1.5 py-0.5 rounded">
|
||||
{alarm.sensor_id.split("/").slice(-1)[0]}
|
||||
</span>
|
||||
) : <span className="text-muted-foreground text-xs">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", cat.className)}>
|
||||
{cat.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge className={cn("text-[10px] font-semibold border-0", sc.className)}>
|
||||
{sc.label}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground text-xs whitespace-nowrap tabular-nums">
|
||||
{timeAgo(alarm.triggered_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
{alarm.state !== "resolved" && alarm.severity === "critical" ? (
|
||||
<EscalationTimer triggeredAt={alarm.triggered_at} now={now} />
|
||||
) : (
|
||||
<span className="text-muted-foreground/30 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
<select
|
||||
value={assignments[alarm.id] ?? ""}
|
||||
onChange={(e) => setAssignment(alarm.id, e.target.value)}
|
||||
className="text-[10px] bg-muted/30 border border-border rounded px-1.5 py-0.5 text-foreground/80 focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<option value="">— Unassigned</option>
|
||||
<option value="Alice T.">Alice T.</option>
|
||||
<option value="Bob K.">Bob K.</option>
|
||||
<option value="Charlie L.">Charlie L.</option>
|
||||
<option value="Dave M.">Dave M.</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{alarm.state === "active" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
disabled={acting === alarm.id}
|
||||
onClick={() => handleAcknowledge(alarm.id)}
|
||||
>
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
Ack
|
||||
</Button>
|
||||
)}
|
||||
{(alarm.state === "active" || alarm.state === "acknowledged") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs text-green-400 border-green-500/30 hover:bg-green-500/10"
|
||||
disabled={acting === alarm.id}
|
||||
onClick={() => handleResolve(alarm.id)}
|
||||
>
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pagination bar */}
|
||||
{!loading && visible.length > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
Showing {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, visible.length)} of {visible.length} alarms
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="px-2">{page} / {pageCount}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
disabled={page >= pageCount}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
703
frontend/app/(dashboard)/assets/page.tsx
Normal file
703
frontend/app/(dashboard)/assets/page.tsx
Normal file
|
|
@ -0,0 +1,703 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
fetchAssets, fetchAllDevices, fetchPduReadings,
|
||||
type AssetsData, type RackAsset, type CracAsset, type UpsAsset, type Device, type PduReading,
|
||||
} from "@/lib/api";
|
||||
import { RackDetailSheet } from "@/components/dashboard/rack-detail-sheet";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Thermometer, Zap, Wind, Battery, AlertTriangle,
|
||||
CheckCircle2, HelpCircle, LayoutGrid, List, Download,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
const roomLabels: Record<string, string> = { "hall-a": "Hall A", "hall-b": "Hall B" };
|
||||
|
||||
// ── Status helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
const statusStyles: Record<string, { dot: string; border: string }> = {
|
||||
ok: { dot: "bg-green-500", border: "border-green-500/20" },
|
||||
warning: { dot: "bg-amber-500", border: "border-amber-500/30" },
|
||||
critical: { dot: "bg-destructive", border: "border-destructive/30" },
|
||||
unknown: { dot: "bg-muted", border: "border-border" },
|
||||
};
|
||||
|
||||
const TYPE_STYLES: Record<string, { dot: string; label: string }> = {
|
||||
server: { dot: "bg-blue-400", label: "Server" },
|
||||
switch: { dot: "bg-green-400", label: "Switch" },
|
||||
patch_panel: { dot: "bg-slate-400", label: "Patch Panel" },
|
||||
pdu: { dot: "bg-amber-400", label: "PDU" },
|
||||
storage: { dot: "bg-purple-400", label: "Storage" },
|
||||
firewall: { dot: "bg-red-400", label: "Firewall" },
|
||||
kvm: { dot: "bg-teal-400", label: "KVM" },
|
||||
};
|
||||
|
||||
// ── Compact CRAC row ──────────────────────────────────────────────────────────
|
||||
|
||||
function CracRow({ crac }: { crac: CracAsset }) {
|
||||
const online = crac.state === "online";
|
||||
const fault = crac.state === "fault";
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 text-xs">
|
||||
<Wind className="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<span className="font-semibold font-mono w-20 shrink-0">{crac.crac_id.toUpperCase()}</span>
|
||||
<span className={cn(
|
||||
"flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded-full uppercase tracking-wide shrink-0",
|
||||
fault ? "bg-destructive/10 text-destructive" :
|
||||
online ? "bg-green-500/10 text-green-400" :
|
||||
"bg-muted text-muted-foreground",
|
||||
)}>
|
||||
{fault ? <AlertTriangle className="w-2.5 h-2.5" /> :
|
||||
online ? <CheckCircle2 className="w-2.5 h-2.5" /> :
|
||||
<HelpCircle className="w-2.5 h-2.5" />}
|
||||
{fault ? "Fault" : online ? "Online" : "Unk"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">Supply: <span className="text-foreground font-medium">{crac.supply_temp !== null ? `${crac.supply_temp}°C` : "—"}</span></span>
|
||||
<span className="text-muted-foreground">Return: <span className="text-foreground font-medium">{crac.return_temp !== null ? `${crac.return_temp}°C` : "—"}</span></span>
|
||||
<span className="text-muted-foreground">Fan: <span className="text-foreground font-medium">{crac.fan_pct !== null ? `${crac.fan_pct}%` : "—"}</span></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Compact UPS row ───────────────────────────────────────────────────────────
|
||||
|
||||
function UpsRow({ ups }: { ups: UpsAsset }) {
|
||||
const onBattery = ups.state === "battery";
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 text-xs">
|
||||
<Battery className="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<span className="font-semibold font-mono w-20 shrink-0">{ups.ups_id.toUpperCase()}</span>
|
||||
<span className={cn(
|
||||
"flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded-full uppercase tracking-wide shrink-0",
|
||||
onBattery ? "bg-amber-500/10 text-amber-400" :
|
||||
ups.state === "online" ? "bg-green-500/10 text-green-400" :
|
||||
"bg-muted text-muted-foreground",
|
||||
)}>
|
||||
{onBattery ? <AlertTriangle className="w-2.5 h-2.5" /> : <CheckCircle2 className="w-2.5 h-2.5" />}
|
||||
{onBattery ? "Battery" : ups.state === "online" ? "Mains" : "Unk"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">Charge: <span className="text-foreground font-medium">{ups.charge_pct !== null ? `${ups.charge_pct}%` : "—"}</span></span>
|
||||
<span className="text-muted-foreground">Load: <span className="text-foreground font-medium">{ups.load_pct !== null ? `${ups.load_pct}%` : "—"}</span></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Rack sortable table ───────────────────────────────────────────────────────
|
||||
|
||||
type RackSortCol = "rack_id" | "temp" | "power_kw" | "power_pct" | "alarm_count" | "status";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
function RackTable({
|
||||
racks, roomId, statusFilter, onRackClick,
|
||||
}: {
|
||||
racks: RackAsset[];
|
||||
roomId: string;
|
||||
statusFilter: "all" | "warning" | "critical";
|
||||
onRackClick: (id: string) => void;
|
||||
}) {
|
||||
const [sortCol, setSortCol] = useState<RackSortCol>("rack_id");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
|
||||
function toggleSort(col: RackSortCol) {
|
||||
if (sortCol === col) setSortDir(d => d === "asc" ? "desc" : "asc");
|
||||
else { setSortCol(col); setSortDir("asc"); }
|
||||
}
|
||||
|
||||
function SortIcon({ col }: { col: RackSortCol }) {
|
||||
if (sortCol !== col) return <span className="opacity-30 ml-0.5">↕</span>;
|
||||
return <span className="ml-0.5">{sortDir === "asc" ? "↑" : "↓"}</span>;
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const base = statusFilter === "all" ? racks : racks.filter(r => r.status === statusFilter);
|
||||
return [...base].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortCol === "temp" || sortCol === "power_kw" || sortCol === "alarm_count") {
|
||||
cmp = ((a[sortCol] ?? 0) as number) - ((b[sortCol] ?? 0) as number);
|
||||
} else if (sortCol === "power_pct") {
|
||||
const aP = a.power_kw !== null ? a.power_kw / 10 * 100 : 0;
|
||||
const bP = b.power_kw !== null ? b.power_kw / 10 * 100 : 0;
|
||||
cmp = aP - bP;
|
||||
} else {
|
||||
cmp = String(a[sortCol]).localeCompare(String(b[sortCol]));
|
||||
}
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}, [racks, statusFilter, sortCol, sortDir]);
|
||||
|
||||
type ColDef = { col: RackSortCol; label: string };
|
||||
const cols: ColDef[] = [
|
||||
{ col: "rack_id", label: "Rack ID" },
|
||||
{ col: "temp", label: "Temp (°C)" },
|
||||
{ col: "power_kw", label: "Power (kW)" },
|
||||
{ col: "power_pct", label: "Power%" },
|
||||
{ col: "alarm_count", label: "Alarms" },
|
||||
{ col: "status", label: "Status" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-muted-foreground select-none">
|
||||
{cols.map(({ col, label }) => (
|
||||
<th key={col} className="text-left px-3 py-2">
|
||||
<button
|
||||
onClick={() => toggleSort(col)}
|
||||
className="font-semibold hover:text-foreground transition-colors flex items-center gap-0.5"
|
||||
>
|
||||
{label}<SortIcon col={col} />
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
{/* Room column header */}
|
||||
<th className="text-left px-3 py-2 font-semibold text-muted-foreground">Room</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-8 text-muted-foreground">No racks matching this filter</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map(rack => {
|
||||
const powerPct = rack.power_kw !== null ? (rack.power_kw / 10) * 100 : null;
|
||||
const tempCls = rack.temp !== null
|
||||
? rack.temp >= 30 ? "text-destructive" : rack.temp >= 28 ? "text-amber-400" : ""
|
||||
: "";
|
||||
const pctCls = powerPct !== null
|
||||
? powerPct >= 85 ? "text-destructive" : powerPct >= 75 ? "text-amber-400" : ""
|
||||
: "";
|
||||
const s = statusStyles[rack.status] ?? statusStyles.unknown;
|
||||
return (
|
||||
<tr
|
||||
key={rack.rack_id}
|
||||
onClick={() => onRackClick(rack.rack_id)}
|
||||
className="border-b border-border/40 last:border-0 hover:bg-muted/20 transition-colors cursor-pointer"
|
||||
>
|
||||
<td className="px-3 py-2 font-mono font-semibold">{rack.rack_id.toUpperCase()}</td>
|
||||
<td className={cn("px-3 py-2 tabular-nums font-medium", tempCls)}>
|
||||
{rack.temp !== null ? rack.temp : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 tabular-nums text-muted-foreground">
|
||||
{rack.power_kw !== null ? rack.power_kw : "—"}
|
||||
</td>
|
||||
<td className={cn("px-3 py-2 tabular-nums font-medium", pctCls)}>
|
||||
{powerPct !== null ? `${powerPct.toFixed(0)}%` : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{rack.alarm_count > 0
|
||||
? <span className="font-bold text-destructive">{rack.alarm_count}</span>
|
||||
: <span className="text-muted-foreground">0</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn("w-2 h-2 rounded-full", s.dot)} />
|
||||
<span className="capitalize text-muted-foreground">{rack.status}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{roomLabels[roomId] ?? roomId}</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Inventory table ───────────────────────────────────────────────────────────
|
||||
|
||||
type SortCol = "name" | "type" | "rack_id" | "room_id" | "u_start" | "power_draw_w";
|
||||
|
||||
function InventoryTable({ siteId, onRackClick }: { siteId: string; onRackClick: (rackId: string) => void }) {
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
const [roomFilter, setRoomFilter] = useState<string>("all");
|
||||
const [sortCol, setSortCol] = useState<SortCol>("name");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllDevices(siteId)
|
||||
.then(setDevices)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [siteId]);
|
||||
|
||||
function toggleSort(col: SortCol) {
|
||||
if (sortCol === col) setSortDir(d => d === "asc" ? "desc" : "asc");
|
||||
else { setSortCol(col); setSortDir("asc"); }
|
||||
}
|
||||
|
||||
function SortIcon({ col }: { col: SortCol }) {
|
||||
if (sortCol !== col) return <span className="opacity-30 ml-0.5">↕</span>;
|
||||
return <span className="ml-0.5">{sortDir === "asc" ? "↑" : "↓"}</span>;
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
const base = devices.filter(d => {
|
||||
if (typeFilter !== "all" && d.type !== typeFilter) return false;
|
||||
if (roomFilter !== "all" && d.room_id !== roomFilter) return false;
|
||||
if (q && !d.name.toLowerCase().includes(q) && !d.rack_id.includes(q) && !d.ip.includes(q) && !d.serial.toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
return [...base].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortCol === "power_draw_w" || sortCol === "u_start") {
|
||||
cmp = (a[sortCol] ?? 0) - (b[sortCol] ?? 0);
|
||||
} else {
|
||||
cmp = String(a[sortCol]).localeCompare(String(b[sortCol]));
|
||||
}
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}, [devices, search, typeFilter, roomFilter, sortCol, sortDir]);
|
||||
|
||||
const totalPower = filtered.reduce((s, d) => s + d.power_draw_w, 0);
|
||||
const types = Array.from(new Set(devices.map(d => d.type))).sort();
|
||||
|
||||
function downloadCsv() {
|
||||
const headers = ["Device", "Type", "Rack", "Room", "U Start", "U Height", "IP", "Serial", "Power (W)", "Status"];
|
||||
const rows = filtered.map((d) => [
|
||||
d.name, TYPE_STYLES[d.type]?.label ?? d.type, d.rack_id.toUpperCase(),
|
||||
roomLabels[d.room_id] ?? d.room_id, d.u_start, d.u_height,
|
||||
d.ip !== "-" ? d.ip : "", d.serial, d.power_draw_w, d.status,
|
||||
]);
|
||||
const csv = [headers, ...rows]
|
||||
.map((r) => r.map((v) => `"${String(v ?? "").replace(/"/g, '""')}"`).join(","))
|
||||
.join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = Object.assign(document.createElement("a"), {
|
||||
href: url, download: `bms-inventory-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
});
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Export downloaded");
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-2 mt-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Device type legend */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
{Object.entries(TYPE_STYLES).map(([key, { dot, label }]) => (
|
||||
<div key={key} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className={cn("w-2.5 h-2.5 rounded-full shrink-0", dot)} />
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search name, rack, IP, serial…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="flex-1 min-w-48 h-8 rounded-md border border-border bg-muted/30 px-3 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => setTypeFilter(e.target.value)}
|
||||
className="h-8 rounded-md border border-border bg-muted/30 px-2 text-xs focus:outline-none"
|
||||
>
|
||||
<option value="all">All types</option>
|
||||
{types.map(t => <option key={t} value={t}>{TYPE_STYLES[t]?.label ?? t}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={roomFilter}
|
||||
onChange={e => setRoomFilter(e.target.value)}
|
||||
className="h-8 rounded-md border border-border bg-muted/30 px-2 text-xs focus:outline-none"
|
||||
>
|
||||
<option value="all">All rooms</option>
|
||||
<option value="hall-a">Hall A</option>
|
||||
<option value="hall-b">Hall B</option>
|
||||
</select>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{filtered.length} devices · {(totalPower / 1000).toFixed(1)} kW
|
||||
</span>
|
||||
<button
|
||||
onClick={downloadCsv}
|
||||
className="flex items-center gap-1.5 h-8 px-3 rounded-md border border-border bg-muted/30 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" /> Export CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-muted-foreground select-none">
|
||||
{([
|
||||
{ col: "name" as SortCol, label: "Device", cls: "text-left px-3 py-2" },
|
||||
{ col: "type" as SortCol, label: "Type", cls: "text-left px-3 py-2" },
|
||||
{ col: "rack_id" as SortCol, label: "Rack", cls: "text-left px-3 py-2" },
|
||||
{ col: "room_id" as SortCol, label: "Room", cls: "text-left px-3 py-2 hidden sm:table-cell" },
|
||||
{ col: "u_start" as SortCol, label: "U", cls: "text-left px-3 py-2 hidden md:table-cell" },
|
||||
]).map(({ col, label, cls }) => (
|
||||
<th key={col} className={cls}>
|
||||
<button
|
||||
onClick={() => toggleSort(col)}
|
||||
className="font-semibold hover:text-foreground transition-colors flex items-center gap-0.5"
|
||||
>
|
||||
{label}<SortIcon col={col} />
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
<th className="text-left px-3 py-2 font-semibold hidden md:table-cell">IP</th>
|
||||
<th className="text-right px-3 py-2">
|
||||
<button
|
||||
onClick={() => toggleSort("power_draw_w")}
|
||||
className="font-semibold hover:text-foreground transition-colors flex items-center gap-0.5 ml-auto"
|
||||
>
|
||||
Power<SortIcon col="power_draw_w" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left px-3 py-2 font-semibold">Status</th>
|
||||
<th className="text-left px-3 py-2 font-semibold hidden lg:table-cell">Lifecycle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="text-center py-8 text-muted-foreground">
|
||||
No devices match your filters.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map(d => {
|
||||
const ts = TYPE_STYLES[d.type];
|
||||
return (
|
||||
<tr key={d.device_id} className="border-b border-border/40 last:border-0 hover:bg-muted/20 transition-colors cursor-pointer" onClick={() => onRackClick(d.rack_id)}>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium truncate max-w-[180px]">{d.name}</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono">{d.serial}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full shrink-0", ts?.dot ?? "bg-muted")} />
|
||||
<span className="text-muted-foreground">{ts?.label ?? d.type}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono">{d.rack_id.toUpperCase()}</td>
|
||||
<td className="px-3 py-2 hidden sm:table-cell text-muted-foreground">
|
||||
{roomLabels[d.room_id] ?? d.room_id}
|
||||
</td>
|
||||
<td className="px-3 py-2 hidden md:table-cell text-muted-foreground font-mono">
|
||||
U{d.u_start}{d.u_height > 1 ? `–U${d.u_start + d.u_height - 1}` : ""}
|
||||
</td>
|
||||
<td className="px-3 py-2 hidden md:table-cell font-mono text-muted-foreground">
|
||||
{d.ip !== "-" ? d.ip : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-mono">{d.power_draw_w} W</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-green-500/10 text-green-400">
|
||||
● online
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 hidden lg:table-cell">
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full",
|
||||
d.status === "online" ? "bg-blue-500/10 text-blue-400" :
|
||||
d.status === "offline" ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-muted/50 text-muted-foreground"
|
||||
)}>
|
||||
{d.status === "online" ? "Active" : d.status === "offline" ? "Offline" : "Unknown"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── PDU Monitoring ────────────────────────────────────────────────────────────
|
||||
|
||||
function PduMonitoringSection({ siteId }: { siteId: string }) {
|
||||
const [pdus, setPdus] = useState<PduReading[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPduReadings(siteId)
|
||||
.then(setPdus)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
const id = setInterval(() => fetchPduReadings(siteId).then(setPdus).catch(() => {}), 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [siteId]);
|
||||
|
||||
const critical = pdus.filter(p => p.status === "critical").length;
|
||||
const warning = pdus.filter(p => p.status === "warning").length;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-amber-400" /> PDU Phase Monitoring
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2 text-[10px]">
|
||||
{critical > 0 && <span className="text-destructive font-semibold">{critical} critical</span>}
|
||||
{warning > 0 && <span className="text-amber-400 font-semibold">{warning} warning</span>}
|
||||
{critical === 0 && warning === 0 && !loading && (
|
||||
<span className="text-green-400 font-semibold">All balanced</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-4 space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-8 w-full" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30 text-muted-foreground">
|
||||
<th className="text-left px-4 py-2 font-semibold">Rack</th>
|
||||
<th className="text-left px-4 py-2 font-semibold hidden sm:table-cell">Room</th>
|
||||
<th className="text-right px-4 py-2 font-semibold">Total kW</th>
|
||||
<th className="text-right px-4 py-2 font-semibold hidden md:table-cell">Ph-A kW</th>
|
||||
<th className="text-right px-4 py-2 font-semibold hidden md:table-cell">Ph-B kW</th>
|
||||
<th className="text-right px-4 py-2 font-semibold hidden md:table-cell">Ph-C kW</th>
|
||||
<th className="text-right px-4 py-2 font-semibold hidden lg:table-cell">Ph-A A</th>
|
||||
<th className="text-right px-4 py-2 font-semibold hidden lg:table-cell">Ph-B A</th>
|
||||
<th className="text-right px-4 py-2 font-semibold hidden lg:table-cell">Ph-C A</th>
|
||||
<th className="text-right px-4 py-2 font-semibold">Imbalance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{pdus.map(p => (
|
||||
<tr key={p.rack_id} className={cn(
|
||||
"hover:bg-muted/20 transition-colors",
|
||||
p.status === "critical" && "bg-destructive/5",
|
||||
p.status === "warning" && "bg-amber-500/5",
|
||||
)}>
|
||||
<td className="px-4 py-2 font-mono font-medium">{p.rack_id.toUpperCase().replace("RACK-", "")}</td>
|
||||
<td className="px-4 py-2 hidden sm:table-cell text-muted-foreground">
|
||||
{roomLabels[p.room_id] ?? p.room_id}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums font-medium">
|
||||
{p.total_kw !== null ? p.total_kw.toFixed(2) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums hidden md:table-cell text-muted-foreground">
|
||||
{p.phase_a_kw !== null ? p.phase_a_kw.toFixed(2) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums hidden md:table-cell text-muted-foreground">
|
||||
{p.phase_b_kw !== null ? p.phase_b_kw.toFixed(2) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums hidden md:table-cell text-muted-foreground">
|
||||
{p.phase_c_kw !== null ? p.phase_c_kw.toFixed(2) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums hidden lg:table-cell text-muted-foreground">
|
||||
{p.phase_a_a !== null ? p.phase_a_a.toFixed(1) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums hidden lg:table-cell text-muted-foreground">
|
||||
{p.phase_b_a !== null ? p.phase_b_a.toFixed(1) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums hidden lg:table-cell text-muted-foreground">
|
||||
{p.phase_c_a !== null ? p.phase_c_a.toFixed(1) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums">
|
||||
{p.imbalance_pct !== null ? (
|
||||
<span className={cn(
|
||||
"font-semibold",
|
||||
p.status === "critical" ? "text-destructive" :
|
||||
p.status === "warning" ? "text-amber-400" : "text-green-400",
|
||||
)}>
|
||||
{p.imbalance_pct.toFixed(1)}%
|
||||
</span>
|
||||
) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AssetsPage() {
|
||||
const [data, setData] = useState<AssetsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [selectedRack, setSelectedRack] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "warning" | "critical">("all");
|
||||
const [view, setView] = useState<"grid" | "inventory">("grid");
|
||||
|
||||
async function load() {
|
||||
try { const d = await fetchAssets(SITE_ID); setData(d); setError(false); }
|
||||
catch { setError(true); toast.error("Failed to load asset data"); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="h-20" />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="p-6 flex items-center justify-center h-64 text-sm text-muted-foreground">
|
||||
Unable to load asset data.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultTab = data.rooms[0]?.room_id ?? "";
|
||||
const totalRacks = data.rooms.reduce((s, r) => s + r.racks.length, 0);
|
||||
const critCount = data.rooms.flatMap(r => r.racks).filter(r => r.status === "critical").length;
|
||||
const warnCount = data.rooms.flatMap(r => r.racks).filter(r => r.status === "warning").length;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Asset Registry</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Singapore DC01 · {totalRacks} racks
|
||||
{critCount > 0 && <span className="text-destructive ml-2">· {critCount} critical</span>}
|
||||
{warnCount > 0 && <span className="text-amber-400 ml-2">· {warnCount} warning</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center gap-1 rounded-lg border border-border p-1">
|
||||
<button
|
||||
onClick={() => setView("grid")}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors",
|
||||
view === "grid" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<LayoutGrid className="w-3.5 h-3.5" /> Grid
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("inventory")}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors",
|
||||
view === "inventory" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<List className="w-3.5 h-3.5" /> Inventory
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RackDetailSheet siteId={SITE_ID} rackId={selectedRack} onClose={() => setSelectedRack(null)} />
|
||||
|
||||
{view === "inventory" ? (
|
||||
<InventoryTable siteId={SITE_ID} onRackClick={setSelectedRack} />
|
||||
) : (
|
||||
<>
|
||||
{/* Compact UPS + CRAC rows */}
|
||||
<div className="rounded-lg border border-border divide-y divide-border/50">
|
||||
{data.ups_units.map(ups => <UpsRow key={ups.ups_id} ups={ups} />)}
|
||||
{data.rooms.map(room => <CracRow key={room.crac.crac_id} crac={room.crac} />)}
|
||||
</div>
|
||||
|
||||
{/* PDU phase monitoring */}
|
||||
<PduMonitoringSection siteId={SITE_ID} />
|
||||
|
||||
{/* Per-room rack table */}
|
||||
<Tabs defaultValue={defaultTab}>
|
||||
<TabsList>
|
||||
{data.rooms.map(room => (
|
||||
<TabsTrigger key={room.room_id} value={room.room_id}>
|
||||
{roomLabels[room.room_id] ?? room.room_id}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{data.rooms.map(room => {
|
||||
const rWarn = room.racks.filter(r => r.status === "warning").length;
|
||||
const rCrit = room.racks.filter(r => r.status === "critical").length;
|
||||
|
||||
return (
|
||||
<TabsContent key={room.room_id} value={room.room_id} className="space-y-4 mt-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Racks — {roomLabels[room.room_id] ?? room.room_id}
|
||||
</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
{(["all", "warning", "critical"] as const).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setStatusFilter(f)}
|
||||
className={cn(
|
||||
"px-2.5 py-1 rounded-md text-xs font-medium capitalize transition-colors",
|
||||
statusFilter === f
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{f === "all" ? `All (${room.racks.length})`
|
||||
: f === "warning" ? `Warn (${rWarn})`
|
||||
: `Crit (${rCrit})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RackTable
|
||||
racks={room.racks}
|
||||
roomId={room.room_id}
|
||||
statusFilter={statusFilter}
|
||||
onRackClick={setSelectedRack}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
596
frontend/app/(dashboard)/capacity/page.tsx
Normal file
596
frontend/app/(dashboard)/capacity/page.tsx
Normal file
|
|
@ -0,0 +1,596 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { fetchCapacitySummary, type CapacitySummary, type RoomCapacity, type RackCapacity } 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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ReferenceLine, ResponsiveContainer, Cell } from "recharts";
|
||||
import { Zap, Wind, Server, RefreshCw, AlertTriangle, TrendingDown, TrendingUp, Clock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
const ROOM_LABELS: Record<string, string> = { "hall-a": "Hall A", "hall-b": "Hall B" };
|
||||
|
||||
// ── Radial gauge ──────────────────────────────────────────────────────────────
|
||||
|
||||
function RadialGauge({ pct, warn, crit, headroom, unit }: { pct: number; warn: number; crit: number; headroom?: number; unit?: string }) {
|
||||
const r = 36;
|
||||
const circumference = 2 * Math.PI * r;
|
||||
const arc = circumference * 0.75; // 270° sweep
|
||||
const filled = Math.min(pct / 100, 1) * arc;
|
||||
|
||||
const color =
|
||||
pct >= crit ? "#ef4444" :
|
||||
pct >= warn ? "#f59e0b" :
|
||||
"#22c55e";
|
||||
const textColor =
|
||||
pct >= crit ? "text-destructive" :
|
||||
pct >= warn ? "text-amber-400" :
|
||||
"text-green-400";
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center py-1">
|
||||
<svg viewBox="0 0 100 100" className="w-28 h-28 -rotate-[135deg]" style={{ overflow: "visible" }}>
|
||||
{/* Track */}
|
||||
<circle
|
||||
cx="50" cy="50" r={r}
|
||||
fill="none"
|
||||
strokeWidth="9"
|
||||
className="stroke-muted"
|
||||
strokeDasharray={`${arc} ${circumference}`}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Fill */}
|
||||
<circle
|
||||
cx="50" cy="50" r={r}
|
||||
fill="none"
|
||||
strokeWidth="9"
|
||||
stroke={color}
|
||||
strokeDasharray={`${filled} ${circumference}`}
|
||||
strokeLinecap="round"
|
||||
style={{ transition: "stroke-dasharray 0.7s ease" }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute text-center pointer-events-none">
|
||||
<span className={cn("text-3xl font-bold tabular-nums leading-none", textColor)}>
|
||||
{pct.toFixed(1)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">%</span>
|
||||
{headroom !== undefined && unit !== undefined && (
|
||||
<p className="text-[9px] text-muted-foreground leading-tight mt-0.5">
|
||||
{headroom.toFixed(1)} {unit}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Capacity gauge card ────────────────────────────────────────────────────────
|
||||
|
||||
function CapacityGauge({
|
||||
label, used, capacity, unit, pct, headroom, icon: Icon, warn = 70, crit = 85,
|
||||
}: {
|
||||
label: string; used: number; capacity: number; unit: string; pct: number;
|
||||
headroom: number; icon: React.ElementType; warn?: number; crit?: number;
|
||||
}) {
|
||||
const textColor = pct >= crit ? "text-destructive" : pct >= warn ? "text-amber-400" : "text-green-400";
|
||||
const status = pct >= crit ? "Critical" : pct >= warn ? "Warning" : "OK";
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-xl border border-border bg-muted/10 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Icon className="w-4 h-4 text-primary" />
|
||||
{label}
|
||||
</div>
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase", textColor,
|
||||
pct >= crit ? "bg-destructive/10" : pct >= warn ? "bg-amber-500/10" : "bg-green-500/10"
|
||||
)}>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<RadialGauge pct={pct} warn={warn} crit={crit} headroom={headroom} unit={unit} />
|
||||
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span><strong className="text-foreground">{used.toFixed(1)}</strong> {unit} used</span>
|
||||
<span><strong className="text-foreground">{capacity.toFixed(0)}</strong> {unit} rated</span>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"rounded-lg px-3 py-2 text-xs",
|
||||
pct >= crit ? "bg-destructive/10 text-destructive" :
|
||||
pct >= warn ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-green-500/10 text-green-400"
|
||||
)}>
|
||||
{headroom.toFixed(1)} {unit} headroom remaining
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Capacity runway component ──────────────────────────────────────
|
||||
// Assumes ~0.5 kW/week average growth rate to forecast when limits are hit
|
||||
|
||||
const GROWTH_KW_WEEK = 0.5;
|
||||
const WARN_PCT = 85;
|
||||
|
||||
function RunwayCard({ rooms }: { rooms: RoomCapacity[] }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||
Capacity Runway
|
||||
<span className="text-[10px] text-muted-foreground font-normal ml-1">
|
||||
(assuming {GROWTH_KW_WEEK} kW/week growth)
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{rooms.map((room) => {
|
||||
const powerHeadroomToWarn = Math.max(0, room.power.capacity_kw * (WARN_PCT / 100) - room.power.used_kw);
|
||||
const coolHeadroomToWarn = Math.max(0, room.cooling.capacity_kw * (WARN_PCT / 100) - room.cooling.load_kw);
|
||||
const powerRunwayWeeks = Math.round(powerHeadroomToWarn / GROWTH_KW_WEEK);
|
||||
const coolRunwayWeeks = Math.round(coolHeadroomToWarn / GROWTH_KW_WEEK);
|
||||
const constrainedBy = powerRunwayWeeks <= coolRunwayWeeks ? "power" : "cooling";
|
||||
const minRunway = Math.min(powerRunwayWeeks, coolRunwayWeeks);
|
||||
|
||||
const runwayColor =
|
||||
minRunway < 4 ? "text-destructive" :
|
||||
minRunway < 12 ? "text-amber-400" :
|
||||
"text-green-400";
|
||||
|
||||
// N+1 cooling: at 1 CRAC per room, losing it means all load hits chillers/other rooms
|
||||
const n1Margin = room.cooling.capacity_kw - room.cooling.load_kw;
|
||||
const n1Ok = n1Margin > room.cooling.capacity_kw * 0.2; // 20% spare = N+1 safe
|
||||
|
||||
return (
|
||||
<div key={room.room_id} className="rounded-xl border border-border bg-muted/10 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold">{ROOM_LABELS[room.room_id] ?? room.room_id}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase",
|
||||
n1Ok ? "bg-green-500/10 text-green-400" : "bg-amber-500/10 text-amber-400",
|
||||
)}>
|
||||
{n1Ok ? "N+1 OK" : "N+1 marginal"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<TrendingUp className={cn("w-8 h-8 shrink-0", runwayColor)} />
|
||||
<div>
|
||||
<p className={cn("text-2xl font-bold tabular-nums leading-none", runwayColor)}>
|
||||
{minRunway}w
|
||||
</p>
|
||||
<p className={cn("text-xs tabular-nums text-muted-foreground leading-none mt-0.5")}>
|
||||
≈{(minRunway / 4.33).toFixed(1)}mo
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
until {WARN_PCT}% {constrainedBy} limit
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-[11px]">
|
||||
<div className={cn(
|
||||
"rounded-lg px-2.5 py-2",
|
||||
powerRunwayWeeks < 4 ? "bg-destructive/10" :
|
||||
powerRunwayWeeks < 12 ? "bg-amber-500/10" : "bg-muted/30",
|
||||
)}>
|
||||
<p className="text-muted-foreground mb-0.5">Power runway</p>
|
||||
<p className={cn(
|
||||
"font-bold",
|
||||
powerRunwayWeeks < 4 ? "text-destructive" :
|
||||
powerRunwayWeeks < 12 ? "text-amber-400" : "text-green-400",
|
||||
)}>
|
||||
{powerRunwayWeeks}w / ≈{(powerRunwayWeeks / 4.33).toFixed(1)}mo
|
||||
</p>
|
||||
<p className="text-muted-foreground">{powerHeadroomToWarn.toFixed(1)} kW free</p>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"rounded-lg px-2.5 py-2",
|
||||
coolRunwayWeeks < 4 ? "bg-destructive/10" :
|
||||
coolRunwayWeeks < 12 ? "bg-amber-500/10" : "bg-muted/30",
|
||||
)}>
|
||||
<p className="text-muted-foreground mb-0.5">Cooling runway</p>
|
||||
<p className={cn(
|
||||
"font-bold",
|
||||
coolRunwayWeeks < 4 ? "text-destructive" :
|
||||
coolRunwayWeeks < 12 ? "text-amber-400" : "text-green-400",
|
||||
)}>
|
||||
{coolRunwayWeeks}w / ≈{(coolRunwayWeeks / 4.33).toFixed(1)}mo
|
||||
</p>
|
||||
<p className="text-muted-foreground">{coolHeadroomToWarn.toFixed(1)} kW free</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Room summary strip ────────────────────────────────────────────────────────
|
||||
|
||||
function RoomSummaryStrip({ rooms }: { rooms: RoomCapacity[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{rooms.map((room) => {
|
||||
const powerPct = room.power.pct;
|
||||
const coolPct = room.cooling.pct;
|
||||
const worstPct = Math.max(powerPct, coolPct);
|
||||
const worstColor =
|
||||
worstPct >= 85 ? "border-destructive/40 bg-destructive/5" :
|
||||
worstPct >= 70 ? "border-amber-500/40 bg-amber-500/5" :
|
||||
"border-border bg-muted/10";
|
||||
|
||||
return (
|
||||
<div key={room.room_id} className={cn("rounded-xl border px-4 py-3 space-y-2", worstColor)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold">{ROOM_LABELS[room.room_id] ?? room.room_id}</span>
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase",
|
||||
worstPct >= 85 ? "bg-destructive/10 text-destructive" :
|
||||
worstPct >= 70 ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-green-500/10 text-green-400"
|
||||
)}>
|
||||
{worstPct >= 85 ? "Critical" : worstPct >= 70 ? "Warning" : "OK"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-xs">
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Power</p>
|
||||
<p className={cn("font-bold text-sm", powerPct >= 85 ? "text-destructive" : powerPct >= 70 ? "text-amber-400" : "text-green-400")}>
|
||||
{powerPct.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">{room.power.used_kw.toFixed(1)} / {room.power.capacity_kw} kW</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Cooling</p>
|
||||
<p className={cn("font-bold text-sm", coolPct >= 80 ? "text-destructive" : coolPct >= 65 ? "text-amber-400" : "text-green-400")}>
|
||||
{coolPct.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">{room.cooling.load_kw.toFixed(1)} / {room.cooling.capacity_kw} kW</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Space</p>
|
||||
<p className="font-bold text-sm text-foreground">{room.space.racks_populated} / {room.space.racks_total}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{room.space.racks_total - room.space.racks_populated} slots free</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Room capacity section ─────────────────────────────────────────────────────
|
||||
|
||||
function RoomCapacityPanel({ room, racks, config }: {
|
||||
room: RoomCapacity;
|
||||
racks: RackCapacity[];
|
||||
config: CapacitySummary["config"];
|
||||
}) {
|
||||
const roomRacks = racks.filter((r) => r.room_id === room.room_id);
|
||||
|
||||
const chartData = roomRacks
|
||||
.map((r) => ({
|
||||
rack: r.rack_id.replace("rack-", "").toUpperCase(),
|
||||
rack_id: r.rack_id,
|
||||
pct: r.power_pct ?? 0,
|
||||
kw: r.power_kw ?? 0,
|
||||
temp: r.temp,
|
||||
}))
|
||||
.sort((a, b) => b.pct - a.pct);
|
||||
|
||||
const forecastPct = Math.min(100, (chartData.reduce((s, d) => s + d.pct, 0) / Math.max(1, chartData.length)) + (GROWTH_KW_WEEK * 13 / config.rack_power_kw * 100));
|
||||
|
||||
const highLoad = roomRacks.filter((r) => (r.power_pct ?? 0) >= 75);
|
||||
const stranded = roomRacks.filter((r) => r.power_kw !== null && (r.power_pct ?? 0) < 20);
|
||||
const strandedKw = stranded.reduce((s, r) => s + ((config.rack_power_kw - (r.power_kw ?? 0))), 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<CapacityGauge
|
||||
label="Power"
|
||||
used={room.power.used_kw}
|
||||
capacity={room.power.capacity_kw}
|
||||
unit="kW"
|
||||
pct={room.power.pct}
|
||||
headroom={room.power.headroom_kw}
|
||||
icon={Zap}
|
||||
/>
|
||||
<CapacityGauge
|
||||
label="Cooling"
|
||||
used={room.cooling.load_kw}
|
||||
capacity={room.cooling.capacity_kw}
|
||||
unit="kW"
|
||||
pct={room.cooling.pct}
|
||||
headroom={room.cooling.headroom_kw}
|
||||
icon={Wind}
|
||||
warn={65}
|
||||
crit={80}
|
||||
/>
|
||||
<div className="space-y-2 rounded-xl border border-border bg-muted/10 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Server className="w-4 h-4 text-primary" /> Space
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<div className="relative w-28 h-28 flex items-center justify-center">
|
||||
<svg viewBox="0 0 100 100" className="w-28 h-28 -rotate-[135deg]" style={{ overflow: "visible" }}>
|
||||
<circle cx="50" cy="50" r="36" fill="none" strokeWidth="9" className="stroke-muted"
|
||||
strokeDasharray={`${2 * Math.PI * 36 * 0.75} ${2 * Math.PI * 36}`} strokeLinecap="round" />
|
||||
<circle cx="50" cy="50" r="36" fill="none" strokeWidth="9" stroke="oklch(0.62 0.17 212)"
|
||||
strokeDasharray={`${(room.space.pct / 100) * 2 * Math.PI * 36 * 0.75} ${2 * Math.PI * 36}`}
|
||||
strokeLinecap="round" style={{ transition: "stroke-dasharray 0.7s ease" }} />
|
||||
</svg>
|
||||
<div className="absolute text-center">
|
||||
<span className="text-2xl font-bold tabular-nums leading-none text-foreground">
|
||||
{room.space.racks_populated}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">/{room.space.racks_total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span><strong className="text-foreground">{room.space.racks_populated}</strong> active</span>
|
||||
<span><strong className="text-foreground">{room.space.racks_total - room.space.racks_populated}</strong> free</span>
|
||||
</div>
|
||||
<div className="rounded-lg px-3 py-2 text-xs bg-muted/40 text-muted-foreground">
|
||||
Each rack rated {config.rack_u_total}U / {config.rack_power_kw} kW max
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-primary" /> Per-rack Power Utilisation
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chartData.length === 0 ? (
|
||||
<div className="h-48 flex items-center justify-center text-sm text-muted-foreground">
|
||||
No rack data available
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 16, left: -10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="rack"
|
||||
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
|
||||
tickLine={false} axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
|
||||
tickLine={false} axisLine={false}
|
||||
domain={[0, 100]} tickFormatter={(v) => `${v}%`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0/9%)", borderRadius: "6px", fontSize: "12px" }}
|
||||
formatter={(v, _name, props) => [
|
||||
`${Number(v).toFixed(1)}% (${props.payload.kw.toFixed(2)} kW)`, "Power load"
|
||||
]}
|
||||
/>
|
||||
<ReferenceLine y={75} stroke="oklch(0.72 0.18 84)" strokeDasharray="4 4" strokeWidth={1}
|
||||
label={{ value: "Warn 75%", fontSize: 9, fill: "oklch(0.72 0.18 84)", position: "insideTopRight" }} />
|
||||
<ReferenceLine y={90} stroke="oklch(0.55 0.22 25)" strokeDasharray="4 4" strokeWidth={1}
|
||||
label={{ value: "Crit 90%", fontSize: 9, fill: "oklch(0.55 0.22 25)", position: "insideTopRight" }} />
|
||||
<ReferenceLine y={forecastPct} stroke="oklch(0.62 0.17 212)" strokeDasharray="6 3" strokeWidth={1.5}
|
||||
label={{ value: "90d forecast", fontSize: 9, fill: "oklch(0.62 0.17 212)", position: "insideTopLeft" }} />
|
||||
<Bar dataKey="pct" radius={[3, 3, 0, 0]}>
|
||||
{chartData.map((d) => (
|
||||
<Cell
|
||||
key={d.rack_id}
|
||||
fill={
|
||||
d.pct >= 90 ? "oklch(0.55 0.22 25)" :
|
||||
d.pct >= 75 ? "oklch(0.65 0.20 45)" :
|
||||
d.pct >= 50 ? "oklch(0.68 0.14 162)" :
|
||||
"oklch(0.62 0.17 212)"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400" /> High Load Racks
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{highLoad.length === 0 ? (
|
||||
<p className="text-sm text-green-400">All racks within normal limits</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{highLoad.sort((a, b) => (b.power_pct ?? 0) - (a.power_pct ?? 0)).map((r) => (
|
||||
<div key={r.rack_id} className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium">{r.rack_id.toUpperCase()}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{r.power_kw?.toFixed(1)} kW</span>
|
||||
<span className={cn(
|
||||
"font-bold",
|
||||
(r.power_pct ?? 0) >= 90 ? "text-destructive" : "text-amber-400"
|
||||
)}>{r.power_pct?.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<TrendingDown className="w-4 h-4 text-muted-foreground" /> Stranded Capacity
|
||||
</CardTitle>
|
||||
{stranded.length > 0 && (
|
||||
<span className="text-xs font-semibold text-amber-400">
|
||||
{strandedKw.toFixed(1)} kW recoverable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stranded.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No underutilised racks detected</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{stranded.sort((a, b) => (a.power_pct ?? 0) - (b.power_pct ?? 0)).map((r) => (
|
||||
<div key={r.rack_id} className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium">{r.rack_id.toUpperCase()}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{r.power_kw?.toFixed(1)} kW</span>
|
||||
<span className="text-muted-foreground">{r.power_pct?.toFixed(1)}% utilised</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-[10px] text-muted-foreground pt-1">
|
||||
{stranded.length} rack{stranded.length > 1 ? "s" : ""} below 20% — consider consolidation
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CapacityPage() {
|
||||
const [data, setData] = useState<CapacitySummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeRoom, setActiveRoom] = useState("hall-a");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try { setData(await fetchCapacitySummary(SITE_ID)); }
|
||||
catch { toast.error("Failed to load capacity data"); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
const sitePower = data?.rooms.reduce((s, r) => s + r.power.used_kw, 0) ?? 0;
|
||||
const siteCapacity = data?.rooms.reduce((s, r) => s + r.power.capacity_kw, 0) ?? 0;
|
||||
|
||||
return (
|
||||
<PageShell className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Capacity Planning</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — power, cooling & space headroom</p>
|
||||
</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>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-56" />)}
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
) : !data ? (
|
||||
<div className="flex items-center justify-center h-64 text-sm text-muted-foreground">
|
||||
Unable to load capacity data.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Site summary banner */}
|
||||
<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">Site IT load</span>
|
||||
{" "}
|
||||
<strong className="text-foreground text-base">{sitePower.toFixed(1)} kW</strong>
|
||||
<span className="text-muted-foreground"> / {siteCapacity.toFixed(0)} kW rated</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Site load</span>
|
||||
{" "}
|
||||
<strong className={cn(
|
||||
"text-base",
|
||||
(sitePower / siteCapacity * 100) >= 85 ? "text-destructive" :
|
||||
(sitePower / siteCapacity * 100) >= 70 ? "text-amber-400" : "text-green-400"
|
||||
)}>
|
||||
{(sitePower / siteCapacity * 100).toFixed(1)}%
|
||||
</strong>
|
||||
</div>
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
Capacity config: {data.config.rack_power_kw} kW/rack ·{" "}
|
||||
{data.config.crac_cooling_kw} kW CRAC ·{" "}
|
||||
{data.config.rack_u_total}U/rack
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Room comparison strip */}
|
||||
<RoomSummaryStrip rooms={data.rooms} />
|
||||
|
||||
{/* Capacity runway + N+1 */}
|
||||
<RunwayCard rooms={data.rooms} />
|
||||
|
||||
{/* Per-room detail tabs */}
|
||||
<div>
|
||||
<Tabs value={activeRoom} onValueChange={setActiveRoom}>
|
||||
<TabsList>
|
||||
{data.rooms.map((r) => (
|
||||
<TabsTrigger key={r.room_id} value={r.room_id}>
|
||||
{ROOM_LABELS[r.room_id] ?? r.room_id}
|
||||
{r.power.pct >= 85 && (
|
||||
<AlertTriangle className="w-3 h-3 ml-1.5 text-destructive" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-6">
|
||||
{data.rooms
|
||||
.filter((r) => r.room_id === activeRoom)
|
||||
.map((room) => (
|
||||
<RoomCapacityPanel
|
||||
key={room.room_id}
|
||||
room={room}
|
||||
racks={data.racks}
|
||||
config={data.config}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
610
frontend/app/(dashboard)/cooling/page.tsx
Normal file
610
frontend/app/(dashboard)/cooling/page.tsx
Normal file
|
|
@ -0,0 +1,610 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { fetchCracStatus, fetchChillerStatus, type CracStatus, type ChillerStatus } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { CracDetailSheet } from "@/components/dashboard/crac-detail-sheet";
|
||||
import {
|
||||
Wind, AlertTriangle, CheckCircle2, Zap, ChevronRight, ArrowRight, Waves, Filter,
|
||||
ChevronUp, ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
const roomLabels: Record<string, string> = { "hall-a": "Hall A", "hall-b": "Hall B" };
|
||||
|
||||
function fmt(v: number | null | undefined, dec = 1, unit = "") {
|
||||
if (v == null) return "—";
|
||||
return `${v.toFixed(dec)}${unit}`;
|
||||
}
|
||||
|
||||
function FillBar({
|
||||
value, max, color, warn, crit, height = "h-2",
|
||||
}: {
|
||||
value: number | null; max: number; color: string;
|
||||
warn?: number; crit?: number; height?: string;
|
||||
}) {
|
||||
const pct = value != null ? Math.min(100, (value / max) * 100) : 0;
|
||||
const barColor =
|
||||
crit && value != null && value >= crit ? "#ef4444" :
|
||||
warn && value != null && value >= warn ? "#f59e0b" :
|
||||
color;
|
||||
return (
|
||||
<div className={cn("rounded-full bg-muted overflow-hidden w-full", height)}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%`, backgroundColor: barColor }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiTile({ label, value, sub, warn }: {
|
||||
label: string; value: string; sub?: string; warn?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-muted/30 rounded-lg px-4 py-3 flex-1 min-w-0">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">{label}</p>
|
||||
<p className={cn("text-xl font-bold tabular-nums", warn && "text-amber-400")}>{value}</p>
|
||||
{sub && <p className="text-[10px] text-muted-foreground mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CracCard({ crac, onOpen }: { crac: CracStatus; onOpen: () => void }) {
|
||||
const [showCompressor, setShowCompressor] = useState(false);
|
||||
const online = crac.state === "online";
|
||||
|
||||
const deltaWarn = (crac.delta ?? 0) > 11;
|
||||
const deltaCrit = (crac.delta ?? 0) > 14;
|
||||
const capWarn = (crac.cooling_capacity_pct ?? 0) > 75;
|
||||
const capCrit = (crac.cooling_capacity_pct ?? 0) > 90;
|
||||
const copWarn = (crac.cop ?? 99) < 1.5;
|
||||
const filterWarn = (crac.filter_dp_pa ?? 0) > 80;
|
||||
const filterCrit = (crac.filter_dp_pa ?? 0) > 120;
|
||||
const compWarn = (crac.compressor_load_pct ?? 0) > 95;
|
||||
const hiPWarn = (crac.high_pressure_bar ?? 0) > 22;
|
||||
const loPWarn = (crac.low_pressure_bar ?? 99) < 3;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"border cursor-pointer hover:border-primary/50 transition-colors",
|
||||
!online && "border-destructive/40",
|
||||
)}
|
||||
onClick={onOpen}
|
||||
>
|
||||
{/* ── Header ───────────────────────────────────────────────── */}
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wind className={cn("w-4 h-4", online ? "text-primary" : "text-destructive")} />
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold leading-none">
|
||||
{crac.crac_id.toUpperCase()}
|
||||
</CardTitle>
|
||||
{crac.room_id && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{roomLabels[crac.room_id] ?? crac.room_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{online && (
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
|
||||
deltaCrit || capCrit ? "bg-destructive/10 text-destructive" :
|
||||
deltaWarn || capWarn || filterWarn || copWarn ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-green-500/10 text-green-400",
|
||||
)}>
|
||||
{deltaCrit || capCrit ? "Critical" : deltaWarn || capWarn || filterWarn || copWarn ? "Warning" : "Normal"}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
"flex items-center gap-1 text-[10px] font-semibold px-2.5 py-1 rounded-full uppercase tracking-wide",
|
||||
online ? "bg-green-500/10 text-green-400" : "bg-destructive/10 text-destructive",
|
||||
)}>
|
||||
{online
|
||||
? <><CheckCircle2 className="w-3 h-3" /> Online</>
|
||||
: <><AlertTriangle className="w-3 h-3" /> Fault</>}
|
||||
</span>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{!online ? (
|
||||
<div className="rounded-md px-3 py-2 text-xs bg-destructive/10 text-destructive">
|
||||
Unit offline — cooling capacity in this room is degraded.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* ── Thermal hero ─────────────────────────────────────── */}
|
||||
<div className="rounded-lg bg-muted/20 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Supply</p>
|
||||
<p className="text-3xl font-bold tabular-nums text-blue-400">
|
||||
{fmt(crac.supply_temp, 1)}°C
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center gap-1 px-4">
|
||||
<p className={cn(
|
||||
"text-base font-bold tabular-nums",
|
||||
deltaCrit ? "text-destructive" : deltaWarn ? "text-amber-400" : "text-muted-foreground",
|
||||
)}>
|
||||
ΔT {fmt(crac.delta, 1)}°C
|
||||
</p>
|
||||
<div className="flex items-center gap-1 w-full">
|
||||
<div className="flex-1 h-px bg-muted-foreground/30" />
|
||||
<ArrowRight className="w-3 h-3 text-muted-foreground/50 shrink-0" />
|
||||
<div className="flex-1 h-px bg-muted-foreground/30" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Return</p>
|
||||
<p className={cn(
|
||||
"text-3xl font-bold tabular-nums",
|
||||
deltaCrit ? "text-destructive" : deltaWarn ? "text-amber-400" : "text-orange-400",
|
||||
)}>
|
||||
{fmt(crac.return_temp, 1)}°C
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Cooling capacity ─────────────────────────────────── */}
|
||||
<div>
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">Cooling Capacity</span>
|
||||
<span className="text-xs font-mono">
|
||||
<span className={cn(capCrit ? "text-destructive" : capWarn ? "text-amber-400" : "text-foreground")}>
|
||||
{fmt(crac.cooling_capacity_kw, 1)} / {crac.rated_capacity_kw} kW
|
||||
</span>
|
||||
<span className="text-muted-foreground mx-1.5">·</span>
|
||||
<span className={cn(copWarn ? "text-amber-400" : "text-foreground")}>
|
||||
COP {fmt(crac.cop, 2)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={crac.cooling_capacity_pct} max={100} color="#34d399" warn={75} crit={90} />
|
||||
<p className={cn(
|
||||
"text-[10px] mt-1 text-right",
|
||||
capCrit ? "text-destructive" : capWarn ? "text-amber-400" : "text-muted-foreground",
|
||||
)}>
|
||||
{fmt(crac.cooling_capacity_pct, 1)}% utilised
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Fan + Filter ─────────────────────────────────────── */}
|
||||
<div className="space-y-2.5">
|
||||
<div>
|
||||
<div className="flex justify-between text-[10px] mb-1">
|
||||
<span className="text-muted-foreground">Fan</span>
|
||||
<span className="font-mono text-foreground">
|
||||
{fmt(crac.fan_pct, 1)}%
|
||||
{crac.fan_rpm != null ? ` · ${Math.round(crac.fan_rpm).toLocaleString()} rpm` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={crac.fan_pct} max={100} color="#60a5fa" height="h-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-[10px] mb-1">
|
||||
<span className="text-muted-foreground">Filter ΔP</span>
|
||||
<span className={cn(
|
||||
"font-mono",
|
||||
filterCrit ? "text-destructive" : filterWarn ? "text-amber-400" : "text-foreground",
|
||||
)}>
|
||||
{fmt(crac.filter_dp_pa, 0)} Pa
|
||||
{!filterWarn && <span className="text-green-400 ml-1.5">✓</span>}
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={crac.filter_dp_pa} max={150} color="#94a3b8" warn={80} crit={120} height="h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Compressor (collapsible) ─────────────────────────── */}
|
||||
<div className="rounded-md bg-muted/20 px-3 py-2.5 space-y-2">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowCompressor(!showCompressor); }}
|
||||
className="flex items-center justify-between w-full text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span className="uppercase tracking-wide font-semibold">Compressor</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-mono">{fmt(crac.compressor_load_pct, 1)}% · {fmt(crac.compressor_power_kw, 2)} kW</span>
|
||||
{showCompressor ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
</span>
|
||||
</button>
|
||||
{showCompressor && (
|
||||
<div className="pt-2 space-y-2">
|
||||
<FillBar value={crac.compressor_load_pct} max={100} color="#e879f9" warn={80} crit={95} height="h-1.5" />
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground pt-0.5">
|
||||
<span className={cn(hiPWarn ? "text-destructive" : "")}>
|
||||
Hi {fmt(crac.high_pressure_bar, 1)} bar
|
||||
</span>
|
||||
<span className={cn(loPWarn ? "text-destructive" : "")}>
|
||||
Lo {fmt(crac.low_pressure_bar, 2)} bar
|
||||
</span>
|
||||
<span>SH {fmt(crac.discharge_superheat_c, 1)}°C</span>
|
||||
<span>SC {fmt(crac.liquid_subcooling_c, 1)}°C</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Electrical (one line) ────────────────────────────── */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground border-t border-border/30 pt-3">
|
||||
<Zap className="w-3 h-3 shrink-0" />
|
||||
<span className="font-mono font-medium text-foreground">{fmt(crac.total_unit_power_kw, 2)} kW</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono">{fmt(crac.input_voltage_v, 0)} V</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono">{fmt(crac.input_current_a, 1)} A</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono">PF {fmt(crac.power_factor, 3)}</span>
|
||||
</div>
|
||||
|
||||
{/* ── Status banner ────────────────────────────────────── */}
|
||||
<div className={cn(
|
||||
"rounded-md px-3 py-2 text-xs",
|
||||
deltaCrit || capCrit
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: deltaWarn || capWarn || filterWarn || copWarn
|
||||
? "bg-amber-500/10 text-amber-400"
|
||||
: "bg-green-500/10 text-green-400",
|
||||
)}>
|
||||
{deltaCrit || capCrit
|
||||
? "Heat load is high — check airflow or redistribute rack density."
|
||||
: deltaWarn || capWarn
|
||||
? "Heat load is elevated — monitor for further rises."
|
||||
: filterWarn
|
||||
? "Filter requires attention — airflow may be restricted."
|
||||
: copWarn
|
||||
? "Running inefficiently — check refrigerant charge."
|
||||
: "Operating efficiently within normal parameters."}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Filter replacement estimate ────────────────────────────────────
|
||||
// Assumes ~1.2 Pa/day rate of rise — replace at 120 Pa threshold
|
||||
|
||||
const FILTER_REPLACE_PA = 120;
|
||||
const FILTER_RATE_PA_DAY = 1.2;
|
||||
|
||||
function FilterEstimate({ cracs }: { cracs: CracStatus[] }) {
|
||||
const units = cracs
|
||||
.filter((c) => c.state === "online" && c.filter_dp_pa != null)
|
||||
.map((c) => {
|
||||
const dp = c.filter_dp_pa!;
|
||||
const days = Math.max(0, Math.round((FILTER_REPLACE_PA - dp) / FILTER_RATE_PA_DAY));
|
||||
const urgent = dp >= 120;
|
||||
const warn = dp >= 80;
|
||||
return { crac_id: c.crac_id, dp, days, urgent, warn };
|
||||
})
|
||||
.sort((a, b) => a.days - b.days);
|
||||
|
||||
if (units.length === 0) return null;
|
||||
|
||||
const anyUrgent = units.some((u) => u.urgent);
|
||||
const anyWarn = units.some((u) => u.warn);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-muted-foreground" />
|
||||
Predictive Filter Replacement
|
||||
</CardTitle>
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase",
|
||||
anyUrgent ? "bg-destructive/10 text-destructive" :
|
||||
anyWarn ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-green-500/10 text-green-400",
|
||||
)}>
|
||||
{anyUrgent ? "Overdue" : anyWarn ? "Attention needed" : "All filters OK"}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{units.map((u) => (
|
||||
<div key={u.crac_id}>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="font-medium">{u.crac_id.toUpperCase()}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={cn(
|
||||
"font-mono",
|
||||
u.urgent ? "text-destructive" : u.warn ? "text-amber-400" : "text-muted-foreground",
|
||||
)}>
|
||||
{u.dp} Pa
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-[10px] px-2 py-0.5 rounded-full font-semibold",
|
||||
u.urgent ? "bg-destructive/10 text-destructive" :
|
||||
u.warn ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-green-500/10 text-green-400",
|
||||
)}>
|
||||
{u.urgent ? "Replace now" : `~${u.days}d`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full bg-muted overflow-hidden h-1.5">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.min(100, (u.dp / FILTER_REPLACE_PA) * 100)}%`,
|
||||
backgroundColor: u.urgent ? "#ef4444" : u.warn ? "#f59e0b" : "#94a3b8",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-[10px] text-muted-foreground pt-1">
|
||||
Estimated at {FILTER_RATE_PA_DAY} Pa/day increase · replace at {FILTER_REPLACE_PA} Pa threshold
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Chiller card ──────────────────────────────────────────────────
|
||||
|
||||
function ChillerCard({ chiller }: { chiller: ChillerStatus }) {
|
||||
const online = chiller.state === "online";
|
||||
const loadWarn = (chiller.cooling_load_pct ?? 0) > 80;
|
||||
|
||||
return (
|
||||
<Card className={cn("border", !online && "border-destructive/40")}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Waves className="w-4 h-4 text-blue-400" />
|
||||
{chiller.chiller_id.toUpperCase()} — Chiller Plant
|
||||
</CardTitle>
|
||||
<span className={cn("text-[10px] font-semibold px-2.5 py-1 rounded-full uppercase tracking-wide",
|
||||
online ? "bg-green-500/10 text-green-400" : "bg-destructive/10 text-destructive",
|
||||
)}>
|
||||
{online ? <><CheckCircle2 className="w-3 h-3 inline mr-0.5" /> Online</> : <><AlertTriangle className="w-3 h-3 inline mr-0.5" /> Fault</>}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!online ? (
|
||||
<div className="rounded-md px-3 py-2 text-xs bg-destructive/10 text-destructive">
|
||||
Chiller fault — CHW supply lost. CRAC/CRAH units relying on local refrigerant circuits only.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* CHW temps */}
|
||||
<div className="rounded-lg bg-muted/20 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">CHW Supply</p>
|
||||
<p className="text-2xl font-bold tabular-nums text-blue-400">{fmt(chiller.chw_supply_c, 1)}°C</p>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center gap-1 px-4">
|
||||
<p className="text-sm font-bold tabular-nums text-muted-foreground">ΔT {fmt(chiller.chw_delta_c, 1)}°C</p>
|
||||
<div className="flex items-center gap-1 w-full">
|
||||
<div className="flex-1 h-px bg-muted-foreground/30" />
|
||||
<ArrowRight className="w-3 h-3 text-muted-foreground/50 shrink-0" />
|
||||
<div className="flex-1 h-px bg-muted-foreground/30" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">CHW Return</p>
|
||||
<p className="text-2xl font-bold tabular-nums text-orange-400">{fmt(chiller.chw_return_c, 1)}°C</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Load */}
|
||||
<div>
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">Cooling Load</span>
|
||||
<span className="text-xs font-mono">
|
||||
<span className={cn(loadWarn ? "text-amber-400" : "")}>{fmt(chiller.cooling_load_kw, 1)} kW</span>
|
||||
<span className="text-muted-foreground mx-1.5">·</span>
|
||||
<span>COP {fmt(chiller.cop, 2)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={chiller.cooling_load_pct} max={100} color="#34d399" warn={80} crit={95} />
|
||||
<p className="text-[10px] mt-1 text-right text-muted-foreground">{fmt(chiller.cooling_load_pct, 1)}% load</p>
|
||||
</div>
|
||||
{/* Details */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px]">
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Flow rate</span><span className="font-mono">{fmt(chiller.flow_gpm, 0)} GPM</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Comp load</span><span className="font-mono">{fmt(chiller.compressor_load_pct, 1)}%</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Cond press</span><span className="font-mono">{fmt(chiller.condenser_pressure_bar, 2)} bar</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Evap press</span><span className="font-mono">{fmt(chiller.evaporator_pressure_bar, 2)} bar</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">CW supply</span><span className="font-mono">{fmt(chiller.cw_supply_c, 1)}°C</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">CW return</span><span className="font-mono">{fmt(chiller.cw_return_c, 1)}°C</span></div>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground border-t border-border/30 pt-2">
|
||||
Run hours: <strong className="text-foreground">{chiller.run_hours != null ? chiller.run_hours.toFixed(0) : "—"} h</strong>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CoolingPage() {
|
||||
const [cracs, setCracs] = useState<CracStatus[]>([]);
|
||||
const [chillers, setChillers] = useState<ChillerStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedCrac, setSelected] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [c, ch] = await Promise.all([
|
||||
fetchCracStatus(SITE_ID),
|
||||
fetchChillerStatus(SITE_ID).catch(() => []),
|
||||
]);
|
||||
setCracs(c);
|
||||
setChillers(ch);
|
||||
}
|
||||
catch { toast.error("Failed to load cooling data"); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
const online = cracs.filter(c => c.state === "online");
|
||||
const anyFaulted = cracs.some(c => c.state === "fault");
|
||||
const totalCoolingKw = online.reduce((s, c) => s + (c.cooling_capacity_kw ?? 0), 0);
|
||||
const totalRatedKw = cracs.reduce((s, c) => s + (c.rated_capacity_kw ?? 0), 0);
|
||||
const copUnits = online.filter(c => c.cop != null);
|
||||
const avgCop = copUnits.length > 0
|
||||
? copUnits.reduce((s, c) => s + (c.cop ?? 0), 0) / copUnits.length
|
||||
: null;
|
||||
const totalUnitPower = online.reduce((s, c) => s + (c.total_unit_power_kw ?? 0), 0);
|
||||
const totalAirflowCfm = online.reduce((s, c) => s + (c.airflow_cfm ?? 0), 0);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* ── Page header ───────────────────────────────────────────── */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Cooling Systems</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Singapore DC01 · click a unit to drill down · refreshes every 30s
|
||||
</p>
|
||||
</div>
|
||||
{!loading && (
|
||||
<span className={cn(
|
||||
"flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-full",
|
||||
anyFaulted ? "bg-destructive/10 text-destructive" : "bg-green-500/10 text-green-400",
|
||||
)}>
|
||||
{anyFaulted
|
||||
? <><AlertTriangle className="w-3.5 h-3.5" /> Cooling fault detected</>
|
||||
: <><CheckCircle2 className="w-3.5 h-3.5" /> All {cracs.length} units operational</>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Filter alert banner ───────────────────────────────────── */}
|
||||
{!loading && (() => {
|
||||
const urgent = cracs
|
||||
.filter(c => c.state === "online" && c.filter_dp_pa != null)
|
||||
.map(c => ({ id: c.crac_id, days: Math.max(0, Math.round((120 - c.filter_dp_pa!) / 1.2)) }))
|
||||
.filter(c => c.days < 14)
|
||||
.sort((a, b) => a.days - b.days);
|
||||
if (urgent.length === 0) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm">
|
||||
<Filter className="w-4 h-4 text-amber-400 shrink-0" />
|
||||
<span className="text-amber-400">
|
||||
<strong>Filter replacement due:</strong>{" "}
|
||||
{urgent.map(u => `${u.id.toUpperCase()} in ${u.days === 0 ? "now" : `~${u.days}d`}`).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* ── Fleet summary KPI cards ───────────────────────────────── */}
|
||||
{loading && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-20" />)}
|
||||
</div>
|
||||
)}
|
||||
{!loading && cracs.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Cooling Load</p>
|
||||
<p className="text-2xl font-bold tabular-nums">{totalCoolingKw.toFixed(1)} kW</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">of {totalRatedKw} kW rated</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Avg COP</p>
|
||||
<p className={cn("text-2xl font-bold tabular-nums", avgCop != null && avgCop < 1.5 && "text-amber-400")}>
|
||||
{avgCop != null ? avgCop.toFixed(2) : "—"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Unit Power Draw</p>
|
||||
<p className="text-2xl font-bold tabular-nums">{totalUnitPower.toFixed(1)} kW</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">total electrical input</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Units Online</p>
|
||||
<p className={cn("text-2xl font-bold tabular-nums", anyFaulted && "text-amber-400")}>
|
||||
{online.length} / {cracs.length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{totalAirflowCfm > 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Total Airflow</p>
|
||||
<p className="text-2xl font-bold tabular-nums">{Math.round(totalAirflowCfm).toLocaleString()}</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">CFM combined output</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Chiller plant ─────────────────────────────────────────── */}
|
||||
{(loading || chillers.length > 0) && (
|
||||
<>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">Chiller Plant</h2>
|
||||
{loading ? (
|
||||
<Skeleton className="h-56" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{chillers.map(ch => <ChillerCard key={ch.chiller_id} chiller={ch} />)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Filter health (moved before CRAC cards) ───────────────── */}
|
||||
{!loading && <FilterEstimate cracs={cracs} />}
|
||||
|
||||
{/* ── CRAC cards ────────────────────────────────────────────── */}
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">CRAC / CRAH Units</h2>
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Skeleton className="h-72" />
|
||||
<Skeleton className="h-72" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{cracs.map(crac => (
|
||||
<CracCard key={crac.crac_id} crac={crac} onOpen={() => setSelected(crac.crac_id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CracDetailSheet
|
||||
siteId={SITE_ID}
|
||||
cracId={selectedCrac}
|
||||
onClose={() => setSelected(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
846
frontend/app/(dashboard)/environmental/page.tsx
Normal file
846
frontend/app/(dashboard)/environmental/page.tsx
Normal file
|
|
@ -0,0 +1,846 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
fetchRackEnvReadings, fetchHumidityHistory, fetchTempHistory as fetchRoomTempHistory,
|
||||
fetchCracStatus, fetchLeakStatus, fetchFireStatus, fetchParticleStatus,
|
||||
type RoomEnvReadings, type HumidityBucket, type TempBucket, type CracStatus,
|
||||
type LeakSensorStatus, type FireZoneStatus, type ParticleStatus,
|
||||
} from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useThresholds } from "@/lib/threshold-context";
|
||||
import { TimeRangePicker } from "@/components/ui/time-range-picker";
|
||||
import {
|
||||
ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, ReferenceLine, ReferenceArea,
|
||||
} from "recharts";
|
||||
import { Thermometer, Droplets, WifiOff, CheckCircle2, AlertTriangle, Flame, Wind } from "lucide-react";
|
||||
import { RackDetailSheet } from "@/components/dashboard/rack-detail-sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
const ROOM_COLORS: Record<string, { temp: string; hum: string }> = {
|
||||
"hall-a": { temp: "oklch(0.62 0.17 212)", hum: "oklch(0.55 0.18 270)" },
|
||||
"hall-b": { temp: "oklch(0.7 0.15 162)", hum: "oklch(0.60 0.15 145)" },
|
||||
};
|
||||
const roomLabels: Record<string, string> = { "hall-a": "Hall A", "hall-b": "Hall B" };
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
// ── Utility functions ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Magnus formula dew point (°C) from temperature (°C) and relative humidity (%) */
|
||||
function dewPoint(temp: number, rh: number): number {
|
||||
const gamma = Math.log(rh / 100) + (17.625 * temp) / (243.04 + temp);
|
||||
return Math.round((243.04 * gamma / (17.625 - gamma)) * 10) / 10;
|
||||
}
|
||||
|
||||
function humidityColor(hum: number | null): string {
|
||||
if (hum === null) return "oklch(0.25 0.02 265)";
|
||||
if (hum > 80) return "oklch(0.55 0.22 25)"; // critical high
|
||||
if (hum > 65) return "oklch(0.65 0.20 45)"; // warning high
|
||||
if (hum > 50) return "oklch(0.72 0.18 84)"; // elevated
|
||||
if (hum >= 30) return "oklch(0.68 0.14 162)"; // optimal
|
||||
return "oklch(0.62 0.17 212)"; // low (static risk)
|
||||
}
|
||||
|
||||
// ── Temperature heatmap ───────────────────────────────────────────────────────
|
||||
|
||||
function tempColor(temp: number | null, warn = 26, crit = 28): string {
|
||||
if (temp === null) return "oklch(0.25 0.02 265)";
|
||||
if (temp >= crit + 4) return "oklch(0.55 0.22 25)";
|
||||
if (temp >= crit) return "oklch(0.65 0.20 45)";
|
||||
if (temp >= warn) return "oklch(0.72 0.18 84)";
|
||||
if (temp >= warn - 2) return "oklch(0.78 0.14 140)";
|
||||
if (temp >= warn - 4) return "oklch(0.68 0.14 162)";
|
||||
return "oklch(0.60 0.15 212)";
|
||||
}
|
||||
|
||||
type HeatmapOverlay = "temp" | "humidity";
|
||||
|
||||
function TempHeatmap({
|
||||
rooms, onRackClick, activeRoom, tempWarn = 26, tempCrit = 28, humWarn = 65, humCrit = 80,
|
||||
}: {
|
||||
rooms: RoomEnvReadings[];
|
||||
onRackClick: (rackId: string) => void;
|
||||
activeRoom: string;
|
||||
tempWarn?: number;
|
||||
tempCrit?: number;
|
||||
humWarn?: number;
|
||||
humCrit?: number;
|
||||
}) {
|
||||
const [overlay, setOverlay] = useState<HeatmapOverlay>("temp");
|
||||
const room = rooms.find((r) => r.room_id === activeRoom);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
{overlay === "temp"
|
||||
? <Thermometer className="w-4 h-4 text-primary" />
|
||||
: <Droplets className="w-4 h-4 text-blue-400" />}
|
||||
{overlay === "temp" ? "Temperature" : "Humidity"} Heatmap
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Overlay toggle */}
|
||||
<div className="flex items-center gap-0.5 rounded-md border border-border p-0.5">
|
||||
<button
|
||||
onClick={() => setOverlay("temp")}
|
||||
aria-label="Temperature overlay"
|
||||
aria-pressed={overlay === "temp"}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
||||
overlay === "temp" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Thermometer className="w-3 h-3" /> Temp
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOverlay("humidity")}
|
||||
aria-label="Humidity overlay"
|
||||
aria-pressed={overlay === "humidity"}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
||||
overlay === "humidity" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Droplets className="w-3 h-3" /> Humidity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Callout — hottest or most humid */}
|
||||
{(() => {
|
||||
if (overlay === "temp") {
|
||||
const hottest = room?.racks.reduce((a, b) =>
|
||||
(a.temperature ?? 0) > (b.temperature ?? 0) ? a : b
|
||||
);
|
||||
if (!hottest || hottest.temperature === null) return null;
|
||||
const isHot = hottest.temperature >= tempWarn;
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-3 py-2 text-xs",
|
||||
hottest.temperature >= tempCrit ? "bg-destructive/10 text-destructive" :
|
||||
isHot ? "bg-amber-500/10 text-amber-400" : "bg-muted/40 text-muted-foreground"
|
||||
)}>
|
||||
<Thermometer className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>
|
||||
Hottest: <strong>{hottest.rack_id.toUpperCase()}</strong> at <strong>{hottest.temperature}°C</strong>
|
||||
{hottest.temperature >= tempCrit ? " — above critical threshold" : isHot ? " — above warning threshold" : " — within normal range"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const humid = room?.racks.reduce((a, b) =>
|
||||
(a.humidity ?? 0) > (b.humidity ?? 0) ? a : b
|
||||
);
|
||||
if (!humid || humid.humidity === null) return null;
|
||||
const isHigh = humid.humidity > humWarn;
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-3 py-2 text-xs",
|
||||
humid.humidity > humCrit ? "bg-destructive/10 text-destructive" :
|
||||
isHigh ? "bg-amber-500/10 text-amber-400" : "bg-muted/40 text-muted-foreground"
|
||||
)}>
|
||||
<Droplets className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>
|
||||
Highest humidity: <strong>{humid.rack_id.toUpperCase()}</strong> at <strong>{humid.humidity}%</strong>
|
||||
{humid.humidity > humCrit ? " — above critical threshold" : isHigh ? " — above warning threshold" : " — within normal range"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
|
||||
{/* Rack grid */}
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{room?.racks.map((rack) => {
|
||||
const offline = rack.temperature === null && rack.humidity === null;
|
||||
const bg = overlay === "temp" ? tempColor(rack.temperature, tempWarn, tempCrit) : humidityColor(rack.humidity);
|
||||
const mainVal = overlay === "temp"
|
||||
? (rack.temperature !== null ? `${rack.temperature}°` : null)
|
||||
: (rack.humidity !== null ? `${rack.humidity}%` : null);
|
||||
const subVal = overlay === "temp"
|
||||
? (rack.humidity !== null && rack.temperature !== null
|
||||
? `DP ${dewPoint(rack.temperature, rack.humidity)}°` : null)
|
||||
: (rack.temperature !== null ? `${rack.temperature}°C` : null);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={rack.rack_id}
|
||||
onClick={() => onRackClick(rack.rack_id)}
|
||||
className={cn(
|
||||
"relative rounded-lg p-3 flex flex-col items-center justify-center gap-0.5 min-h-[72px] transition-all cursor-pointer hover:ring-2 hover:ring-white/20",
|
||||
offline ? "hover:opacity-70" : "hover:opacity-80"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: offline ? "oklch(0.22 0.02 265)" : bg,
|
||||
backgroundImage: offline
|
||||
? "repeating-linear-gradient(45deg, transparent, transparent 4px, oklch(1 0 0 / 4%) 4px, oklch(1 0 0 / 4%) 8px)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px] font-semibold text-white/70">
|
||||
{rack.rack_id.replace("rack-", "").toUpperCase()}
|
||||
</span>
|
||||
{offline ? (
|
||||
<WifiOff className="w-3.5 h-3.5 text-white/40" />
|
||||
) : (
|
||||
<span className="text-base font-bold text-white">{mainVal ?? "—"}</span>
|
||||
)}
|
||||
{subVal && (
|
||||
<span className="text-[10px] text-white/60">{subVal}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
{overlay === "temp" ? (
|
||||
<>
|
||||
<span>Cool</span>
|
||||
{(["oklch(0.60 0.15 212)", "oklch(0.68 0.14 162)", "oklch(0.78 0.14 140)", "oklch(0.72 0.18 84)", "oklch(0.65 0.20 45)", "oklch(0.55 0.22 25)"] as string[]).map((c, i) => (
|
||||
<span key={i} className="w-6 h-3 rounded-sm inline-block" style={{ backgroundColor: c }} />
|
||||
))}
|
||||
<span>Hot</span>
|
||||
<span className="ml-auto">Warn: {tempWarn}°C | Crit: {tempCrit}°C · Tiles show dew point (DP)</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Dry</span>
|
||||
{(["oklch(0.62 0.17 212)", "oklch(0.68 0.14 162)", "oklch(0.72 0.18 84)", "oklch(0.65 0.20 45)", "oklch(0.55 0.22 25)"] as string[]).map((c, i) => (
|
||||
<span key={i} className="w-6 h-3 rounded-sm inline-block" style={{ backgroundColor: c }} />
|
||||
))}
|
||||
<span>Humid</span>
|
||||
<span className="ml-auto">Optimal: 30–65% | ASHRAE A1 max: 80%</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Dual-axis trend chart ─────────────────────────────────────────────────────
|
||||
|
||||
function EnvTrendChart({
|
||||
tempData, humData, hours, activeRoom, tempWarn = 26, tempCrit = 28, humWarn = 65,
|
||||
}: {
|
||||
tempData: TempBucket[];
|
||||
humData: HumidityBucket[];
|
||||
hours: number;
|
||||
activeRoom: string;
|
||||
tempWarn?: number;
|
||||
tempCrit?: number;
|
||||
humWarn?: number;
|
||||
}) {
|
||||
const roomIds = [...new Set(tempData.map((d) => d.room_id))].sort();
|
||||
|
||||
// Build combined rows for the active room
|
||||
type ComboRow = { time: string; temp: number | null; hum: number | null };
|
||||
const buckets = new Map<string, ComboRow>();
|
||||
|
||||
for (const d of tempData.filter((d) => d.room_id === activeRoom)) {
|
||||
const time = formatTime(d.bucket);
|
||||
if (!buckets.has(time)) buckets.set(time, { time, temp: null, hum: null });
|
||||
buckets.get(time)!.temp = d.avg_temp;
|
||||
}
|
||||
for (const d of humData.filter((d) => d.room_id === activeRoom)) {
|
||||
const time = formatTime(d.bucket);
|
||||
if (!buckets.has(time)) buckets.set(time, { time, temp: null, hum: null });
|
||||
buckets.get(time)!.hum = d.avg_humidity;
|
||||
}
|
||||
const chartData = Array.from(buckets.values());
|
||||
const colors = ROOM_COLORS[activeRoom] ?? ROOM_COLORS["hall-a"];
|
||||
|
||||
const labelSuffix = hours <= 1 ? "1h" : hours <= 6 ? "6h" : hours <= 24 ? "24h" : "7d";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Thermometer className="w-4 h-4 text-primary" />
|
||||
<Droplets className="w-4 h-4 text-blue-400" />
|
||||
Temp & Humidity — last {labelSuffix}
|
||||
</CardTitle>
|
||||
</div>
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-4 text-[10px] text-muted-foreground mt-1 flex-wrap">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-4 h-0.5 inline-block rounded" style={{ backgroundColor: colors.temp }} />
|
||||
Temp (°C, left axis)
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-4 h-0.5 inline-block rounded border-t-2 border-dashed" style={{ borderColor: colors.hum }} />
|
||||
Humidity (%, right axis)
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 ml-auto">
|
||||
<span className="w-4 h-3 inline-block rounded opacity-40" style={{ backgroundColor: "#22c55e" }} />
|
||||
ASHRAE A1 safe zone
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chartData.length === 0 ? (
|
||||
<div className="h-[240px] flex items-center justify-center text-sm text-muted-foreground">
|
||||
Waiting for data...
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<ComposedChart data={chartData} margin={{ top: 4, right: 30, left: -16, bottom: 0 }}>
|
||||
{/* ASHRAE A1 safe zones */}
|
||||
<ReferenceArea yAxisId="temp" y1={18} y2={27} fill="#22c55e" fillOpacity={0.07} />
|
||||
<ReferenceArea yAxisId="hum" y1={20} y2={80} fill="#3b82f6" fillOpacity={0.05} />
|
||||
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
{/* Left axis — temperature */}
|
||||
<YAxis
|
||||
yAxisId="temp"
|
||||
domain={[16, 36]}
|
||||
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) => `${v}°`}
|
||||
/>
|
||||
{/* Right axis — humidity */}
|
||||
<YAxis
|
||||
yAxisId="hum"
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "12px" }}
|
||||
formatter={(v, name) =>
|
||||
name === "temp" ? [`${Number(v).toFixed(1)}°C`, "Temperature"] :
|
||||
[`${Number(v).toFixed(0)}%`, "Humidity"]
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Temp reference lines */}
|
||||
<ReferenceLine yAxisId="temp" y={tempWarn} stroke="oklch(0.72 0.18 84)" strokeDasharray="4 4" strokeWidth={1}
|
||||
label={{ value: `Warn ${tempWarn}°`, fontSize: 9, fill: "oklch(0.72 0.18 84)", position: "right" }} />
|
||||
<ReferenceLine yAxisId="temp" y={tempCrit} stroke="oklch(0.55 0.22 25)" strokeDasharray="4 4" strokeWidth={1}
|
||||
label={{ value: `Crit ${tempCrit}°`, fontSize: 9, fill: "oklch(0.55 0.22 25)", position: "right" }} />
|
||||
|
||||
{/* Humidity reference line */}
|
||||
<ReferenceLine yAxisId="hum" y={humWarn} stroke="oklch(0.62 0.17 212)" strokeDasharray="4 4" strokeWidth={1} />
|
||||
|
||||
{/* Lines */}
|
||||
<Line
|
||||
yAxisId="temp"
|
||||
type="monotone"
|
||||
dataKey="temp"
|
||||
stroke={colors.temp}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls
|
||||
/>
|
||||
<Line
|
||||
yAxisId="hum"
|
||||
type="monotone"
|
||||
dataKey="hum"
|
||||
stroke={colors.hum}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="6 3"
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground mt-2">
|
||||
Green shaded band = ASHRAE A1 thermal envelope (18–27°C / 20–80% RH)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ASHRAE A1 Compliance Table ────────────────────────────────────────────────
|
||||
|
||||
// ASHRAE A1: 15–32°C, 20–80% RH
|
||||
function AshraeTable({ rooms }: { rooms: RoomEnvReadings[] }) {
|
||||
const allRacks = rooms.flatMap(r =>
|
||||
r.racks.map(rack => ({ ...rack, room_id: r.room_id }))
|
||||
).filter(r => r.temperature !== null || r.humidity !== null);
|
||||
|
||||
type Issue = { type: string; detail: string };
|
||||
const rows = allRacks.map(rack => {
|
||||
const issues: Issue[] = [];
|
||||
if (rack.temperature !== null) {
|
||||
if (rack.temperature < 15) issues.push({ type: "Temp", detail: `${rack.temperature}°C — below 15°C min` });
|
||||
if (rack.temperature > 32) issues.push({ type: "Temp", detail: `${rack.temperature}°C — above 32°C max` });
|
||||
}
|
||||
if (rack.humidity !== null) {
|
||||
if (rack.humidity < 20) issues.push({ type: "RH", detail: `${rack.humidity}% — below 20% min` });
|
||||
if (rack.humidity > 80) issues.push({ type: "RH", detail: `${rack.humidity}% — above 80% max` });
|
||||
}
|
||||
const dp = rack.temperature !== null && rack.humidity !== null
|
||||
? dewPoint(rack.temperature, rack.humidity) : null;
|
||||
return { rack, issues, dp };
|
||||
});
|
||||
|
||||
const violations = rows.filter(r => r.issues.length > 0);
|
||||
const compliant = rows.filter(r => r.issues.length === 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" /> ASHRAE A1 Compliance
|
||||
</CardTitle>
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-2 py-0.5 rounded-full",
|
||||
violations.length > 0 ? "bg-destructive/10 text-destructive" : "bg-green-500/10 text-green-400"
|
||||
)}>
|
||||
{violations.length === 0 ? `All ${compliant.length} racks compliant` : `${violations.length} violation${violations.length > 1 ? "s" : ""}`}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{violations.length === 0 ? (
|
||||
<p className="text-sm text-green-400 flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
All racks within ASHRAE A1 envelope (15–32°C, 20–80% RH)
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{violations.map(({ rack, issues, dp }) => (
|
||||
<div key={rack.rack_id} className="flex items-start gap-3 rounded-lg bg-destructive/5 border border-destructive/20 px-3 py-2 text-xs">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-destructive mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<span className="font-semibold">{rack.rack_id.toUpperCase()}</span>
|
||||
<span className="text-muted-foreground ml-2">{roomLabels[rack.room_id] ?? rack.room_id}</span>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-0.5 mt-0.5 text-destructive">
|
||||
{issues.map((iss, i) => <span key={i}>{iss.type}: {iss.detail}</span>)}
|
||||
{dp !== null && <span className="text-muted-foreground">DP: {dp}°C</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-[10px] text-muted-foreground pt-1">
|
||||
ASHRAE A1 envelope: 15–32°C dry bulb, 20–80% relative humidity
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Dew Point Panel ───────────────────────────────────────────────────────────
|
||||
|
||||
function DewPointPanel({
|
||||
rooms, cracs, activeRoom,
|
||||
}: {
|
||||
rooms: RoomEnvReadings[];
|
||||
cracs: CracStatus[];
|
||||
activeRoom: string;
|
||||
}) {
|
||||
const room = rooms.find(r => r.room_id === activeRoom);
|
||||
const crac = cracs.find(c => c.room_id === activeRoom);
|
||||
const supplyTemp = crac?.supply_temp ?? null;
|
||||
|
||||
const rackDps = (room?.racks ?? [])
|
||||
.filter(r => r.temperature !== null && r.humidity !== null)
|
||||
.map(r => ({
|
||||
rack_id: r.rack_id,
|
||||
dp: dewPoint(r.temperature!, r.humidity!),
|
||||
temp: r.temperature!,
|
||||
hum: r.humidity!,
|
||||
}))
|
||||
.sort((a, b) => b.dp - a.dp);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Droplets className="w-4 h-4 text-blue-400" /> Dew Point by Rack
|
||||
</CardTitle>
|
||||
{supplyTemp !== null && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
CRAC supply: <strong className="text-blue-400">{supplyTemp}°C</strong>
|
||||
{rackDps.some(r => r.dp >= supplyTemp - 1) && (
|
||||
<span className="text-destructive ml-1">— condensation risk!</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{rackDps.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No data available</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{rackDps.map(({ rack_id, dp, temp, hum }) => {
|
||||
const nearCondensation = supplyTemp !== null && dp >= supplyTemp - 1;
|
||||
const dpColor = nearCondensation ? "text-destructive"
|
||||
: dp > 15 ? "text-amber-400" : "text-foreground";
|
||||
return (
|
||||
<div key={rack_id} className="flex items-center gap-3 text-xs">
|
||||
<span className="font-mono w-16 shrink-0 text-muted-foreground">
|
||||
{rack_id.replace("rack-", "").toUpperCase()}
|
||||
</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all", nearCondensation ? "bg-destructive" : dp > 15 ? "bg-amber-500" : "bg-blue-500")}
|
||||
style={{ width: `${Math.min(100, Math.max(0, (dp / 30) * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={cn("font-mono font-semibold w-16 text-right", dpColor)}>
|
||||
{dp}°C DP
|
||||
</span>
|
||||
<span className="text-muted-foreground w-20 text-right hidden sm:block">
|
||||
{temp}° / {hum}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-[10px] text-muted-foreground pt-1">
|
||||
Dew point approaching CRAC supply temp = condensation risk on cold surfaces
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Leak sensor panel ─────────────────────────────────────────────────────────
|
||||
|
||||
function LeakPanel({ sensors }: { sensors: LeakSensorStatus[] }) {
|
||||
const detected = sensors.filter(s => s.state === "detected");
|
||||
const anyDetected = detected.length > 0;
|
||||
|
||||
return (
|
||||
<Card className={cn("border", anyDetected && "border-destructive/50")}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Droplets className="w-4 h-4 text-blue-400" /> Water / Leak Detection
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/leak" className="text-[10px] text-primary hover:underline">View full page →</Link>
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
|
||||
anyDetected ? "bg-destructive/10 text-destructive animate-pulse" : "bg-green-500/10 text-green-400",
|
||||
)}>
|
||||
{anyDetected ? `${detected.length} leak detected` : "All clear"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{sensors.map(s => {
|
||||
const detected = s.state === "detected";
|
||||
return (
|
||||
<div key={s.sensor_id} className={cn(
|
||||
"flex items-start justify-between rounded-lg px-3 py-2 text-xs",
|
||||
detected ? "bg-destructive/10" : "bg-muted/30",
|
||||
)}>
|
||||
<div>
|
||||
<p className={cn("font-semibold", detected ? "text-destructive" : "text-foreground")}>
|
||||
{detected ? <AlertTriangle className="w-3 h-3 inline mr-1" /> : <CheckCircle2 className="w-3 h-3 inline mr-1 text-green-400" />}
|
||||
{s.sensor_id}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-0.5">
|
||||
Zone: {s.floor_zone}
|
||||
{s.under_floor ? " · under-floor" : ""}
|
||||
{s.near_crac ? " · near CRAC" : ""}
|
||||
{s.room_id ? ` · ${s.room_id}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<span className={cn("font-semibold shrink-0 ml-2", detected ? "text-destructive" : "text-green-400")}>
|
||||
{s.state === "detected" ? "DETECTED" : s.state === "clear" ? "Clear" : "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sensors.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">No sensors configured</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── VESDA / Fire panel ────────────────────────────────────────────────────────
|
||||
|
||||
const VESDA_LEVEL_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
||||
normal: { label: "Normal", color: "text-green-400", bg: "bg-green-500/10" },
|
||||
alert: { label: "Alert", color: "text-amber-400", bg: "bg-amber-500/10" },
|
||||
action: { label: "Action", color: "text-orange-400", bg: "bg-orange-500/10" },
|
||||
fire: { label: "FIRE", color: "text-destructive", bg: "bg-destructive/10" },
|
||||
};
|
||||
|
||||
function FirePanel({ zones }: { zones: FireZoneStatus[] }) {
|
||||
const elevated = zones.filter(z => z.level !== "normal");
|
||||
|
||||
return (
|
||||
<Card className={cn("border", elevated.length > 0 && elevated.some(z => z.level === "fire") && "border-destructive/50")}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Flame className="w-4 h-4 text-orange-400" /> VESDA / Smoke Detection
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/fire" className="text-[10px] text-primary hover:underline">View full page →</Link>
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
|
||||
elevated.length === 0 ? "bg-green-500/10 text-green-400" :
|
||||
zones.some(z => z.level === "fire") ? "bg-destructive/10 text-destructive animate-pulse" :
|
||||
"bg-amber-500/10 text-amber-400",
|
||||
)}>
|
||||
{elevated.length === 0 ? "All normal" : `${elevated.length} zone${elevated.length !== 1 ? "s" : ""} elevated`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{zones.map(zone => {
|
||||
const cfg = VESDA_LEVEL_CONFIG[zone.level] ?? VESDA_LEVEL_CONFIG.normal;
|
||||
return (
|
||||
<div key={zone.zone_id} className={cn("rounded-lg px-3 py-2 text-xs", cfg.bg)}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div>
|
||||
<p className="font-semibold">{zone.zone_id}</p>
|
||||
{zone.room_id && <p className="text-muted-foreground">{zone.room_id}</p>}
|
||||
</div>
|
||||
<span className={cn("font-bold text-sm uppercase", cfg.color)}>{cfg.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
Obscuration: <strong className={cn(zone.level !== "normal" ? cfg.color : "")}>{zone.obscuration_pct_m != null ? `${zone.obscuration_pct_m.toFixed(3)} %/m` : "—"}</strong>
|
||||
</span>
|
||||
<div className="flex gap-2 text-[10px]">
|
||||
{!zone.detector_1_ok && <span className="text-destructive">Det1 fault</span>}
|
||||
{!zone.detector_2_ok && <span className="text-destructive">Det2 fault</span>}
|
||||
{!zone.power_ok && <span className="text-destructive">Power fault</span>}
|
||||
{!zone.flow_ok && <span className="text-destructive">Flow fault</span>}
|
||||
{zone.detector_1_ok && zone.detector_2_ok && zone.power_ok && zone.flow_ok && (
|
||||
<span className="text-green-400">Systems OK</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{zones.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">No VESDA zones configured</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Particle count panel (ISO 14644) ──────────────────────────────────────────
|
||||
|
||||
const ISO_LABELS: Record<number, { label: string; color: string }> = {
|
||||
5: { label: "ISO 5", color: "text-green-400" },
|
||||
6: { label: "ISO 6", color: "text-green-400" },
|
||||
7: { label: "ISO 7", color: "text-green-400" },
|
||||
8: { label: "ISO 8", color: "text-amber-400" },
|
||||
9: { label: "ISO 9", color: "text-destructive" },
|
||||
};
|
||||
|
||||
const ISO8_0_5UM = 3_520_000;
|
||||
const ISO8_5UM = 29_300;
|
||||
|
||||
function ParticlePanel({ rooms }: { rooms: ParticleStatus[] }) {
|
||||
if (rooms.length === 0) return null;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Wind className="w-4 h-4 text-primary" />
|
||||
Air Quality — ISO 14644
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{rooms.map(r => {
|
||||
const cls = r.iso_class ? ISO_LABELS[r.iso_class] : null;
|
||||
const p05pct = r.particles_0_5um !== null ? Math.min(100, (r.particles_0_5um / ISO8_0_5UM) * 100) : null;
|
||||
const p5pct = r.particles_5um !== null ? Math.min(100, (r.particles_5um / ISO8_5UM) * 100) : null;
|
||||
return (
|
||||
<div key={r.room_id} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{r.room_id === "hall-a" ? "Hall A" : r.room_id === "hall-b" ? "Hall B" : r.room_id}</span>
|
||||
{cls ? (
|
||||
<span className={cn("text-xs font-semibold px-2 py-0.5 rounded-full bg-muted/40", cls.color)}>
|
||||
{cls.label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">No data</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-28 shrink-0">≥0.5 µm</span>
|
||||
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
|
||||
{p05pct !== null && (
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all", p05pct >= 100 ? "bg-destructive" : p05pct >= 70 ? "bg-amber-500" : "bg-green-500")}
|
||||
style={{ width: `${p05pct}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="w-32 text-right font-mono">
|
||||
{r.particles_0_5um !== null ? r.particles_0_5um.toLocaleString() : "—"} /m³
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-28 shrink-0">≥5 µm</span>
|
||||
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
|
||||
{p5pct !== null && (
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all", p5pct >= 100 ? "bg-destructive" : p5pct >= 70 ? "bg-amber-500" : "bg-green-500")}
|
||||
style={{ width: `${p5pct}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="w-32 text-right font-mono">
|
||||
{r.particles_5um !== null ? r.particles_5um.toLocaleString() : "—"} /m³
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-[10px] text-muted-foreground pt-1">
|
||||
DC target: ISO 8 (≤3,520,000 particles ≥0.5 µm/m³ · ≤29,300 ≥5 µm/m³)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function EnvironmentalPage() {
|
||||
const { thresholds } = useThresholds();
|
||||
const [rooms, setRooms] = useState<RoomEnvReadings[]>([]);
|
||||
const [tempHist, setTempHist] = useState<TempBucket[]>([]);
|
||||
const [humHist, setHumHist] = useState<HumidityBucket[]>([]);
|
||||
const [cracs, setCracs] = useState<CracStatus[]>([]);
|
||||
const [leakSensors, setLeak] = useState<LeakSensorStatus[]>([]);
|
||||
const [fireZones, setFire] = useState<FireZoneStatus[]>([]);
|
||||
const [particles, setParticles] = useState<ParticleStatus[]>([]);
|
||||
const [hours, setHours] = useState(6);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedRack, setSelectedRack] = useState<string | null>(null);
|
||||
const [activeRoom, setActiveRoom] = useState("hall-a");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [r, t, h, c, l, f, p] = await Promise.all([
|
||||
fetchRackEnvReadings(SITE_ID),
|
||||
fetchRoomTempHistory(SITE_ID, hours),
|
||||
fetchHumidityHistory(SITE_ID, hours),
|
||||
fetchCracStatus(SITE_ID),
|
||||
fetchLeakStatus(SITE_ID).catch(() => []),
|
||||
fetchFireStatus(SITE_ID).catch(() => []),
|
||||
fetchParticleStatus(SITE_ID).catch(() => []),
|
||||
]);
|
||||
setRooms(r);
|
||||
setTempHist(t);
|
||||
setHumHist(h);
|
||||
setCracs(c);
|
||||
setLeak(l);
|
||||
setFire(f);
|
||||
setParticles(p);
|
||||
} catch {
|
||||
toast.error("Failed to load environmental data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [hours]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Environmental Monitoring</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — refreshes every 30s</p>
|
||||
</div>
|
||||
<TimeRangePicker value={hours} onChange={setHours} />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<RackDetailSheet siteId={SITE_ID} rackId={selectedRack} onClose={() => setSelectedRack(null)} />
|
||||
|
||||
{/* Page-level room tab selector */}
|
||||
{rooms.length > 0 && (
|
||||
<Tabs value={activeRoom} onValueChange={setActiveRoom}>
|
||||
<TabsList>
|
||||
{rooms.map(r => (
|
||||
<TabsTrigger key={r.room_id} value={r.room_id} className="px-6">
|
||||
{roomLabels[r.room_id] ?? r.room_id}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{rooms.length > 0 && (
|
||||
<TempHeatmap rooms={rooms} onRackClick={setSelectedRack} activeRoom={activeRoom}
|
||||
tempWarn={thresholds.temp.warn} tempCrit={thresholds.temp.critical}
|
||||
humWarn={thresholds.humidity.warn} humCrit={thresholds.humidity.critical} />
|
||||
)}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{rooms.length > 0 && <AshraeTable rooms={rooms} />}
|
||||
{rooms.length > 0 && (
|
||||
<DewPointPanel rooms={rooms} cracs={cracs} activeRoom={activeRoom} />
|
||||
)}
|
||||
</div>
|
||||
{/* Leak + VESDA panels */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<LeakPanel sensors={leakSensors} />
|
||||
<FirePanel zones={fireZones} />
|
||||
</div>
|
||||
<EnvTrendChart tempData={tempHist} humData={humHist} hours={hours} activeRoom={activeRoom}
|
||||
tempWarn={thresholds.temp.warn} tempCrit={thresholds.temp.critical}
|
||||
humWarn={thresholds.humidity.warn} />
|
||||
<ParticlePanel rooms={particles} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
285
frontend/app/(dashboard)/fire/page.tsx
Normal file
285
frontend/app/(dashboard)/fire/page.tsx
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { fetchFireStatus, type FireZoneStatus } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Flame, RefreshCw, CheckCircle2, AlertTriangle, Zap, Wind, Activity } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
const LEVEL_CONFIG: Record<string, {
|
||||
label: string; bg: string; border: string; text: string; icon: React.ElementType; pulsing: boolean;
|
||||
}> = {
|
||||
normal: {
|
||||
label: "Normal",
|
||||
bg: "bg-green-500/10", border: "border-green-500/20", text: "text-green-400",
|
||||
icon: CheckCircle2, pulsing: false,
|
||||
},
|
||||
alert: {
|
||||
label: "Alert",
|
||||
bg: "bg-amber-500/10", border: "border-amber-500/40", text: "text-amber-400",
|
||||
icon: AlertTriangle, pulsing: false,
|
||||
},
|
||||
action: {
|
||||
label: "Action",
|
||||
bg: "bg-orange-500/10", border: "border-orange-500/40", text: "text-orange-400",
|
||||
icon: AlertTriangle, pulsing: true,
|
||||
},
|
||||
fire: {
|
||||
label: "FIRE",
|
||||
bg: "bg-destructive/10", border: "border-destructive/60", text: "text-destructive",
|
||||
icon: Flame, pulsing: true,
|
||||
},
|
||||
};
|
||||
|
||||
function ObscurationBar({ value }: { value: number | null }) {
|
||||
if (value == null) return null;
|
||||
const pct = Math.min(100, value * 20); // 0–5 %/m mapped to 0–100%
|
||||
const color = value > 3 ? "#ef4444" : value > 1.5 ? "#f59e0b" : "#94a3b8";
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between text-[10px] mb-1">
|
||||
<span className="text-muted-foreground">Obscuration</span>
|
||||
<span className="font-mono font-semibold text-xs" style={{ color }}>{value.toFixed(2)} %/m</span>
|
||||
</div>
|
||||
<div className="rounded-full bg-muted overflow-hidden h-1.5">
|
||||
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${pct}%`, backgroundColor: color }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIndicator({ label, ok, icon: Icon }: {
|
||||
label: string; ok: boolean; icon: React.ElementType;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"rounded-lg px-2.5 py-2 flex items-center gap-2 text-xs",
|
||||
ok ? "bg-green-500/10" : "bg-destructive/10",
|
||||
)}>
|
||||
<Icon className={cn("w-3.5 h-3.5 shrink-0", ok ? "text-green-400" : "text-destructive")} />
|
||||
<div>
|
||||
<p className="text-muted-foreground text-[10px]">{label}</p>
|
||||
<p className={cn("font-semibold", ok ? "text-green-400" : "text-destructive")}>
|
||||
{ok ? "OK" : "Fault"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VesdaCard({ zone }: { zone: FireZoneStatus }) {
|
||||
const level = zone.level;
|
||||
const cfg = LEVEL_CONFIG[level] ?? LEVEL_CONFIG.normal;
|
||||
const Icon = cfg.icon;
|
||||
const isAlarm = level !== "normal";
|
||||
|
||||
return (
|
||||
<Card className={cn(isAlarm ? "border-2" : "border", cfg.border, isAlarm && cfg.bg, level === "fire" && "bg-red-950/30")}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Icon className={cn("w-4 h-4", cfg.text, cfg.pulsing && "animate-pulse")} />
|
||||
{zone.zone_id.toUpperCase()}
|
||||
</CardTitle>
|
||||
<span className={cn(
|
||||
"flex items-center gap-1 text-[10px] font-bold px-2.5 py-0.5 rounded-full uppercase tracking-wide border",
|
||||
cfg.bg, cfg.border, cfg.text,
|
||||
)}>
|
||||
<Icon className={cn("w-3 h-3", cfg.pulsing && "animate-pulse")} />
|
||||
{cfg.label}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{level === "fire" && (
|
||||
<div className="rounded-lg border border-destructive/60 bg-destructive/15 px-3 py-3 text-xs text-destructive font-semibold animate-pulse">
|
||||
FIRE ALARM — Initiate evacuation and contact emergency services immediately
|
||||
</div>
|
||||
)}
|
||||
{level === "action" && (
|
||||
<div className="rounded-lg border border-orange-500/40 bg-orange-500/10 px-3 py-2.5 text-xs text-orange-400 font-medium">
|
||||
Action threshold reached — investigate smoke source immediately
|
||||
</div>
|
||||
)}
|
||||
{level === "alert" && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 text-xs text-amber-400">
|
||||
Alert level — elevated smoke particles detected, monitor closely
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ObscurationBar value={zone.obscuration_pct_m} />
|
||||
|
||||
{/* Detector status */}
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ label: "Detector 1", ok: zone.detector_1_ok },
|
||||
{ label: "Detector 2", ok: zone.detector_2_ok },
|
||||
].map(({ label, ok }) => (
|
||||
<div key={label} className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
{ok ? <CheckCircle2 className="w-3.5 h-3.5 text-green-400" /> : <AlertTriangle className="w-3.5 h-3.5 text-destructive" />}
|
||||
{label}
|
||||
</span>
|
||||
<span className={cn("font-semibold", ok ? "text-green-400" : "text-destructive")}>
|
||||
{ok ? "Online" : "Fault"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* System status */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<StatusIndicator label="Power supply" ok={zone.power_ok} icon={Zap} />
|
||||
<StatusIndicator label="Airflow" ok={zone.flow_ok} icon={Wind} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FireSafetyPage() {
|
||||
const [zones, setZones] = useState<FireZoneStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setZones(await fetchFireStatus(SITE_ID));
|
||||
} catch { toast.error("Failed to load fire safety data"); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 10_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
const fireZones = zones.filter((z) => z.level === "fire");
|
||||
const actionZones = zones.filter((z) => z.level === "action");
|
||||
const alertZones = zones.filter((z) => z.level === "alert");
|
||||
const normalZones = zones.filter((z) => z.level === "normal");
|
||||
const anyAlarm = fireZones.length + actionZones.length + alertZones.length > 0;
|
||||
|
||||
const worstLevel =
|
||||
fireZones.length > 0 ? "fire" :
|
||||
actionZones.length > 0 ? "action" :
|
||||
alertZones.length > 0 ? "alert" : "normal";
|
||||
const worstCfg = LEVEL_CONFIG[worstLevel];
|
||||
const WIcon = worstCfg.icon;
|
||||
|
||||
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">Fire & Life Safety</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — VESDA aspirating detector network · refreshes every 10s</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!loading && (
|
||||
<span className={cn(
|
||||
"flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-full border",
|
||||
worstCfg.bg, worstCfg.border, worstCfg.text,
|
||||
anyAlarm && "animate-pulse",
|
||||
)}>
|
||||
<WIcon className="w-3.5 h-3.5" />
|
||||
{anyAlarm
|
||||
? `${fireZones.length + actionZones.length + alertZones.length} zone${fireZones.length + actionZones.length + alertZones.length > 1 ? "s" : ""} in alarm`
|
||||
: `All ${zones.length} zones normal`}
|
||||
</span>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{/* System summary bar */}
|
||||
{!loading && (
|
||||
<div className="rounded-xl border border-border bg-muted/10 px-5 py-3 flex items-center gap-8 text-sm flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-primary" />
|
||||
<span className="text-muted-foreground">VESDA zones monitored:</span>
|
||||
<strong>{zones.length}</strong>
|
||||
</div>
|
||||
{[
|
||||
{ label: "Fire", count: fireZones.length, cls: "text-destructive" },
|
||||
{ label: "Action", count: actionZones.length, cls: "text-orange-400" },
|
||||
{ label: "Alert", count: alertZones.length, cls: "text-amber-400" },
|
||||
{ label: "Normal", count: normalZones.length, cls: "text-green-400" },
|
||||
].map(({ label, count, cls }) => (
|
||||
<div key={label} className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">{label}:</span>
|
||||
<strong className={cls}>{count}</strong>
|
||||
</div>
|
||||
))}
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
All detectors use VESDA aspirating smoke detection technology
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fire alarm banner */}
|
||||
{!loading && fireZones.length > 0 && (
|
||||
<div className="rounded-xl border-2 border-destructive bg-destructive/10 px-5 py-4 animate-pulse">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Flame className="w-6 h-6 text-destructive shrink-0" />
|
||||
<p className="text-base font-bold text-destructive">
|
||||
FIRE ALARM ACTIVE — {fireZones.length} zone{fireZones.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-destructive/80">
|
||||
Initiate building evacuation. Contact SCDF (995). Do not re-enter until cleared by fire services.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zone cards — alarms first */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-64" />)}
|
||||
</div>
|
||||
) : zones.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No VESDA zone data available</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{[...fireZones, ...actionZones, ...alertZones, ...normalZones].map((zone) => (
|
||||
<VesdaCard key={zone.zone_id} zone={zone} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="rounded-xl border border-border bg-muted/5 px-5 py-4">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-widest mb-3">VESDA Alert Levels</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
|
||||
{Object.entries(LEVEL_CONFIG).map(([key, cfg]) => {
|
||||
const Icon = cfg.icon;
|
||||
return (
|
||||
<div key={key} className={cn("rounded-lg border px-3 py-2.5", cfg.bg, cfg.border)}>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Icon className={cn("w-3.5 h-3.5", cfg.text)} />
|
||||
<span className={cn("font-bold uppercase", cfg.text)}>{cfg.label}</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
{key === "normal" ? "No smoke detected, system clear" :
|
||||
key === "alert" ? "Trace smoke particles, monitor" :
|
||||
key === "action" ? "Significant smoke, investigate now" :
|
||||
"Confirmed fire, evacuate immediately"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
963
frontend/app/(dashboard)/floor-map/page.tsx
Normal file
963
frontend/app/(dashboard)/floor-map/page.tsx
Normal file
|
|
@ -0,0 +1,963 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
|
||||
import {
|
||||
fetchCapacitySummary, fetchCracStatus, fetchAlarms, fetchLeakStatus,
|
||||
fetchFloorLayout, saveFloorLayout,
|
||||
type CapacitySummary, type CracStatus, type Alarm, type LeakSensorStatus,
|
||||
} from "@/lib/api";
|
||||
import { RackDetailSheet } from "@/components/dashboard/rack-detail-sheet";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Wind, WifiOff, Thermometer, Zap, CheckCircle2, AlertTriangle,
|
||||
Droplets, Cable, Flame, Snowflake, Settings2, Plus, Trash2,
|
||||
GripVertical, ChevronDown, ChevronUp,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useThresholds } from "@/lib/threshold-context";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
// ── Layout type ───────────────────────────────────────────────────────────────
|
||||
|
||||
type RowLayout = { label: string; racks: string[] };
|
||||
type RoomLayout = { label: string; crac_id: string; rows: RowLayout[] };
|
||||
type FloorLayout = Record<string, RoomLayout>;
|
||||
|
||||
const DEFAULT_LAYOUT: FloorLayout = {
|
||||
"hall-a": {
|
||||
label: "Hall A",
|
||||
crac_id: "crac-01",
|
||||
rows: [
|
||||
{ label: "Row 1", racks: Array.from({ length: 20 }, (_, i) => `SG1A01.${String(i + 1).padStart(2, "0")}`) },
|
||||
{ label: "Row 2", racks: Array.from({ length: 20 }, (_, i) => `SG1A02.${String(i + 1).padStart(2, "0")}`) },
|
||||
],
|
||||
},
|
||||
"hall-b": {
|
||||
label: "Hall B",
|
||||
crac_id: "crac-02",
|
||||
rows: [
|
||||
{ label: "Row 1", racks: Array.from({ length: 20 }, (_, i) => `SG1B01.${String(i + 1).padStart(2, "0")}`) },
|
||||
{ label: "Row 2", racks: Array.from({ length: 20 }, (_, i) => `SG1B02.${String(i + 1).padStart(2, "0")}`) },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Derive feed from row index: even index → "A", odd → "B"
|
||||
function getFeed(layout: FloorLayout, rackId: string): "A" | "B" | undefined {
|
||||
for (const room of Object.values(layout)) {
|
||||
for (let i = 0; i < room.rows.length; i++) {
|
||||
if (room.rows[i].racks.includes(rackId)) return i % 2 === 0 ? "A" : "B";
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── Colour helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function tempBg(temp: number | null, warn = 26, crit = 28) {
|
||||
if (temp === null) return "oklch(0.22 0.02 265)";
|
||||
if (temp >= crit + 4) return "oklch(0.55 0.22 25)";
|
||||
if (temp >= crit) return "oklch(0.65 0.20 45)";
|
||||
if (temp >= warn) return "oklch(0.72 0.18 84)";
|
||||
if (temp >= warn - 2) return "oklch(0.78 0.14 140)";
|
||||
if (temp >= warn - 4) return "oklch(0.68 0.14 162)";
|
||||
return "oklch(0.60 0.15 212)";
|
||||
}
|
||||
|
||||
function powerBg(pct: number | null) {
|
||||
if (pct === null) return "oklch(0.22 0.02 265)";
|
||||
if (pct >= 90) return "oklch(0.55 0.22 25)";
|
||||
if (pct >= 75) return "oklch(0.65 0.20 45)";
|
||||
if (pct >= 55) return "oklch(0.72 0.18 84)";
|
||||
if (pct >= 35) return "oklch(0.68 0.14 162)";
|
||||
return "oklch(0.60 0.15 212)";
|
||||
}
|
||||
|
||||
type Overlay = "temp" | "power" | "alarms" | "feed" | "crac";
|
||||
|
||||
function alarmBg(count: number): string {
|
||||
if (count === 0) return "oklch(0.22 0.02 265)";
|
||||
if (count >= 3) return "oklch(0.55 0.22 25)";
|
||||
if (count >= 1) return "oklch(0.65 0.20 45)";
|
||||
return "oklch(0.68 0.14 162)";
|
||||
}
|
||||
|
||||
function feedBg(feed: "A" | "B" | undefined): string {
|
||||
if (feed === "A") return "oklch(0.55 0.18 255)";
|
||||
if (feed === "B") return "oklch(0.60 0.18 40)";
|
||||
return "oklch(0.22 0.02 265)";
|
||||
}
|
||||
|
||||
const CRAC_ZONE_COLORS = [
|
||||
"oklch(0.55 0.18 255)", // blue — zone 1
|
||||
"oklch(0.60 0.18 40)", // amber — zone 2
|
||||
"oklch(0.60 0.16 145)", // teal — zone 3
|
||||
"oklch(0.58 0.18 310)", // purple — zone 4
|
||||
];
|
||||
|
||||
// ── Rack tile ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function RackTile({
|
||||
rackId, temp, powerPct, alarmCount, overlay, feed, cracColor, onClick, tempWarn = 26, tempCrit = 28,
|
||||
}: {
|
||||
rackId: string; temp: number | null; powerPct: number | null;
|
||||
alarmCount: number; overlay: Overlay; feed?: "A" | "B"; cracColor?: string; onClick: () => void;
|
||||
tempWarn?: number; tempCrit?: number;
|
||||
}) {
|
||||
const offline = temp === null && powerPct === null;
|
||||
const bg = offline ? "oklch(0.22 0.02 265)"
|
||||
: overlay === "temp" ? tempBg(temp, tempWarn, tempCrit)
|
||||
: overlay === "power" ? powerBg(powerPct)
|
||||
: overlay === "feed" ? feedBg(feed)
|
||||
: overlay === "crac" ? (cracColor ?? "oklch(0.22 0.02 265)")
|
||||
: alarmBg(alarmCount);
|
||||
|
||||
const shortId = rackId.replace("rack-", "").toUpperCase();
|
||||
const mainVal = overlay === "temp" ? (temp !== null ? `${temp}°` : null)
|
||||
: overlay === "power" ? (powerPct !== null ? `${Math.round(powerPct)}%` : null)
|
||||
: overlay === "feed" ? (feed ?? null)
|
||||
: (alarmCount > 0 ? String(alarmCount) : null);
|
||||
const subVal = overlay === "temp" ? (powerPct !== null ? `${Math.round(powerPct)}%` : null)
|
||||
: overlay === "power" ? (temp !== null ? `${temp}°C` : null)
|
||||
: overlay === "feed" ? (temp !== null ? `${temp}°C` : null)
|
||||
: (temp !== null ? `${temp}°C` : null);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={`${rackId} — ${temp ?? "—"}°C · ${powerPct != null ? Math.round(powerPct) : "—"}% load · ${alarmCount} alarm${alarmCount !== 1 ? "s" : ""}`}
|
||||
aria-label={`${rackId} — ${temp ?? "—"}°C · ${powerPct != null ? Math.round(powerPct) : "—"}% load · ${alarmCount} alarm${alarmCount !== 1 ? "s" : ""}`}
|
||||
className="group relative flex flex-col items-center justify-center gap-0.5 rounded-lg cursor-pointer select-none transition-all duration-200 hover:ring-2 hover:ring-white/40 hover:scale-105 active:scale-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
style={{
|
||||
backgroundColor: bg,
|
||||
width: 76, height: 92,
|
||||
backgroundImage: offline
|
||||
? "repeating-linear-gradient(45deg,transparent,transparent 5px,oklch(1 0 0/5%) 5px,oklch(1 0 0/5%) 10px)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<span className="text-[9px] font-bold text-white/60 tracking-widest">{shortId}</span>
|
||||
{offline ? (
|
||||
<WifiOff className="w-4 h-4 text-white/30" />
|
||||
) : overlay === "alarms" && alarmCount === 0 ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-white/40" />
|
||||
) : (
|
||||
<span className="text-[17px] font-bold text-white leading-none">{mainVal ?? "—"}</span>
|
||||
)}
|
||||
{subVal && (
|
||||
<span className="text-[9px] text-white/55 opacity-0 group-hover:opacity-100 transition-opacity">{subVal}</span>
|
||||
)}
|
||||
{overlay === "temp" && powerPct !== null && (
|
||||
<div className="absolute bottom-1.5 left-2 right-2 h-[3px] rounded-full bg-white/15 overflow-hidden">
|
||||
<div className="h-full rounded-full bg-white/50" style={{ width: `${powerPct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{overlay !== "alarms" && alarmCount > 0 && (
|
||||
<div className="absolute top-1 right-1 w-3.5 h-3.5 rounded-full bg-destructive flex items-center justify-center">
|
||||
<span className="text-[8px] font-bold text-white leading-none">{alarmCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── CRAC strip ────────────────────────────────────────────────────────────────
|
||||
|
||||
function CracStrip({ crac }: { crac: CracStatus | undefined }) {
|
||||
const online = crac?.state === "online";
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-center gap-4 rounded-lg px-4 py-2.5 border text-sm",
|
||||
online ? "bg-primary/5 border-primary/20" : "bg-destructive/5 border-destructive/20"
|
||||
)}>
|
||||
<Wind className={cn("w-4 h-4 shrink-0", online ? "text-primary" : "text-destructive")} />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-xs">{crac?.crac_id.toUpperCase() ?? "CRAC"}</span>
|
||||
<span className={cn(
|
||||
"flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase",
|
||||
online ? "bg-green-500/10 text-green-400" : "bg-destructive/10 text-destructive"
|
||||
)}>
|
||||
{online ? <CheckCircle2 className="w-3 h-3" /> : <AlertTriangle className="w-3 h-3" />}
|
||||
{online ? "Online" : "Fault"}
|
||||
</span>
|
||||
</div>
|
||||
{crac && online && (
|
||||
<div className="flex items-center gap-4 ml-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
<Thermometer className="w-3 h-3 inline mr-0.5 text-primary" />
|
||||
Supply <strong className="text-foreground">{crac.supply_temp ?? "—"}°C</strong>
|
||||
</span>
|
||||
<span>
|
||||
<Thermometer className="w-3 h-3 inline mr-0.5 text-orange-400" />
|
||||
Return <strong className="text-foreground">{crac.return_temp ?? "—"}°C</strong>
|
||||
</span>
|
||||
{crac.delta !== null && (
|
||||
<span>ΔT <strong className={cn(
|
||||
crac.delta > 14 ? "text-destructive" : crac.delta > 11 ? "text-amber-400" : "text-green-400"
|
||||
)}>+{crac.delta}°C</strong></span>
|
||||
)}
|
||||
{crac.fan_pct !== null && (
|
||||
<span>Fan <strong className="text-foreground">{crac.fan_pct}%</strong></span>
|
||||
)}
|
||||
{crac.cooling_capacity_pct !== null && (
|
||||
<span>Cap <strong className={cn(
|
||||
(crac.cooling_capacity_pct ?? 0) >= 90 ? "text-destructive" :
|
||||
(crac.cooling_capacity_pct ?? 0) >= 75 ? "text-amber-400" : "text-foreground"
|
||||
)}>{crac.cooling_capacity_pct?.toFixed(0)}%</strong></span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Room plan ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function RoomPlan({
|
||||
roomId, layout, data, cracs, overlay, alarmsByRack, onRackClick, tempWarn = 26, tempCrit = 28,
|
||||
}: {
|
||||
roomId: string;
|
||||
layout: FloorLayout;
|
||||
data: CapacitySummary;
|
||||
cracs: CracStatus[];
|
||||
overlay: Overlay;
|
||||
alarmsByRack: Map<string, number>;
|
||||
onRackClick: (id: string) => void;
|
||||
tempWarn?: number;
|
||||
tempCrit?: number;
|
||||
}) {
|
||||
const roomLayout = layout[roomId];
|
||||
if (!roomLayout) return null;
|
||||
|
||||
const rackMap = new Map(data.racks.map((r) => [r.rack_id, r]));
|
||||
const crac = cracs.find((c) => c.crac_id === roomLayout.crac_id);
|
||||
const roomRacks = data.racks.filter((r) => r.room_id === roomId);
|
||||
const offlineCount = roomRacks.filter((r) => r.temp === null && r.power_kw === null).length;
|
||||
|
||||
const avgTemp = (() => {
|
||||
const temps = roomRacks.map((r) => r.temp).filter((t): t is number => t !== null);
|
||||
return temps.length ? Math.round((temps.reduce((a, b) => a + b, 0) / temps.length) * 10) / 10 : null;
|
||||
})();
|
||||
const totalPower = (() => {
|
||||
const powers = roomRacks.map((r) => r.power_kw).filter((p): p is number => p !== null);
|
||||
return powers.length ? Math.round(powers.reduce((a, b) => a + b, 0) * 10) / 10 : null;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-6 text-xs text-muted-foreground">
|
||||
<span>{roomRacks.length} racks</span>
|
||||
{avgTemp !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Thermometer className="w-3 h-3" />
|
||||
Avg <strong className="text-foreground">{avgTemp}°C</strong>
|
||||
</span>
|
||||
)}
|
||||
{totalPower !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" />
|
||||
<strong className="text-foreground">{totalPower} kW</strong> IT load
|
||||
</span>
|
||||
)}
|
||||
{offlineCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-muted-foreground/60">
|
||||
<WifiOff className="w-3 h-3" />
|
||||
{offlineCount} offline
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TransformWrapper initialScale={1} minScale={0.4} maxScale={3} limitToBounds={false} wheel={{ disabled: true }}>
|
||||
{({ zoomIn, zoomOut, resetTransform }) => (
|
||||
<>
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<button
|
||||
onClick={() => zoomIn()}
|
||||
className="px-2 py-1 rounded border border-border text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||
title="Zoom in"
|
||||
>+</button>
|
||||
<button
|
||||
onClick={() => resetTransform()}
|
||||
className="px-2 py-1 rounded border border-border text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||
title="Reset zoom"
|
||||
>⊙</button>
|
||||
<button
|
||||
onClick={() => zoomOut()}
|
||||
className="px-2 py-1 rounded border border-border text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||
title="Zoom out"
|
||||
>−</button>
|
||||
<span className="text-[10px] text-muted-foreground/50 ml-1">Drag to pan</span>
|
||||
</div>
|
||||
<TransformComponent wrapperStyle={{ width: "100%", overflow: "hidden", borderRadius: "0.75rem" }}>
|
||||
<div className="rounded-xl border border-border bg-muted/10 p-5 space-y-3" style={{ minWidth: "100%" }}>
|
||||
<CracStrip crac={crac} />
|
||||
|
||||
<div
|
||||
className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-widest px-2 rounded-md py-2"
|
||||
style={{ backgroundColor: "oklch(0.55 0.22 25 / 6%)", color: "oklch(0.65 0.20 45)" }}
|
||||
>
|
||||
<div className="flex-1 h-px" style={{ backgroundColor: "oklch(0.65 0.20 45 / 30%)" }} />
|
||||
<Flame className="w-3 h-3 shrink-0" style={{ color: "oklch(0.65 0.20 45)" }} />
|
||||
HOT AISLE
|
||||
<Flame className="w-3 h-3 shrink-0" style={{ color: "oklch(0.65 0.20 45)" }} />
|
||||
<div className="flex-1 h-px" style={{ backgroundColor: "oklch(0.65 0.20 45 / 30%)" }} />
|
||||
</div>
|
||||
|
||||
{roomLayout.rows.map((row, rowIdx) => {
|
||||
const rowCracColor = CRAC_ZONE_COLORS[rowIdx % CRAC_ZONE_COLORS.length];
|
||||
return (
|
||||
<div key={rowIdx}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[9px] text-muted-foreground/40 uppercase tracking-widest w-10 shrink-0 text-right">
|
||||
{row.label}
|
||||
</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{row.racks.map((rackId) => {
|
||||
const rack = rackMap.get(rackId);
|
||||
return (
|
||||
<RackTile
|
||||
key={rackId}
|
||||
rackId={rackId}
|
||||
temp={rack?.temp ?? null}
|
||||
powerPct={rack?.power_pct ?? null}
|
||||
alarmCount={alarmsByRack.get(rackId) ?? 0}
|
||||
overlay={overlay}
|
||||
feed={getFeed(layout, rackId)}
|
||||
cracColor={rowCracColor}
|
||||
onClick={() => onRackClick(rackId)}
|
||||
tempWarn={tempWarn}
|
||||
tempCrit={tempCrit}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{rowIdx < roomLayout.rows.length - 1 && (
|
||||
<div
|
||||
className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-widest px-2 mt-2 mb-1 rounded-md py-2"
|
||||
style={{ backgroundColor: "oklch(0.62 0.17 212 / 7%)", color: "oklch(0.62 0.17 212)" }}
|
||||
>
|
||||
<div className="flex-1 h-px border-t border-dashed" style={{ borderColor: "oklch(0.62 0.17 212 / 30%)" }} />
|
||||
<Snowflake className="w-3 h-3 shrink-0" style={{ color: "oklch(0.62 0.17 212)" }} />
|
||||
COLD AISLE
|
||||
<Snowflake className="w-3 h-3 shrink-0" style={{ color: "oklch(0.62 0.17 212)" }} />
|
||||
<div className="flex-1 h-px border-t border-dashed" style={{ borderColor: "oklch(0.62 0.17 212 / 30%)" }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div
|
||||
className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-widest px-2 rounded-md py-2"
|
||||
style={{ backgroundColor: "oklch(0.55 0.22 25 / 6%)", color: "oklch(0.65 0.20 45)" }}
|
||||
>
|
||||
<div className="flex-1 h-px" style={{ backgroundColor: "oklch(0.65 0.20 45 / 30%)" }} />
|
||||
<Flame className="w-3 h-3 shrink-0" style={{ color: "oklch(0.65 0.20 45)" }} />
|
||||
HOT AISLE
|
||||
<Flame className="w-3 h-3 shrink-0" style={{ color: "oklch(0.65 0.20 45)" }} />
|
||||
<div className="flex-1 h-px" style={{ backgroundColor: "oklch(0.65 0.20 45 / 30%)" }} />
|
||||
</div>
|
||||
</div>
|
||||
</TransformComponent>
|
||||
</>
|
||||
)}
|
||||
</TransformWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Leak sensor panel ─────────────────────────────────────────────────────────
|
||||
|
||||
function LeakSensorPanel({ sensors }: { sensors: LeakSensorStatus[] }) {
|
||||
if (sensors.length === 0) return null;
|
||||
const active = sensors.filter((s) => s.state === "detected");
|
||||
const byZone = sensors.reduce<Record<string, LeakSensorStatus[]>>((acc, s) => {
|
||||
const zone = s.floor_zone ?? "unknown";
|
||||
(acc[zone] ??= []).push(s);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<Card className={cn("border", active.length > 0 && "border-destructive/50")}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Droplets className={cn("w-4 h-4", active.length > 0 ? "text-destructive" : "text-blue-400")} />
|
||||
Leak Sensor Status
|
||||
</CardTitle>
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-2.5 py-0.5 rounded-full uppercase",
|
||||
active.length > 0 ? "bg-destructive/10 text-destructive" : "bg-green-500/10 text-green-400",
|
||||
)}>
|
||||
{active.length > 0 ? `${active.length} leak${active.length > 1 ? "s" : ""} detected` : "All clear"}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{Object.entries(byZone).map(([zone, zoneSensors]) => (
|
||||
<div key={zone} className="rounded-lg border border-border/50 bg-muted/10 p-3 space-y-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{zone}</p>
|
||||
{zoneSensors.map((s) => {
|
||||
const detected = s.state === "detected";
|
||||
return (
|
||||
<div key={s.sensor_id} className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={cn(
|
||||
"w-2 h-2 rounded-full shrink-0",
|
||||
detected ? "bg-destructive animate-pulse" :
|
||||
s.state === "unknown" ? "bg-muted-foreground/30" : "bg-green-500",
|
||||
)} />
|
||||
<span className="text-xs font-medium truncate">{s.sensor_id}</span>
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground/60 mt-0.5 truncate">
|
||||
{[
|
||||
s.under_floor ? "Under floor" : "Surface mount",
|
||||
s.near_crac ? "near CRAC" : null,
|
||||
s.room_id ?? null,
|
||||
].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold shrink-0",
|
||||
detected ? "text-destructive" :
|
||||
s.state === "unknown" ? "text-muted-foreground/50" : "text-green-400",
|
||||
)}>
|
||||
{detected ? "LEAK" : s.state === "unknown" ? "unknown" : "clear"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Layout editor ─────────────────────────────────────────────────────────────
|
||||
|
||||
function LayoutEditor({
|
||||
layout, onSave, saving,
|
||||
}: {
|
||||
layout: FloorLayout;
|
||||
onSave: (l: FloorLayout) => void;
|
||||
saving?: boolean;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<FloorLayout>(() => JSON.parse(JSON.stringify(layout)));
|
||||
const [newRoomId, setNewRoomId] = useState("");
|
||||
const [newRoomLabel, setNewRoomLabel] = useState("");
|
||||
const [newRoomCrac, setNewRoomCrac] = useState("");
|
||||
const [expandedRoom, setExpandedRoom] = useState<string | null>(Object.keys(draft)[0] ?? null);
|
||||
|
||||
function updateRoom(roomId: string, patch: Partial<RoomLayout>) {
|
||||
setDraft(d => ({ ...d, [roomId]: { ...d[roomId], ...patch } }));
|
||||
}
|
||||
|
||||
function deleteRoom(roomId: string) {
|
||||
setDraft(d => { const n = { ...d }; delete n[roomId]; return n; });
|
||||
if (expandedRoom === roomId) setExpandedRoom(null);
|
||||
}
|
||||
|
||||
function addRoom() {
|
||||
const id = newRoomId.trim().toLowerCase().replace(/\s+/g, "-");
|
||||
if (!id || !newRoomLabel.trim() || draft[id]) return;
|
||||
setDraft(d => ({
|
||||
...d,
|
||||
[id]: { label: newRoomLabel.trim(), crac_id: newRoomCrac.trim(), rows: [] },
|
||||
}));
|
||||
setNewRoomId(""); setNewRoomLabel(""); setNewRoomCrac("");
|
||||
setExpandedRoom(id);
|
||||
}
|
||||
|
||||
function addRow(roomId: string) {
|
||||
const room = draft[roomId];
|
||||
const label = `Row ${room.rows.length + 1}`;
|
||||
updateRoom(roomId, { rows: [...room.rows, { label, racks: [] }] });
|
||||
}
|
||||
|
||||
function deleteRow(roomId: string, rowIdx: number) {
|
||||
const rows = draft[roomId].rows.filter((_, i) => i !== rowIdx);
|
||||
updateRoom(roomId, { rows });
|
||||
}
|
||||
|
||||
function updateRowLabel(roomId: string, rowIdx: number, label: string) {
|
||||
const rows = draft[roomId].rows.map((r, i) => i === rowIdx ? { ...r, label } : r);
|
||||
updateRoom(roomId, { rows });
|
||||
}
|
||||
|
||||
function addRack(roomId: string, rowIdx: number, rackId: string) {
|
||||
const id = rackId.trim();
|
||||
if (!id) return;
|
||||
const rows = draft[roomId].rows.map((r, i) =>
|
||||
i === rowIdx ? { ...r, racks: [...r.racks, id] } : r
|
||||
);
|
||||
updateRoom(roomId, { rows });
|
||||
}
|
||||
|
||||
function removeRack(roomId: string, rowIdx: number, rackIdx: number) {
|
||||
const rows = draft[roomId].rows.map((r, i) =>
|
||||
i === rowIdx ? { ...r, racks: r.racks.filter((_, j) => j !== rackIdx) } : r
|
||||
);
|
||||
updateRoom(roomId, { rows });
|
||||
}
|
||||
|
||||
function moveRow(roomId: string, rowIdx: number, dir: -1 | 1) {
|
||||
const rows = [...draft[roomId].rows];
|
||||
const target = rowIdx + dir;
|
||||
if (target < 0 || target >= rows.length) return;
|
||||
[rows[rowIdx], rows[target]] = [rows[target], rows[rowIdx]];
|
||||
updateRoom(roomId, { rows });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-6 pt-6 pb-4 border-b border-border shrink-0">
|
||||
<h2 className="text-base font-semibold flex items-center gap-2">
|
||||
<Settings2 className="w-4 h-4 text-primary" /> Floor Layout Editor
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Configure rooms, rows, and rack positions. Changes are saved for all users.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{Object.entries(draft).map(([roomId, room]) => (
|
||||
<div key={roomId} className="rounded-xl border border-border bg-muted/10 overflow-hidden">
|
||||
{/* Room header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 bg-muted/20 border-b border-border/60">
|
||||
<button
|
||||
onClick={() => setExpandedRoom(expandedRoom === roomId ? null : roomId)}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left focus-visible:outline-none"
|
||||
>
|
||||
{expandedRoom === roomId
|
||||
? <ChevronUp className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
: <ChevronDown className="w-3.5 h-3.5 text-muted-foreground shrink-0" />}
|
||||
<span className="text-sm font-semibold truncate">{room.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">{roomId}</span>
|
||||
<span className="text-[10px] text-muted-foreground ml-1">
|
||||
· {room.rows.reduce((s, r) => s + r.racks.length, 0)} racks
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteRoom(roomId)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors shrink-0 p-1"
|
||||
aria-label={`Delete ${room.label}`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expandedRoom === roomId && (
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Room fields */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] text-muted-foreground font-medium">Room Label</label>
|
||||
<input
|
||||
value={room.label}
|
||||
onChange={e => updateRoom(roomId, { label: e.target.value })}
|
||||
className="w-full h-7 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] text-muted-foreground font-medium">CRAC ID</label>
|
||||
<input
|
||||
value={room.crac_id}
|
||||
onChange={e => updateRoom(roomId, { crac_id: e.target.value })}
|
||||
placeholder="e.g. crac-01"
|
||||
className="w-full h-7 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rows</p>
|
||||
<button
|
||||
onClick={() => addRow(roomId)}
|
||||
className="flex items-center gap-1 text-[10px] text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" /> Add Row
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{room.rows.length === 0 && (
|
||||
<p className="text-[10px] text-muted-foreground/50 py-2 text-center">No rows — click Add Row</p>
|
||||
)}
|
||||
|
||||
{room.rows.map((row, rowIdx) => (
|
||||
<div key={rowIdx} className="rounded-lg border border-border/60 bg-background/50 p-2.5 space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<GripVertical className="w-3 h-3 text-muted-foreground/30 shrink-0" />
|
||||
<input
|
||||
value={row.label}
|
||||
onChange={e => updateRowLabel(roomId, rowIdx, e.target.value)}
|
||||
className="flex-1 h-6 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<button onClick={() => moveRow(roomId, rowIdx, -1)} disabled={rowIdx === 0}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-20 transition-colors">
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
</button>
|
||||
<button onClick={() => moveRow(roomId, rowIdx, 1)} disabled={rowIdx === room.rows.length - 1}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-20 transition-colors">
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
<button onClick={() => deleteRow(roomId, rowIdx)}
|
||||
className="p-0.5 text-muted-foreground hover:text-destructive transition-colors">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Racks in this row */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.racks.map((rackId, rackIdx) => (
|
||||
<span key={rackIdx} className="inline-flex items-center gap-0.5 bg-muted rounded px-1.5 py-0.5 text-[10px] font-mono">
|
||||
{rackId}
|
||||
<button
|
||||
onClick={() => removeRack(roomId, rowIdx, rackIdx)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors ml-0.5"
|
||||
aria-label={`Remove ${rackId}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<RackAdder onAdd={(id) => addRack(roomId, rowIdx, id)} />
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground/40">
|
||||
Feed: {rowIdx % 2 === 0 ? "A (even rows)" : "B (odd rows)"} · {row.racks.length} rack{row.racks.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add new room */}
|
||||
<div className="rounded-xl border border-dashed border-border p-3 space-y-2">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Add New Room</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<input
|
||||
value={newRoomId}
|
||||
onChange={e => setNewRoomId(e.target.value)}
|
||||
placeholder="room-id (e.g. hall-c)"
|
||||
className="h-7 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<input
|
||||
value={newRoomLabel}
|
||||
onChange={e => setNewRoomLabel(e.target.value)}
|
||||
placeholder="Label (e.g. Hall C)"
|
||||
className="h-7 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<input
|
||||
value={newRoomCrac}
|
||||
onChange={e => setNewRoomCrac(e.target.value)}
|
||||
placeholder="CRAC ID (e.g. crac-03)"
|
||||
className="h-7 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={addRoom}
|
||||
disabled={!newRoomId.trim() || !newRoomLabel.trim()}
|
||||
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Add Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="px-6 py-4 border-t border-border shrink-0 flex items-center justify-between gap-3">
|
||||
<button
|
||||
onClick={() => { setDraft(JSON.parse(JSON.stringify(DEFAULT_LAYOUT))); setExpandedRoom(Object.keys(DEFAULT_LAYOUT)[0]); }}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
<Button size="sm" onClick={() => onSave(draft)} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save Layout"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Small inline input to add a rack ID to a row
|
||||
function RackAdder({ onAdd }: { onAdd: (id: string) => void }) {
|
||||
const [val, setVal] = useState("");
|
||||
function submit() {
|
||||
if (val.trim()) { onAdd(val.trim()); setVal(""); }
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<input
|
||||
value={val}
|
||||
onChange={e => setVal(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && submit()}
|
||||
placeholder="rack-id"
|
||||
className="h-5 w-20 rounded border border-dashed border-border bg-background px-1.5 text-[10px] font-mono focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<button
|
||||
onClick={submit}
|
||||
className="text-primary hover:text-primary/70 transition-colors"
|
||||
aria-label="Add rack"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function FloorMapPage() {
|
||||
const { thresholds } = useThresholds();
|
||||
const [layout, setLayout] = useState<FloorLayout>(DEFAULT_LAYOUT);
|
||||
const [data, setData] = useState<CapacitySummary | null>(null);
|
||||
const [cracs, setCracs] = useState<CracStatus[]>([]);
|
||||
const [alarms, setAlarms] = useState<Alarm[]>([]);
|
||||
const [leakSensors, setLeakSensors] = useState<LeakSensorStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [overlay, setOverlay] = useState<Overlay>("temp");
|
||||
const [activeRoom, setActiveRoom] = useState<string>("");
|
||||
const [selectedRack, setSelectedRack] = useState<string | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [layoutSaving, setLayoutSaving] = useState(false);
|
||||
|
||||
// Load layout from backend on mount
|
||||
useEffect(() => {
|
||||
fetchFloorLayout(SITE_ID)
|
||||
.then((remote) => {
|
||||
const parsed = remote as FloorLayout;
|
||||
setLayout(parsed);
|
||||
setActiveRoom(Object.keys(parsed)[0] ?? "");
|
||||
})
|
||||
.catch(() => {
|
||||
// No saved layout yet — use default
|
||||
setActiveRoom(Object.keys(DEFAULT_LAYOUT)[0] ?? "");
|
||||
});
|
||||
}, []);
|
||||
|
||||
const alarmsByRack = new Map<string, number>();
|
||||
for (const a of alarms) {
|
||||
if (a.rack_id) alarmsByRack.set(a.rack_id, (alarmsByRack.get(a.rack_id) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [d, c, a, ls] = await Promise.all([
|
||||
fetchCapacitySummary(SITE_ID),
|
||||
fetchCracStatus(SITE_ID),
|
||||
fetchAlarms(SITE_ID, "active", 200),
|
||||
fetchLeakStatus(SITE_ID).catch(() => [] as LeakSensorStatus[]),
|
||||
]);
|
||||
setData(d);
|
||||
setCracs(c);
|
||||
setAlarms(a);
|
||||
setLeakSensors(ls);
|
||||
} catch { /* keep stale */ }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
async function handleSaveLayout(newLayout: FloorLayout) {
|
||||
setLayoutSaving(true);
|
||||
try {
|
||||
await saveFloorLayout(SITE_ID, newLayout as unknown as Record<string, unknown>);
|
||||
setLayout(newLayout);
|
||||
if (!newLayout[activeRoom]) setActiveRoom(Object.keys(newLayout)[0] ?? "");
|
||||
setEditorOpen(false);
|
||||
} catch {
|
||||
// save failed — keep editor open so user can retry
|
||||
} finally {
|
||||
setLayoutSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const roomIds = Object.keys(layout);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Floor Map</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — live rack layout · refreshes every 30s</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Overlay selector */}
|
||||
<div className="flex items-center gap-0.5 rounded-lg bg-muted p-1">
|
||||
{([
|
||||
{ val: "temp" as Overlay, icon: Thermometer, label: "Temperature" },
|
||||
{ val: "power" as Overlay, icon: Zap, label: "Power %" },
|
||||
{ val: "alarms" as Overlay, icon: AlertTriangle, label: "Alarms" },
|
||||
{ val: "feed" as Overlay, icon: Cable, label: "Power Feed" },
|
||||
{ val: "crac" as Overlay, icon: Wind, label: "CRAC Coverage" },
|
||||
]).map(({ val, icon: Icon, label }) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setOverlay(val)}
|
||||
aria-label={label}
|
||||
aria-pressed={overlay === val}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
||||
overlay === val ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" /> {label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Layout editor trigger */}
|
||||
<Sheet open={editorOpen} onOpenChange={setEditorOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="flex items-center gap-1.5">
|
||||
<Settings2 className="w-3.5 h-3.5" /> Edit Layout
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="p-0 w-[420px] sm:w-[480px] flex flex-col">
|
||||
<LayoutEditor layout={layout} onSave={handleSaveLayout} saving={layoutSaving} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton className="h-96 w-full" />
|
||||
) : !data ? (
|
||||
<div className="flex items-center justify-center h-64 text-sm text-muted-foreground">
|
||||
Unable to load floor map data.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<RackDetailSheet siteId={SITE_ID} rackId={selectedRack} onClose={() => setSelectedRack(null)} />
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Room View</CardTitle>
|
||||
{roomIds.length > 0 && (
|
||||
<Tabs value={activeRoom} onValueChange={setActiveRoom}>
|
||||
<TabsList className="h-7">
|
||||
{roomIds.map((id) => (
|
||||
<TabsTrigger key={id} value={id} className="text-xs px-3 py-0.5">
|
||||
{layout[id].label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activeRoom && layout[activeRoom] ? (
|
||||
<RoomPlan
|
||||
roomId={activeRoom}
|
||||
layout={layout}
|
||||
data={data}
|
||||
cracs={cracs}
|
||||
overlay={overlay}
|
||||
alarmsByRack={alarmsByRack}
|
||||
onRackClick={setSelectedRack}
|
||||
tempWarn={thresholds.temp.warn}
|
||||
tempCrit={thresholds.temp.critical}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3 text-muted-foreground">
|
||||
<Settings2 className="w-8 h-8 opacity-30" />
|
||||
<p className="text-sm">No rooms configured</p>
|
||||
<p className="text-xs">Use Edit Layout to add rooms and racks</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<LeakSensorPanel sensors={leakSensors} />
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground flex-wrap">
|
||||
{overlay === "alarms" ? (
|
||||
<>
|
||||
<span className="font-medium">Alarm count:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{([
|
||||
{ c: "oklch(0.22 0.02 265)", l: "0" },
|
||||
{ c: "oklch(0.65 0.20 45)", l: "1–2" },
|
||||
{ c: "oklch(0.55 0.22 25)", l: "3+" },
|
||||
]).map(({ c, l }) => (
|
||||
<span key={l} className="flex items-center gap-0.5">
|
||||
<span className="w-6 h-4 rounded-sm inline-block" style={{ backgroundColor: c }} />
|
||||
<span>{l}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : overlay === "feed" ? (
|
||||
<>
|
||||
<span className="font-medium">Power feed:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-6 h-4 rounded-sm inline-block" style={{ backgroundColor: "oklch(0.55 0.18 255)" }} />
|
||||
<span>Feed A (even rows)</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-6 h-4 rounded-sm inline-block" style={{ backgroundColor: "oklch(0.60 0.18 40)" }} />
|
||||
<span>Feed B (odd rows)</span>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : overlay === "crac" ? (
|
||||
<>
|
||||
<span className="font-medium">CRAC thermal zones:</span>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{CRAC_ZONE_COLORS.slice(0, layout[activeRoom]?.rows.length ?? 2).map((c, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<span className="w-6 h-4 rounded-sm inline-block" style={{ backgroundColor: c }} />
|
||||
<span>Zone {i + 1}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium">{overlay === "temp" ? "Temperature:" : "Power utilisation:"}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{overlay === "temp"
|
||||
? (["oklch(0.60 0.15 212)","oklch(0.68 0.14 162)","oklch(0.78 0.14 140)","oklch(0.72 0.18 84)","oklch(0.65 0.20 45)","oklch(0.55 0.22 25)"] as string[]).map((c, i) => (
|
||||
<span key={i} className="w-7 h-4 rounded-sm inline-block" style={{ backgroundColor: c }} />
|
||||
))
|
||||
: (["oklch(0.60 0.15 212)","oklch(0.68 0.14 162)","oklch(0.72 0.18 84)","oklch(0.65 0.20 45)","oklch(0.55 0.22 25)"] as string[]).map((c, i) => (
|
||||
<span key={i} className="w-7 h-4 rounded-sm inline-block" style={{ backgroundColor: c }} />
|
||||
))}
|
||||
<span className="ml-1">{overlay === "temp" ? "Cool → Hot" : "Low → High"}</span>
|
||||
</div>
|
||||
{overlay === "temp" && <span className="ml-auto">Warn: {thresholds.temp.warn}°C | Critical: {thresholds.temp.critical}°C</span>}
|
||||
{overlay === "power" && <span className="ml-auto">Warn: 75% | Critical: 90%</span>}
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground/50">Click any rack to drill down</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
412
frontend/app/(dashboard)/generator/page.tsx
Normal file
412
frontend/app/(dashboard)/generator/page.tsx
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
fetchGeneratorStatus, fetchAtsStatus, fetchPhaseBreakdown,
|
||||
type GeneratorStatus, type AtsStatus, type RoomPhase,
|
||||
} from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Fuel, Zap, Activity, RefreshCw, CheckCircle2, AlertTriangle,
|
||||
ArrowLeftRight, Gauge, Thermometer, Battery,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { GeneratorDetailSheet } from "@/components/dashboard/generator-detail-sheet";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
const STATE_COLOR: Record<string, string> = {
|
||||
running: "bg-green-500/10 text-green-400",
|
||||
standby: "bg-blue-500/10 text-blue-400",
|
||||
test: "bg-amber-500/10 text-amber-400",
|
||||
fault: "bg-destructive/10 text-destructive",
|
||||
unknown: "bg-muted/30 text-muted-foreground",
|
||||
};
|
||||
|
||||
const ATS_FEED_COLOR: Record<string, string> = {
|
||||
"utility-a": "bg-blue-500/10 text-blue-400",
|
||||
"utility-b": "bg-sky-500/10 text-sky-400",
|
||||
"generator": "bg-amber-500/10 text-amber-400",
|
||||
};
|
||||
|
||||
function FillBar({
|
||||
value, max, color = "#22c55e", warn, crit,
|
||||
}: {
|
||||
value: number | null; max: number; color?: string; warn?: number; crit?: number;
|
||||
}) {
|
||||
const pct = value != null ? Math.min(100, (value / max) * 100) : 0;
|
||||
const bg = crit && value != null && value >= crit ? "#ef4444"
|
||||
: warn && value != null && value >= warn ? "#f59e0b"
|
||||
: color;
|
||||
return (
|
||||
<div className="rounded-full bg-muted overflow-hidden h-2 w-full">
|
||||
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${pct}%`, backgroundColor: bg }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatRow({ label, value, warn }: { label: string; value: string; warn?: boolean }) {
|
||||
return (
|
||||
<div className="flex justify-between items-baseline text-xs">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className={cn("font-mono font-medium", warn && "text-amber-400")}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneratorCard({ gen, onClick }: { gen: GeneratorStatus; onClick: () => void }) {
|
||||
const fuelLow = (gen.fuel_pct ?? 100) < 25;
|
||||
const fuelCrit = (gen.fuel_pct ?? 100) < 10;
|
||||
const isFault = gen.state === "fault";
|
||||
const isRun = gen.state === "running" || gen.state === "test";
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn("border cursor-pointer hover:border-primary/40 transition-colors", isFault && "border-destructive/50")}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Activity className={cn("w-4 h-4", isRun ? "text-green-400" : "text-muted-foreground")} />
|
||||
{gen.gen_id.toUpperCase()}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-2.5 py-0.5 rounded-full uppercase tracking-wide",
|
||||
STATE_COLOR[gen.state] ?? STATE_COLOR.unknown,
|
||||
)}>
|
||||
{gen.state}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Fuel level */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="flex items-center gap-1.5 text-[10px] text-muted-foreground uppercase tracking-wide">
|
||||
<Fuel className="w-3 h-3" /> Fuel Level
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-sm font-bold tabular-nums",
|
||||
fuelCrit ? "text-destructive" : fuelLow ? "text-amber-400" : "text-green-400",
|
||||
)}>
|
||||
{gen.fuel_pct != null ? `${gen.fuel_pct.toFixed(1)}%` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<FillBar
|
||||
value={gen.fuel_pct}
|
||||
max={100}
|
||||
color="#22c55e"
|
||||
warn={25}
|
||||
crit={10}
|
||||
/>
|
||||
{gen.fuel_litres != null && (
|
||||
<p className="text-[10px] text-muted-foreground text-right mt-1">
|
||||
{gen.fuel_litres.toFixed(0)} L remaining
|
||||
</p>
|
||||
)}
|
||||
{gen.fuel_litres != null && gen.load_kw != null && gen.load_kw > 0 && (() => {
|
||||
const runtimeH = gen.fuel_litres / (gen.load_kw * 0.27);
|
||||
const hours = Math.floor(runtimeH);
|
||||
const mins = Math.round((runtimeH - hours) * 60);
|
||||
const cls = runtimeH < 4 ? "text-destructive" : runtimeH < 12 ? "text-amber-400" : "text-green-400";
|
||||
return (
|
||||
<p className={cn("text-[10px] text-right mt-0.5", cls)}>
|
||||
Est. runtime: <strong>{hours}h {mins}m</strong>
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Load */}
|
||||
{gen.load_kw != null && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="flex items-center gap-1.5 text-[10px] text-muted-foreground uppercase tracking-wide">
|
||||
<Zap className="w-3 h-3" /> Load
|
||||
</span>
|
||||
<span className="text-sm font-bold tabular-nums text-foreground">
|
||||
{gen.load_kw.toFixed(1)} kW
|
||||
{gen.load_pct != null && (
|
||||
<span className="text-muted-foreground font-normal ml-1">({gen.load_pct.toFixed(0)}%)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={gen.load_pct} max={100} color="#60a5fa" warn={75} crit={90} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Engine stats */}
|
||||
<div className="rounded-lg bg-muted/20 px-3 py-3 space-y-2">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold mb-1">Engine</p>
|
||||
{gen.voltage_v != null && <StatRow label="Output voltage" value={`${gen.voltage_v.toFixed(0)} V`} />}
|
||||
{gen.frequency_hz != null && <StatRow label="Frequency" value={`${gen.frequency_hz.toFixed(1)} Hz`} warn={Math.abs(gen.frequency_hz - 50) > 0.5} />}
|
||||
{gen.run_hours != null && <StatRow label="Run hours" value={`${gen.run_hours.toFixed(0)} h`} />}
|
||||
{gen.oil_pressure_bar != null && <StatRow label="Oil pressure" value={`${gen.oil_pressure_bar.toFixed(1)} bar`} warn={gen.oil_pressure_bar < 2.0} />}
|
||||
{gen.coolant_temp_c != null && (
|
||||
<div className="flex justify-between items-baseline text-xs">
|
||||
<span className="text-muted-foreground flex items-center gap-1">
|
||||
<Thermometer className="w-3 h-3 inline" /> Coolant temp
|
||||
</span>
|
||||
<span className={cn("font-mono font-medium", gen.coolant_temp_c > 95 ? "text-destructive" : gen.coolant_temp_c > 85 ? "text-amber-400" : "")}>
|
||||
{gen.coolant_temp_c.toFixed(1)}°C
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{gen.battery_v != null && (
|
||||
<div className="flex justify-between items-baseline text-xs">
|
||||
<span className="text-muted-foreground flex items-center gap-1">
|
||||
<Battery className="w-3 h-3 inline" /> Battery
|
||||
</span>
|
||||
<span className={cn("font-mono font-medium", gen.battery_v < 11.5 ? "text-destructive" : gen.battery_v < 12.0 ? "text-amber-400" : "text-green-400")}>
|
||||
{gen.battery_v.toFixed(1)} V
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AtsCard({ ats }: { ats: AtsStatus }) {
|
||||
const feedColor = ATS_FEED_COLOR[ats.active_feed] ?? "bg-muted/30 text-muted-foreground";
|
||||
const isGen = ats.active_feed === "generator";
|
||||
|
||||
return (
|
||||
<Card className={cn("border", isGen && "border-amber-500/40")}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<ArrowLeftRight className="w-4 h-4 text-primary" />
|
||||
{ats.ats_id.toUpperCase()} — ATS Transfer Switch
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">Active feed</span>
|
||||
<span className={cn("text-xs font-bold px-2.5 py-1 rounded-full uppercase tracking-wide", feedColor)}>
|
||||
{ats.active_feed}
|
||||
</span>
|
||||
{isGen && <span className="text-[10px] text-amber-400">Running on generator power</span>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-xs">
|
||||
{[
|
||||
{ label: "Utility A", v: ats.utility_a_v },
|
||||
{ label: "Utility B", v: ats.utility_b_v },
|
||||
{ label: "Generator", v: ats.generator_v },
|
||||
].map(({ label, v }) => (
|
||||
<div key={label} className="rounded-lg bg-muted/20 px-2 py-2 text-center">
|
||||
<p className="text-[10px] text-muted-foreground mb-0.5">{label}</p>
|
||||
<p className="font-bold text-foreground tabular-nums">{v != null ? `${v.toFixed(0)} V` : "—"}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
{ats.transfer_count != null && (
|
||||
<div className="rounded-lg bg-muted/20 px-3 py-2">
|
||||
<p className="text-[10px] text-muted-foreground mb-0.5">Transfers (total)</p>
|
||||
<p className="font-bold">{ats.transfer_count}</p>
|
||||
</div>
|
||||
)}
|
||||
{ats.last_transfer_ms != null && (
|
||||
<div className="rounded-lg bg-muted/20 px-3 py-2">
|
||||
<p className="text-[10px] text-muted-foreground mb-0.5">Last transfer time</p>
|
||||
<p className="font-bold">{ats.last_transfer_ms} ms</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PhaseImbalancePanel({ rooms }: { rooms: RoomPhase[] }) {
|
||||
const allRacks = rooms.flatMap((r) => r.racks);
|
||||
const flagged = allRacks
|
||||
.filter((r) => (r.imbalance_pct ?? 0) >= 5)
|
||||
.sort((a, b) => (b.imbalance_pct ?? 0) - (a.imbalance_pct ?? 0));
|
||||
|
||||
if (flagged.length === 0) return (
|
||||
<div className="rounded-lg border border-border bg-muted/10 px-4 py-3 flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-400" />
|
||||
<span className="text-green-400">No PDU phase imbalance detected across all racks</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400" />
|
||||
PDU Phase Imbalance
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{flagged.map((rack) => {
|
||||
const crit = (rack.imbalance_pct ?? 0) >= 15;
|
||||
return (
|
||||
<div key={rack.rack_id} className={cn(
|
||||
"rounded-lg px-3 py-2 grid grid-cols-5 gap-2 text-xs items-center",
|
||||
crit ? "bg-destructive/10" : "bg-amber-500/10",
|
||||
)}>
|
||||
<span className="font-medium col-span-1">{rack.rack_id.toUpperCase()}</span>
|
||||
<span className={cn("text-center", crit ? "text-destructive" : "text-amber-400")}>
|
||||
{rack.imbalance_pct?.toFixed(1)}% imbalance
|
||||
</span>
|
||||
<span className="text-muted-foreground text-center">A: {rack.phase_a_kw?.toFixed(2) ?? "—"} kW</span>
|
||||
<span className="text-muted-foreground text-center">B: {rack.phase_b_kw?.toFixed(2) ?? "—"} kW</span>
|
||||
<span className="text-muted-foreground text-center">C: {rack.phase_c_kw?.toFixed(2) ?? "—"} kW</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GeneratorPage() {
|
||||
const [generators, setGenerators] = useState<GeneratorStatus[]>([]);
|
||||
const [atsUnits, setAtsUnits] = useState<AtsStatus[]>([]);
|
||||
const [phases, setPhases] = useState<RoomPhase[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedGen, setSelectedGen] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [g, a, p] = await Promise.all([
|
||||
fetchGeneratorStatus(SITE_ID),
|
||||
fetchAtsStatus(SITE_ID).catch(() => [] as AtsStatus[]),
|
||||
fetchPhaseBreakdown(SITE_ID).catch(() => [] as RoomPhase[]),
|
||||
]);
|
||||
setGenerators(g);
|
||||
setAtsUnits(a);
|
||||
setPhases(p);
|
||||
} catch { toast.error("Failed to load generator data"); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 15_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
const anyFault = generators.some((g) => g.state === "fault");
|
||||
const anyRun = generators.some((g) => g.state === "running" || g.state === "test");
|
||||
const onGen = atsUnits.some((a) => a.active_feed === "generator");
|
||||
|
||||
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">Generator & Power Path</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — backup power systems · refreshes every 15s</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!loading && (
|
||||
<span className={cn(
|
||||
"flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-full",
|
||||
anyFault ? "bg-destructive/10 text-destructive" :
|
||||
onGen ? "bg-amber-500/10 text-amber-400" :
|
||||
anyRun ? "bg-green-500/10 text-green-400" :
|
||||
"bg-blue-500/10 text-blue-400",
|
||||
)}>
|
||||
{anyFault ? <><AlertTriangle className="w-3.5 h-3.5" /> Generator fault</> :
|
||||
onGen ? <><AlertTriangle className="w-3.5 h-3.5" /> Running on generator</> :
|
||||
anyRun ? <><CheckCircle2 className="w-3.5 h-3.5" /> Generator running (test)</> :
|
||||
<><CheckCircle2 className="w-3.5 h-3.5" /> Utility power — all standby</>}
|
||||
</span>
|
||||
)}
|
||||
<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 power status bar */}
|
||||
{!loading && atsUnits.length > 0 && (
|
||||
<div className={cn(
|
||||
"rounded-xl border px-5 py-3 flex items-center gap-6 text-sm flex-wrap",
|
||||
onGen ? "border-amber-500/30 bg-amber-500/5" : "border-border bg-muted/10",
|
||||
)}>
|
||||
<Gauge className={cn("w-5 h-5 shrink-0", onGen ? "text-amber-400" : "text-primary")} />
|
||||
<div>
|
||||
<span className="text-muted-foreground">Power path: </span>
|
||||
<strong className="text-foreground capitalize">{onGen ? "Generator (utility lost)" : "Utility mains"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Generators: </span>
|
||||
<strong>{generators.length} total</strong>
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({generators.filter((g) => g.state === "standby").length} standby,{" "}
|
||||
{generators.filter((g) => g.state === "running").length} running,{" "}
|
||||
{generators.filter((g) => g.state === "fault").length} fault)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generators */}
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Diesel Generators
|
||||
</h2>
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Skeleton className="h-80" />
|
||||
<Skeleton className="h-80" />
|
||||
</div>
|
||||
) : generators.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No generator data available</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{generators.map((g) => (
|
||||
<GeneratorCard key={g.gen_id} gen={g} onClick={() => setSelectedGen(g.gen_id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ATS */}
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Automatic Transfer Switches
|
||||
</h2>
|
||||
{loading ? (
|
||||
<Skeleton className="h-40" />
|
||||
) : atsUnits.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No ATS data available</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{atsUnits.map((a) => <AtsCard key={a.ats_id} ats={a} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<GeneratorDetailSheet
|
||||
siteId={SITE_ID}
|
||||
genId={selectedGen}
|
||||
onClose={() => setSelectedGen(null)}
|
||||
/>
|
||||
|
||||
{/* Phase imbalance */}
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
PDU Phase Balance
|
||||
</h2>
|
||||
{loading ? (
|
||||
<Skeleton className="h-32" />
|
||||
) : (
|
||||
<PhaseImbalancePanel rooms={phases} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
frontend/app/(dashboard)/layout.tsx
Normal file
33
frontend/app/(dashboard)/layout.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { Topbar } from "@/components/layout/topbar";
|
||||
import { AlarmProvider } from "@/lib/alarm-context";
|
||||
import { ThresholdProvider } from "@/lib/threshold-context";
|
||||
import { ErrorBoundary } from "@/components/error-boundary";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ThresholdProvider>
|
||||
<AlarmProvider>
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<div className="hidden md:flex">
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
|
||||
<Topbar />
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<ErrorBoundary>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
<Toaster position="bottom-right" theme="dark" richColors />
|
||||
</div>
|
||||
</AlarmProvider>
|
||||
</ThresholdProvider>
|
||||
);
|
||||
}
|
||||
244
frontend/app/(dashboard)/leak/page.tsx
Normal file
244
frontend/app/(dashboard)/leak/page.tsx
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { fetchLeakStatus, type LeakSensorStatus } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Droplets, RefreshCw, CheckCircle2, AlertTriangle, MapPin, Wind, Clock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
function SensorBadge({ state }: { state: string }) {
|
||||
const cfg = {
|
||||
detected: { cls: "bg-destructive/10 text-destructive border-destructive/30", label: "LEAK DETECTED" },
|
||||
clear: { cls: "bg-green-500/10 text-green-400 border-green-500/20", label: "Clear" },
|
||||
unknown: { cls: "bg-muted/30 text-muted-foreground border-border", label: "Unknown" },
|
||||
}[state] ?? { cls: "bg-muted/30 text-muted-foreground border-border", label: state };
|
||||
return (
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide border",
|
||||
cfg.cls,
|
||||
)}>
|
||||
{cfg.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SensorCard({ sensor }: { sensor: LeakSensorStatus }) {
|
||||
const detected = sensor.state === "detected";
|
||||
const sensorAny = sensor as LeakSensorStatus & { last_triggered_at?: string | null; trigger_count_30d?: number };
|
||||
const triggerCount30d = sensorAny.trigger_count_30d ?? 0;
|
||||
const lastTriggeredAt = sensorAny.last_triggered_at ?? null;
|
||||
|
||||
let lastTriggeredText: string;
|
||||
if (lastTriggeredAt) {
|
||||
const daysAgo = Math.floor((Date.now() - new Date(lastTriggeredAt).getTime()) / (1000 * 60 * 60 * 24));
|
||||
lastTriggeredText = daysAgo === 0 ? "Today" : `${daysAgo}d ago`;
|
||||
} else if (detected) {
|
||||
lastTriggeredText = "Currently active";
|
||||
} else {
|
||||
lastTriggeredText = "No recent events";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"rounded-xl border p-4 space-y-3 transition-colors",
|
||||
detected ? "border-destructive/50 bg-destructive/5" : "border-border bg-muted/5",
|
||||
)}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"w-2.5 h-2.5 rounded-full shrink-0 mt-0.5",
|
||||
detected ? "bg-destructive animate-pulse" :
|
||||
sensor.state === "unknown" ? "bg-muted-foreground/30" : "bg-green-500",
|
||||
)} />
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-none">{sensor.sensor_id}</p>
|
||||
{sensor.floor_zone && (
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5 flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" /> {sensor.floor_zone}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground">{triggerCount30d} events (30d)</span>
|
||||
<SensorBadge state={sensor.state} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{sensor.room_id && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<span>Room:</span>
|
||||
<span className="font-medium text-foreground capitalize">{sensor.room_id}</span>
|
||||
</div>
|
||||
)}
|
||||
{sensor.near_crac && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Wind className="w-3 h-3 shrink-0" />
|
||||
<span className="font-medium text-foreground">Near CRAC</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-span-2 flex items-center gap-1.5 text-muted-foreground">
|
||||
<span>{sensor.under_floor ? "Under raised floor" : "Above floor level"}</span>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center gap-1.5 text-[10px] text-muted-foreground mt-0.5">
|
||||
<Clock className="w-3 h-3 shrink-0" />
|
||||
<span>{lastTriggeredText}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detected && (
|
||||
<div className="rounded-lg bg-destructive/10 border border-destructive/30 px-3 py-2 text-xs text-destructive font-medium">
|
||||
Water detected — inspect immediately and isolate if necessary
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LeakDetectionPage() {
|
||||
const [sensors, setSensors] = useState<LeakSensorStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setSensors(await fetchLeakStatus(SITE_ID));
|
||||
} catch { toast.error("Failed to load leak sensor data"); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 15_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
const active = sensors.filter((s) => s.state === "detected");
|
||||
const offline = sensors.filter((s) => s.state === "unknown");
|
||||
const dry = sensors.filter((s) => s.state === "clear");
|
||||
|
||||
// Group by floor_zone
|
||||
const byZone = sensors.reduce<Record<string, LeakSensorStatus[]>>((acc, s) => {
|
||||
const zone = s.floor_zone ?? "Unassigned";
|
||||
(acc[zone] ??= []).push(s);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const zoneEntries = Object.entries(byZone).sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
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">Leak Detection</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — water sensor site map · refreshes every 15s</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!loading && (
|
||||
<span className={cn(
|
||||
"flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-full",
|
||||
active.length > 0
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-green-500/10 text-green-400",
|
||||
)}>
|
||||
{active.length > 0
|
||||
? <><AlertTriangle className="w-3.5 h-3.5" /> {active.length} leak{active.length > 1 ? "s" : ""} detected</>
|
||||
: <><CheckCircle2 className="w-3.5 h-3.5" /> No leaks detected</>}
|
||||
</span>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{/* KPI bar */}
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{
|
||||
label: "Active Leaks",
|
||||
value: active.length,
|
||||
sub: "require immediate action",
|
||||
cls: active.length > 0 ? "border-destructive/40 bg-destructive/5 text-destructive" : "border-border bg-muted/10 text-green-400",
|
||||
},
|
||||
{
|
||||
label: "Sensors Clear",
|
||||
value: dry.length,
|
||||
sub: `of ${sensors.length} total sensors`,
|
||||
cls: "border-border bg-muted/10 text-foreground",
|
||||
},
|
||||
{
|
||||
label: "Offline",
|
||||
value: offline.length,
|
||||
sub: "no signal",
|
||||
cls: offline.length > 0 ? "border-amber-500/30 bg-amber-500/5 text-amber-400" : "border-border bg-muted/10 text-muted-foreground",
|
||||
},
|
||||
].map(({ label, value, sub, cls }) => (
|
||||
<div key={label} className={cn("rounded-xl border px-4 py-3", cls)}>
|
||||
<p className="text-[10px] uppercase tracking-wider mb-1 opacity-70">{label}</p>
|
||||
<p className="text-2xl font-bold tabular-nums leading-none">{value}</p>
|
||||
<p className="text-[10px] opacity-60 mt-1">{sub}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active leak alert */}
|
||||
{!loading && active.length > 0 && (
|
||||
<div className="rounded-xl border border-destructive/50 bg-destructive/10 px-5 py-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Droplets className="w-5 h-5 text-destructive shrink-0" />
|
||||
<p className="text-sm font-semibold text-destructive">
|
||||
{active.length} water leak{active.length > 1 ? "s" : ""} detected — immediate action required
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{active.map((s) => (
|
||||
<p key={s.sensor_id} className="text-xs text-destructive/80">
|
||||
• <strong>{s.sensor_id}</strong>
|
||||
{s.floor_zone ? ` — ${s.floor_zone}` : ""}
|
||||
{s.near_crac ? ` (near ${s.near_crac})` : ""}
|
||||
{s.under_floor ? " — under raised floor" : ""}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zone panels */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-48" />)}
|
||||
</div>
|
||||
) : (
|
||||
zoneEntries.map(([zone, zoneSensors]) => {
|
||||
const zoneActive = zoneSensors.filter((s) => s.state === "detected");
|
||||
return (
|
||||
<div key={zone}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{zone}</h2>
|
||||
{zoneActive.length > 0 && (
|
||||
<span className="text-[10px] font-semibold text-destructive bg-destructive/10 px-2 py-0.5 rounded-full">
|
||||
{zoneActive.length} LEAK
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{zoneSensors.map((s) => <SensorCard key={s.sensor_id} sensor={s} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
440
frontend/app/(dashboard)/maintenance/page.tsx
Normal file
440
frontend/app/(dashboard)/maintenance/page.tsx
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
fetchMaintenanceWindows, createMaintenanceWindow, deleteMaintenanceWindow,
|
||||
type MaintenanceWindow,
|
||||
} 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 { Button } from "@/components/ui/button";
|
||||
import {
|
||||
CalendarClock, Plus, Trash2, CheckCircle2, Clock, AlertTriangle,
|
||||
BellOff, RefreshCw, X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
const TARGET_GROUPS = [
|
||||
{
|
||||
label: "Site",
|
||||
targets: [{ value: "all", label: "Entire Site" }],
|
||||
},
|
||||
{
|
||||
label: "Halls",
|
||||
targets: [
|
||||
{ value: "hall-a", label: "Hall A" },
|
||||
{ value: "hall-b", label: "Hall B" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Racks — Hall A",
|
||||
targets: [
|
||||
{ value: "rack-A01", label: "Rack A01" },
|
||||
{ value: "rack-A02", label: "Rack A02" },
|
||||
{ value: "rack-A03", label: "Rack A03" },
|
||||
{ value: "rack-A04", label: "Rack A04" },
|
||||
{ value: "rack-A05", label: "Rack A05" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Racks — Hall B",
|
||||
targets: [
|
||||
{ value: "rack-B01", label: "Rack B01" },
|
||||
{ value: "rack-B02", label: "Rack B02" },
|
||||
{ value: "rack-B03", label: "Rack B03" },
|
||||
{ value: "rack-B04", label: "Rack B04" },
|
||||
{ value: "rack-B05", label: "Rack B05" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "CRAC Units",
|
||||
targets: [
|
||||
{ value: "crac-01", label: "CRAC-01" },
|
||||
{ value: "crac-02", label: "CRAC-02" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "UPS",
|
||||
targets: [
|
||||
{ value: "ups-01", label: "UPS-01" },
|
||||
{ value: "ups-02", label: "UPS-02" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Generator",
|
||||
targets: [
|
||||
{ value: "gen-01", label: "Generator GEN-01" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Flat list for looking up labels
|
||||
const TARGETS_FLAT = TARGET_GROUPS.flatMap(g => g.targets);
|
||||
|
||||
const statusCfg = {
|
||||
active: { label: "Active", cls: "bg-green-500/10 text-green-400 border-green-500/20", icon: CheckCircle2 },
|
||||
scheduled: { label: "Scheduled", cls: "bg-blue-500/10 text-blue-400 border-blue-500/20", icon: Clock },
|
||||
expired: { label: "Expired", cls: "bg-muted/50 text-muted-foreground border-border", icon: AlertTriangle },
|
||||
};
|
||||
|
||||
function StatusChip({ status }: { status: MaintenanceWindow["status"] }) {
|
||||
const cfg = statusCfg[status];
|
||||
const Icon = cfg.icon;
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border uppercase tracking-wide", cfg.cls)}>
|
||||
<Icon className="w-3 h-3" /> {cfg.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDt(iso: string): string {
|
||||
return new Date(iso).toLocaleString([], { dateStyle: "short", timeStyle: "short" });
|
||||
}
|
||||
|
||||
// 7-day timeline strip
|
||||
const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
function TimelineStrip({ windows }: { windows: MaintenanceWindow[] }) {
|
||||
const relevant = windows.filter(w => w.status === "active" || w.status === "scheduled");
|
||||
if (relevant.length === 0) return null;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const totalMs = 7 * 24 * 3600_000;
|
||||
|
||||
// Day labels
|
||||
const days = Array.from({ length: 7 }, (_, i) => {
|
||||
const d = new Date(today.getTime() + i * 24 * 3600_000);
|
||||
return DAY_LABELS[d.getDay()];
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-muted/10 p-4 space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
7-Day Maintenance Timeline
|
||||
</p>
|
||||
|
||||
{/* Day column labels */}
|
||||
<div className="relative">
|
||||
<div className="flex text-[10px] text-muted-foreground mb-1">
|
||||
{days.map((day, i) => (
|
||||
<div key={i} className="flex-1 text-center">{day}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid lines */}
|
||||
<div className="relative h-auto">
|
||||
<div className="absolute inset-0 flex pointer-events-none">
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<div key={i} className="flex-1 border-l border-border/30 last:border-r" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Window bars */}
|
||||
<div className="space-y-1.5 pt-1 pb-1">
|
||||
{relevant.map(w => {
|
||||
const startMs = Math.max(0, new Date(w.start_dt).getTime() - today.getTime());
|
||||
const endMs = Math.min(totalMs, new Date(w.end_dt).getTime() - today.getTime());
|
||||
if (endMs <= 0 || startMs >= totalMs) return null;
|
||||
|
||||
const leftPct = (startMs / totalMs) * 100;
|
||||
const widthPct = ((endMs - startMs) / totalMs) * 100;
|
||||
|
||||
const barCls = w.status === "active"
|
||||
? "bg-green-500/30 border-green-500/50 text-green-300"
|
||||
: "bg-blue-500/20 border-blue-500/40 text-blue-300";
|
||||
|
||||
return (
|
||||
<div key={w.id} className="relative h-6">
|
||||
<div
|
||||
className={cn("absolute h-full rounded border text-[10px] font-medium flex items-center px-1.5 overflow-hidden", barCls)}
|
||||
style={{ left: `${leftPct}%`, width: `${widthPct}%`, minWidth: "4px" }}
|
||||
>
|
||||
<span className="truncate">{w.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Defaults for new window form: now → +2h
|
||||
function defaultStart() {
|
||||
const d = new Date();
|
||||
d.setSeconds(0, 0);
|
||||
return d.toISOString().slice(0, 16);
|
||||
}
|
||||
function defaultEnd() {
|
||||
const d = new Date(Date.now() + 2 * 3600_000);
|
||||
d.setSeconds(0, 0);
|
||||
return d.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
export default function MaintenancePage() {
|
||||
const [windows, setWindows] = useState<MaintenanceWindow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState("");
|
||||
const [target, setTarget] = useState("all");
|
||||
const [startDt, setStartDt] = useState(defaultStart);
|
||||
const [endDt, setEndDt] = useState(defaultEnd);
|
||||
const [suppress, setSuppress] = useState(true);
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchMaintenanceWindows(SITE_ID);
|
||||
setWindows(data);
|
||||
} catch { toast.error("Failed to load maintenance windows"); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const targetLabel = TARGETS_FLAT.find(t => t.value === target)?.label ?? target;
|
||||
await createMaintenanceWindow({
|
||||
site_id: SITE_ID,
|
||||
title: title.trim(),
|
||||
target,
|
||||
target_label: targetLabel,
|
||||
start_dt: new Date(startDt).toISOString(),
|
||||
end_dt: new Date(endDt).toISOString(),
|
||||
suppress_alarms: suppress,
|
||||
notes: notes.trim(),
|
||||
});
|
||||
await load();
|
||||
toast.success("Maintenance window created");
|
||||
setShowForm(false);
|
||||
setTitle(""); setNotes(""); setStartDt(defaultStart()); setEndDt(defaultEnd());
|
||||
} catch { toast.error("Failed to create maintenance window"); }
|
||||
finally { setSubmitting(false); }
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setDeleting(id);
|
||||
try { await deleteMaintenanceWindow(id); toast.success("Maintenance window deleted"); await load(); }
|
||||
catch { toast.error("Failed to delete maintenance window"); }
|
||||
finally { setDeleting(null); }
|
||||
}
|
||||
|
||||
const active = windows.filter(w => w.status === "active").length;
|
||||
const scheduled = windows.filter(w => w.status === "scheduled").length;
|
||||
|
||||
return (
|
||||
<PageShell className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Maintenance Windows</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — planned outages & alarm suppression</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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" />
|
||||
</button>
|
||||
<Button size="sm" onClick={() => { setShowForm(true); setStartDt(defaultStart()); setEndDt(defaultEnd()); }} className="flex items-center gap-1.5">
|
||||
<Plus className="w-3.5 h-3.5" /> New Window
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{!loading && (
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
{active > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span className="font-semibold text-green-400">{active}</span>
|
||||
<span className="text-muted-foreground">active</span>
|
||||
</span>
|
||||
)}
|
||||
{scheduled > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<span className="font-semibold">{scheduled}</span>
|
||||
<span className="text-muted-foreground">scheduled</span>
|
||||
</span>
|
||||
)}
|
||||
{active === 0 && scheduled === 0 && (
|
||||
<span className="text-muted-foreground text-xs">No active or scheduled maintenance</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create form */}
|
||||
{showForm && (
|
||||
<Card className="border-primary/30">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<CalendarClock className="w-4 h-4 text-primary" /> New Maintenance Window
|
||||
</CardTitle>
|
||||
<button onClick={() => setShowForm(false)} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="sm:col-span-2 space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Title *</label>
|
||||
<input
|
||||
required
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="e.g. UPS-01 firmware update"
|
||||
className="w-full h-9 rounded-md border border-border bg-muted/30 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Target</label>
|
||||
<select
|
||||
value={target}
|
||||
onChange={e => setTarget(e.target.value)}
|
||||
className="w-full h-9 rounded-md border border-border bg-muted/30 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{TARGET_GROUPS.map(group => (
|
||||
<optgroup key={group.label} label={group.label}>
|
||||
{group.targets.map(t => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1 flex items-end gap-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer text-sm mb-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={suppress}
|
||||
onChange={e => setSuppress(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-xs font-medium">Suppress alarms</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Start</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
required
|
||||
value={startDt}
|
||||
onChange={e => setStartDt(e.target.value)}
|
||||
className="w-full h-9 rounded-md border border-border bg-muted/30 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">End</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
required
|
||||
value={endDt}
|
||||
onChange={e => setEndDt(e.target.value)}
|
||||
className="w-full h-9 rounded-md border border-border bg-muted/30 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2 space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Notes (optional)</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Reason, affected systems, contacts…"
|
||||
className="w-full rounded-md border border-border bg-muted/30 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setShowForm(false)}>Cancel</Button>
|
||||
<Button type="submit" size="sm" disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create Window"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Windows list */}
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-20" />)}
|
||||
</div>
|
||||
) : windows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3 text-muted-foreground">
|
||||
<CalendarClock className="w-8 h-8 opacity-30" />
|
||||
<p className="text-sm">No maintenance windows</p>
|
||||
<p className="text-xs">Click "New Window" to schedule planned downtime</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 7-day timeline strip */}
|
||||
<TimelineStrip windows={windows} />
|
||||
|
||||
<div className="space-y-3">
|
||||
{[...windows].sort((a, b) => {
|
||||
const order = { active: 0, scheduled: 1, expired: 2 };
|
||||
return (order[a.status] ?? 9) - (order[b.status] ?? 9) || a.start_dt.localeCompare(b.start_dt);
|
||||
}).map(w => (
|
||||
<Card key={w.id} className={cn(
|
||||
"border",
|
||||
w.status === "active" && "border-green-500/30",
|
||||
w.status === "scheduled" && "border-blue-500/20",
|
||||
w.status === "expired" && "opacity-60",
|
||||
)}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm truncate">{w.title}</span>
|
||||
<StatusChip status={w.status} />
|
||||
{w.suppress_alarms && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<BellOff className="w-3 h-3" /> Alarms suppressed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
|
||||
<span>Target: <strong className="text-foreground">{w.target_label}</strong></span>
|
||||
<span>{formatDt(w.start_dt)} → {formatDt(w.end_dt)}</span>
|
||||
</div>
|
||||
{w.notes && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{w.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive shrink-0"
|
||||
disabled={deleting === w.id}
|
||||
onClick={() => handleDelete(w.id)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
256
frontend/app/(dashboard)/network/page.tsx
Normal file
256
frontend/app/(dashboard)/network/page.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { fetchNetworkStatus, type NetworkSwitchStatus } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Network, Wifi, WifiOff, AlertTriangle, CheckCircle2,
|
||||
RefreshCw, Cpu, HardDrive, Thermometer, Activity,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
function formatUptime(seconds: number | null): string {
|
||||
if (seconds === null) return "—";
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
function StateChip({ state }: { state: NetworkSwitchStatus["state"] }) {
|
||||
const cfg = {
|
||||
up: { label: "Up", icon: CheckCircle2, cls: "bg-green-500/10 text-green-400 border-green-500/20" },
|
||||
degraded: { label: "Degraded", icon: AlertTriangle, cls: "bg-amber-500/10 text-amber-400 border-amber-500/20" },
|
||||
down: { label: "Down", icon: WifiOff, cls: "bg-destructive/10 text-destructive border-destructive/20" },
|
||||
unknown: { label: "Unknown", icon: WifiOff, cls: "bg-muted/50 text-muted-foreground border-border" },
|
||||
}[state] ?? { label: state, icon: WifiOff, cls: "bg-muted/50 text-muted-foreground border-border" };
|
||||
const Icon = cfg.icon;
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wide border", cfg.cls)}>
|
||||
<Icon className="w-3 h-3" /> {cfg.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniBar({ value, max, className }: { value: number; max: number; className?: string }) {
|
||||
const pct = Math.min(100, (value / max) * 100);
|
||||
return (
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div className={cn("h-full rounded-full transition-all", className)} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitchCard({ sw }: { sw: NetworkSwitchStatus }) {
|
||||
const portPct = sw.active_ports !== null ? Math.round((sw.active_ports / sw.port_count) * 100) : null;
|
||||
const stateOk = sw.state === "up";
|
||||
const stateDeg = sw.state === "degraded";
|
||||
|
||||
return (
|
||||
<Card className={cn(
|
||||
"border",
|
||||
sw.state === "down" && "border-destructive/40",
|
||||
sw.state === "degraded" && "border-amber-500/30",
|
||||
)}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-0.5 min-w-0">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Network className={cn(
|
||||
"w-4 h-4 shrink-0",
|
||||
stateOk ? "text-green-400" : stateDeg ? "text-amber-400" : "text-destructive"
|
||||
)} />
|
||||
<span className="truncate">{sw.name}</span>
|
||||
</CardTitle>
|
||||
<p className="text-[10px] text-muted-foreground font-mono">{sw.model}</p>
|
||||
</div>
|
||||
<StateChip state={sw.state} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground mt-1">
|
||||
<span className="capitalize">{sw.role}</span>
|
||||
<span>·</span>
|
||||
<span>{sw.room_id}</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono">{sw.rack_id}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Ports headline */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide">Ports Active</p>
|
||||
<p className="text-base font-bold tabular-nums leading-tight">
|
||||
{sw.active_ports !== null ? Math.round(sw.active_ports) : "—"} / {sw.port_count}
|
||||
</p>
|
||||
{portPct !== null && <p className="text-[10px] text-muted-foreground">{portPct}% utilised</p>}
|
||||
</div>
|
||||
</div>
|
||||
<MiniBar
|
||||
value={sw.active_ports ?? 0}
|
||||
max={sw.port_count}
|
||||
className={portPct !== null && portPct >= 90 ? "bg-amber-500" : "bg-primary"}
|
||||
/>
|
||||
|
||||
{/* Bandwidth */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||
<Activity className="w-3 h-3" /> Ingress
|
||||
</p>
|
||||
<p className="text-sm font-semibold tabular-nums">
|
||||
{sw.bandwidth_in_mbps !== null ? `${sw.bandwidth_in_mbps.toFixed(0)} Mbps` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||
<Activity className="w-3 h-3 rotate-180" /> Egress
|
||||
</p>
|
||||
<p className="text-sm font-semibold tabular-nums">
|
||||
{sw.bandwidth_out_mbps !== null ? `${sw.bandwidth_out_mbps.toFixed(0)} Mbps` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CPU + Mem */}
|
||||
<div className="border-t border-border/40 pt-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="w-3 h-3 text-muted-foreground shrink-0" />
|
||||
<span className="text-[10px] text-muted-foreground w-8">CPU</span>
|
||||
<MiniBar
|
||||
value={sw.cpu_pct ?? 0}
|
||||
max={100}
|
||||
className={
|
||||
(sw.cpu_pct ?? 0) >= 80 ? "bg-destructive" :
|
||||
(sw.cpu_pct ?? 0) >= 60 ? "bg-amber-500" : "bg-green-500"
|
||||
}
|
||||
/>
|
||||
<span className="text-xs font-semibold tabular-nums w-10 text-right">
|
||||
{sw.cpu_pct !== null ? `${sw.cpu_pct.toFixed(0)}%` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="w-3 h-3 text-muted-foreground shrink-0" />
|
||||
<span className="text-[10px] text-muted-foreground w-8">Mem</span>
|
||||
<MiniBar
|
||||
value={sw.mem_pct ?? 0}
|
||||
max={100}
|
||||
className={
|
||||
(sw.mem_pct ?? 0) >= 85 ? "bg-destructive" :
|
||||
(sw.mem_pct ?? 0) >= 70 ? "bg-amber-500" : "bg-blue-500"
|
||||
}
|
||||
/>
|
||||
<span className="text-xs font-semibold tabular-nums w-10 text-right">
|
||||
{sw.mem_pct !== null ? `${sw.mem_pct.toFixed(0)}%` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer stats */}
|
||||
<div className="flex items-center justify-between pt-1 border-t border-border/50 text-[10px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Thermometer className="w-3 h-3" />
|
||||
{sw.temperature_c !== null ? `${sw.temperature_c.toFixed(0)}°C` : "—"}
|
||||
</span>
|
||||
<span>
|
||||
Pkt loss: <span className={cn(
|
||||
"font-semibold",
|
||||
(sw.packet_loss_pct ?? 0) > 1 ? "text-destructive" :
|
||||
(sw.packet_loss_pct ?? 0) > 0.1 ? "text-amber-400" : "text-green-400"
|
||||
)}>
|
||||
{sw.packet_loss_pct !== null ? `${sw.packet_loss_pct.toFixed(2)}%` : "—"}
|
||||
</span>
|
||||
</span>
|
||||
<span>Up: {formatUptime(sw.uptime_s)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NetworkPage() {
|
||||
const [switches, setSwitches] = useState<NetworkSwitchStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchNetworkStatus(SITE_ID);
|
||||
setSwitches(data);
|
||||
} catch { toast.error("Failed to load network data"); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
const down = switches.filter((s) => s.state === "down").length;
|
||||
const degraded = switches.filter((s) => s.state === "degraded").length;
|
||||
const up = switches.filter((s) => s.state === "up").length;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Network Infrastructure</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — switch health · refreshes every 30s</p>
|
||||
</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>
|
||||
|
||||
{/* Summary chips */}
|
||||
{!loading && switches.length > 0 && (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span className="font-semibold">{up}</span>
|
||||
<span className="text-muted-foreground">up</span>
|
||||
</span>
|
||||
{degraded > 0 && (
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-500" />
|
||||
<span className="font-semibold text-amber-400">{degraded}</span>
|
||||
<span className="text-muted-foreground">degraded</span>
|
||||
</span>
|
||||
)}
|
||||
{down > 0 && (
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<span className="w-2 h-2 rounded-full bg-destructive" />
|
||||
<span className="font-semibold text-destructive">{down}</span>
|
||||
<span className="text-muted-foreground">down</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground ml-2">{switches.length} switches total</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Switch cards */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-64" />)}
|
||||
</div>
|
||||
) : switches.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3 text-muted-foreground">
|
||||
<Wifi className="w-8 h-8 opacity-30" />
|
||||
<p className="text-sm">No network switch data available</p>
|
||||
<p className="text-xs text-center">Ensure the simulator is running and network bots are publishing data</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{switches.map((sw) => <SwitchCard key={sw.switch_id} sw={sw} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
858
frontend/app/(dashboard)/power/page.tsx
Normal file
858
frontend/app/(dashboard)/power/page.tsx
Normal file
|
|
@ -0,0 +1,858 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
fetchKpis, fetchRackBreakdown, fetchRoomPowerHistory, fetchUpsStatus, fetchCapacitySummary,
|
||||
fetchGeneratorStatus, fetchAtsStatus, fetchPowerRedundancy, fetchPhaseBreakdown,
|
||||
type KpiData, type RoomPowerBreakdown, type PowerHistoryBucket, type UpsAsset, type CapacitySummary,
|
||||
type GeneratorStatus, type AtsStatus, type PowerRedundancy, type RoomPhase,
|
||||
} from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
|
||||
AreaChart, Area, Cell,
|
||||
} from "recharts";
|
||||
import { Zap, Battery, AlertTriangle, CheckCircle2, Activity, Fuel, ArrowLeftRight, ShieldCheck, Server, Thermometer, Gauge } from "lucide-react";
|
||||
import { UpsDetailSheet } from "@/components/dashboard/ups-detail-sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
const ROOM_COLORS: Record<string, string> = {
|
||||
"hall-a": "oklch(0.62 0.17 212)",
|
||||
"hall-b": "oklch(0.7 0.15 162)",
|
||||
};
|
||||
|
||||
const roomLabels: Record<string, string> = {
|
||||
"hall-a": "Hall A",
|
||||
"hall-b": "Hall B",
|
||||
};
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
// ── Site capacity bar ─────────────────────────────────────────────────────────
|
||||
|
||||
function SiteCapacityBar({ usedKw, capacityKw }: { usedKw: number; capacityKw: number }) {
|
||||
const pct = capacityKw > 0 ? Math.min(100, (usedKw / capacityKw) * 100) : 0;
|
||||
const barColor =
|
||||
pct >= 85 ? "bg-destructive" :
|
||||
pct >= 70 ? "bg-amber-500" :
|
||||
"bg-primary";
|
||||
const textColor =
|
||||
pct >= 85 ? "text-destructive" :
|
||||
pct >= 70 ? "text-amber-400" :
|
||||
"text-green-400";
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-muted/10 px-5 py-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Zap className="w-4 h-4 text-primary" />
|
||||
Site IT Load vs Rated Capacity
|
||||
</div>
|
||||
<span className={cn("text-xs font-semibold", textColor)}>
|
||||
{pct.toFixed(1)}% utilised
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-700", barColor)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
<strong className="text-foreground">{usedKw.toFixed(1)} kW</strong> in use
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-foreground">{(capacityKw - usedKw).toFixed(1)} kW</strong> headroom
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-foreground">{capacityKw.toFixed(0)} kW</strong> rated
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── KPI card ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function KpiCard({ label, value, sub, icon: Icon, accent }: {
|
||||
label: string; value: string; sub?: string; icon: React.ElementType; accent?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className={cn("p-2 rounded-lg", accent ? "bg-primary/10" : "bg-muted")}>
|
||||
<Icon className={cn("w-4 h-4", accent ? "text-primary" : "text-muted-foreground")} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
{sub && <p className="text-[10px] text-muted-foreground">{sub}</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── UPS card ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function UpsCard({ ups, onClick }: { ups: UpsAsset; onClick: () => void }) {
|
||||
const onBattery = ups.state === "battery";
|
||||
const overload = ups.state === "overload";
|
||||
const abnormal = onBattery || overload;
|
||||
const charge = ups.charge_pct ?? 0;
|
||||
const runtime = ups.runtime_min ?? null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn("border cursor-pointer hover:border-primary/40 transition-colors",
|
||||
overload && "border-destructive/50",
|
||||
onBattery && !overload && "border-amber-500/40",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Battery className="w-4 h-4 text-primary" />
|
||||
{ups.ups_id.toUpperCase()}
|
||||
</CardTitle>
|
||||
<span className={cn(
|
||||
"flex items-center gap-1.5 text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
|
||||
overload ? "bg-destructive/10 text-destructive" :
|
||||
onBattery ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-green-500/10 text-green-400"
|
||||
)}>
|
||||
{abnormal ? <AlertTriangle className="w-3 h-3" /> : <CheckCircle2 className="w-3 h-3" />}
|
||||
{overload ? "Overloaded" : onBattery ? "On Battery" : "Mains"}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Battery charge */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[11px] text-muted-foreground">
|
||||
<span>Battery charge</span>
|
||||
<span className="font-medium text-foreground">{ups.charge_pct !== null ? `${ups.charge_pct}%` : "—"}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-500",
|
||||
charge < 50 ? "bg-destructive" : charge < 80 ? "bg-amber-500" : "bg-green-500"
|
||||
)}
|
||||
style={{ width: `${charge}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-xs">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Load</p>
|
||||
<p className={cn("font-semibold",
|
||||
(ups.load_pct ?? 0) >= 95 ? "text-destructive" :
|
||||
(ups.load_pct ?? 0) >= 85 ? "text-amber-400" : "",
|
||||
)}>
|
||||
{ups.load_pct !== null ? `${ups.load_pct}%` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Runtime</p>
|
||||
<p className={cn(
|
||||
"font-semibold",
|
||||
runtime !== null && runtime < 5 ? "text-destructive" :
|
||||
runtime !== null && runtime < 15 ? "text-amber-400" : ""
|
||||
)}>
|
||||
{runtime !== null ? `${Math.round(runtime)} min` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Voltage</p>
|
||||
<p className={cn("font-semibold",
|
||||
ups.voltage_v !== null && (ups.voltage_v < 210 || ups.voltage_v > 250) ? "text-amber-400" : "",
|
||||
)}>
|
||||
{ups.voltage_v !== null ? `${ups.voltage_v} V` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Runtime bar */}
|
||||
{runtime !== null && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>Est. runtime remaining</span>
|
||||
<span className={cn(
|
||||
"font-medium",
|
||||
runtime < 5 ? "text-destructive" :
|
||||
runtime < 15 ? "text-amber-400" : "text-green-400"
|
||||
)}>
|
||||
{runtime < 5 ? "Critical" : runtime < 15 ? "Low" : "OK"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-500",
|
||||
runtime < 5 ? "bg-destructive" :
|
||||
runtime < 15 ? "bg-amber-500" : "bg-green-500"
|
||||
)}
|
||||
style={{ width: `${Math.min(100, (runtime / 120) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-muted-foreground/50 text-right">Click for details</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Rack bar chart ────────────────────────────────────────────────────────────
|
||||
|
||||
function RackPowerChart({ rooms }: { rooms: RoomPowerBreakdown[] }) {
|
||||
const [activeRoom, setActiveRoom] = useState(rooms[0]?.room_id ?? "");
|
||||
const room = rooms.find((r) => r.room_id === activeRoom);
|
||||
|
||||
if (!room) return null;
|
||||
|
||||
const maxKw = Math.max(...room.racks.map((r) => r.power_kw ?? 0), 1);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Per-Rack Power (kW)</CardTitle>
|
||||
<Tabs value={activeRoom} onValueChange={setActiveRoom}>
|
||||
<TabsList className="h-7">
|
||||
{rooms.map((r) => (
|
||||
<TabsTrigger key={r.room_id} value={r.room_id} className="text-xs px-2 py-0.5">
|
||||
{roomLabels[r.room_id] ?? r.room_id}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3 mb-3 text-[10px] text-muted-foreground">
|
||||
{[
|
||||
{ color: "oklch(0.62 0.17 212)", label: "Normal" },
|
||||
{ color: "oklch(0.68 0.14 162)", label: "Moderate" },
|
||||
{ color: "oklch(0.65 0.20 45)", label: "High (≥7.5 kW)" },
|
||||
{ color: "oklch(0.55 0.22 25)", label: "Critical (≥9.5 kW)" },
|
||||
].map(({ color, label }) => (
|
||||
<span key={label} className="flex items-center gap-1">
|
||||
<span className="w-3 h-2 rounded-sm inline-block" style={{ backgroundColor: color }} />
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={room.racks} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
|
||||
<XAxis
|
||||
dataKey="rack_id"
|
||||
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) => v.replace("rack-", "")}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
domain={[0, Math.ceil(maxKw * 1.2)]}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "12px" }}
|
||||
formatter={(v) => [`${v} kW`, "Power"]}
|
||||
labelFormatter={(l) => l}
|
||||
/>
|
||||
<ReferenceLine y={7.5} stroke="oklch(0.72 0.18 84)" strokeDasharray="4 4" strokeWidth={1}
|
||||
label={{ value: "Warn 7.5kW", fontSize: 9, fill: "oklch(0.72 0.18 84)", position: "insideTopRight" }} />
|
||||
<ReferenceLine y={9.5} stroke="oklch(0.55 0.22 25)" strokeDasharray="4 4" strokeWidth={1}
|
||||
label={{ value: "Crit 9.5kW", fontSize: 9, fill: "oklch(0.55 0.22 25)", position: "insideTopRight" }} />
|
||||
<Bar dataKey="power_kw" radius={[3, 3, 0, 0]} maxBarSize={32}>
|
||||
{room.racks.map((r) => (
|
||||
<Cell
|
||||
key={r.rack_id}
|
||||
fill={
|
||||
(r.power_kw ?? 0) >= 9.5 ? "oklch(0.55 0.22 25)" :
|
||||
(r.power_kw ?? 0) >= 7.5 ? "oklch(0.65 0.20 45)" :
|
||||
(r.power_kw ?? 0) >= 4.0 ? "oklch(0.68 0.14 162)" :
|
||||
ROOM_COLORS[room.room_id] ?? "oklch(0.62 0.17 212)"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Room power history chart ──────────────────────────────────────────────────
|
||||
|
||||
function RoomPowerHistoryChart({ data }: { data: PowerHistoryBucket[] }) {
|
||||
type Row = { time: string; [room: string]: string | number };
|
||||
const bucketMap = new Map<string, Row>();
|
||||
for (const row of data) {
|
||||
const time = formatTime(row.bucket);
|
||||
if (!bucketMap.has(time)) bucketMap.set(time, { time });
|
||||
bucketMap.get(time)![row.room_id] = row.total_kw;
|
||||
}
|
||||
const chartData = Array.from(bucketMap.values());
|
||||
const roomIds = [...new Set(data.map((d) => d.room_id))].sort();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Power by Room</CardTitle>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{roomIds.map((id) => (
|
||||
<span key={id} className="flex items-center gap-1">
|
||||
<span className="w-3 h-0.5 inline-block" style={{ backgroundColor: ROOM_COLORS[id] }} />
|
||||
{roomLabels[id] ?? id}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chartData.length === 0 ? (
|
||||
<div className="h-[200px] flex items-center justify-center text-sm text-muted-foreground">
|
||||
Waiting for data...
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
||||
<defs>
|
||||
{roomIds.map((id) => (
|
||||
<linearGradient key={id} id={`grad-${id}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={ROOM_COLORS[id]} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={ROOM_COLORS[id]} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
|
||||
<YAxis tick={{ fontSize: 10, 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: "12px" }}
|
||||
formatter={(v, name) => [`${v} kW`, roomLabels[String(name)] ?? String(name)]}
|
||||
/>
|
||||
{roomIds.map((id) => (
|
||||
<Area key={id} type="monotone" dataKey={id} stroke={ROOM_COLORS[id]} fill={`url(#grad-${id})`} strokeWidth={2} dot={false} />
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Generator card ────────────────────────────────────────────────
|
||||
|
||||
function GeneratorCard({ gen }: { gen: GeneratorStatus }) {
|
||||
const faulted = gen.state === "fault";
|
||||
const running = gen.state === "running" || gen.state === "test";
|
||||
const fuel = gen.fuel_pct ?? 0;
|
||||
const stateLabel = { standby: "Standby", running: "Running", test: "Test Run", fault: "FAULT", unknown: "Unknown" }[gen.state] ?? gen.state;
|
||||
|
||||
return (
|
||||
<Card className={cn("border", faulted && "border-destructive/50")}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Fuel className="w-4 h-4 text-primary" />
|
||||
{gen.gen_id.toUpperCase()}
|
||||
</CardTitle>
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
|
||||
faulted ? "bg-destructive/10 text-destructive" :
|
||||
running ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-green-500/10 text-green-400",
|
||||
)}>
|
||||
{faulted ? <AlertTriangle className="w-3 h-3 inline mr-0.5" /> : <CheckCircle2 className="w-3 h-3 inline mr-0.5" />}
|
||||
{stateLabel}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[11px] text-muted-foreground">
|
||||
<span>Fuel level</span>
|
||||
<span className={cn("font-medium", fuel < 10 ? "text-destructive" : fuel < 25 ? "text-amber-400" : "text-foreground")}>
|
||||
{gen.fuel_pct != null ? `${gen.fuel_pct.toFixed(1)}%` : "—"}
|
||||
{gen.fuel_litres != null ? ` (${gen.fuel_litres.toFixed(0)} L)` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div className={cn("h-full rounded-full transition-all duration-500",
|
||||
fuel < 10 ? "bg-destructive" : fuel < 25 ? "bg-amber-500" : "bg-green-500"
|
||||
)} style={{ width: `${fuel}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div><p className="text-muted-foreground">Load</p>
|
||||
<p className={cn("font-semibold",
|
||||
(gen.load_pct ?? 0) >= 95 ? "text-destructive" :
|
||||
(gen.load_pct ?? 0) >= 85 ? "text-amber-400" : ""
|
||||
)}>
|
||||
{gen.load_kw != null ? `${gen.load_kw} kW (${gen.load_pct}%)` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div><p className="text-muted-foreground">Run hours</p>
|
||||
<p className="font-semibold">{gen.run_hours != null ? `${gen.run_hours.toFixed(0)} h` : "—"}</p></div>
|
||||
<div><p className="text-muted-foreground">Output voltage</p>
|
||||
<p className="font-semibold">{gen.voltage_v != null && gen.voltage_v > 0 ? `${gen.voltage_v} V` : "—"}</p></div>
|
||||
<div><p className="text-muted-foreground">Frequency</p>
|
||||
<p className="font-semibold">{gen.frequency_hz != null && gen.frequency_hz > 0 ? `${gen.frequency_hz} Hz` : "—"}</p></div>
|
||||
<div><p className="text-muted-foreground flex items-center gap-1">
|
||||
<Thermometer className="w-3 h-3" />Coolant
|
||||
</p>
|
||||
<p className={cn("font-semibold",
|
||||
(gen.coolant_temp_c ?? 0) >= 105 ? "text-destructive" :
|
||||
(gen.coolant_temp_c ?? 0) >= 95 ? "text-amber-400" : ""
|
||||
)}>
|
||||
{gen.coolant_temp_c != null ? `${gen.coolant_temp_c}°C` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div><p className="text-muted-foreground flex items-center gap-1">
|
||||
<Thermometer className="w-3 h-3" />Exhaust
|
||||
</p>
|
||||
<p className="font-semibold">{gen.exhaust_temp_c != null && gen.exhaust_temp_c > 0 ? `${gen.exhaust_temp_c}°C` : "—"}</p>
|
||||
</div>
|
||||
<div><p className="text-muted-foreground flex items-center gap-1">
|
||||
<Gauge className="w-3 h-3" />Oil pressure
|
||||
</p>
|
||||
<p className={cn("font-semibold",
|
||||
gen.oil_pressure_bar !== null && gen.oil_pressure_bar < 2 ? "text-destructive" : ""
|
||||
)}>
|
||||
{gen.oil_pressure_bar != null && gen.oil_pressure_bar > 0 ? `${gen.oil_pressure_bar} bar` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div><p className="text-muted-foreground">Battery</p>
|
||||
<p className="font-semibold">{gen.battery_v != null ? `${gen.battery_v} V` : "—"}</p></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ATS card ──────────────────────────────────────────────────────
|
||||
|
||||
function AtsCard({ ats }: { ats: AtsStatus }) {
|
||||
const onGenerator = ats.active_feed === "generator";
|
||||
const transferring = ats.state === "transferring";
|
||||
const feedLabel: Record<string, string> = {
|
||||
"utility-a": "Utility A", "utility-b": "Utility B", "generator": "Generator",
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn("border", onGenerator && "border-amber-500/40")}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<ArrowLeftRight className="w-4 h-4 text-primary" />
|
||||
{ats.ats_id.toUpperCase()}
|
||||
</CardTitle>
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
|
||||
transferring ? "bg-amber-500/10 text-amber-400 animate-pulse" :
|
||||
onGenerator ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-green-500/10 text-green-400",
|
||||
)}>
|
||||
{transferring ? "Transferring" : onGenerator ? "Generator feed" : "Stable"}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="text-center rounded-lg bg-muted/20 py-3">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Active Feed</p>
|
||||
<p className={cn("text-xl font-bold", onGenerator ? "text-amber-400" : "text-green-400")}>
|
||||
{feedLabel[ats.active_feed] ?? ats.active_feed}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-[11px]">
|
||||
{[
|
||||
{ label: "Utility A", v: ats.utility_a_v, active: ats.active_feed === "utility-a" },
|
||||
{ label: "Utility B", v: ats.utility_b_v, active: ats.active_feed === "utility-b" },
|
||||
{ label: "Generator", v: ats.generator_v, active: ats.active_feed === "generator" },
|
||||
].map(({ label, v, active }) => (
|
||||
<div key={label} className={cn("rounded-md px-2 py-1.5 text-center",
|
||||
active ? "bg-primary/10 border border-primary/20" : "bg-muted/20",
|
||||
)}>
|
||||
<p className="text-muted-foreground">{label}</p>
|
||||
<p className={cn("font-semibold", active ? "text-foreground" : "text-muted-foreground")}>
|
||||
{v != null && v > 0 ? `${v.toFixed(0)} V` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px] text-muted-foreground border-t border-border/30 pt-2">
|
||||
<span>Transfers: <strong className="text-foreground">{ats.transfer_count}</strong></span>
|
||||
{ats.last_transfer_ms != null && (
|
||||
<span>Last xfer: <strong className="text-foreground">{ats.last_transfer_ms} ms</strong></span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Redundancy banner ─────────────────────────────────────────────
|
||||
|
||||
function RedundancyBanner({ r }: { r: PowerRedundancy }) {
|
||||
const color =
|
||||
r.level === "2N" ? "border-green-500/30 bg-green-500/5 text-green-400" :
|
||||
r.level === "N+1" ? "border-amber-500/30 bg-amber-500/5 text-amber-400" :
|
||||
"border-destructive/30 bg-destructive/5 text-destructive";
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between rounded-xl border px-5 py-3", color)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck className="w-5 h-5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-bold">Power Redundancy: {r.level}</p>
|
||||
<p className="text-xs opacity-70">{r.notes}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-xs opacity-80 space-y-0.5">
|
||||
<p>UPS online: {r.ups_online}/{r.ups_total}</p>
|
||||
<p>Generator: {r.generator_ok ? "available" : "unavailable"}</p>
|
||||
<p>Feed: {r.ats_active_feed ?? "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Phase imbalance table ──────────────────────────────────────────
|
||||
|
||||
function PhaseImbalanceTable({ rooms }: { rooms: RoomPhase[] }) {
|
||||
const allRacks = rooms.flatMap(r => r.racks).filter(r => (r.imbalance_pct ?? 0) > 5);
|
||||
if (allRacks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400" />
|
||||
Phase Imbalance — {allRacks.length} rack{allRacks.length !== 1 ? "s" : ""} flagged
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b border-border/30">
|
||||
<th className="text-left py-1.5 pr-3 font-medium">Rack</th>
|
||||
<th className="text-right py-1.5 pr-3 font-medium">Phase A kW</th>
|
||||
<th className="text-right py-1.5 pr-3 font-medium">Phase B kW</th>
|
||||
<th className="text-right py-1.5 pr-3 font-medium">Phase C kW</th>
|
||||
<th className="text-right py-1.5 font-medium">Imbalance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allRacks.map(rack => {
|
||||
const imb = rack.imbalance_pct ?? 0;
|
||||
const crit = imb >= 15;
|
||||
return (
|
||||
<tr key={rack.rack_id} className="border-b border-border/10">
|
||||
<td className="py-1.5 pr-3 font-medium">{rack.rack_id}</td>
|
||||
<td className="text-right pr-3 tabular-nums">{rack.phase_a_kw?.toFixed(2) ?? "—"}</td>
|
||||
<td className="text-right pr-3 tabular-nums">{rack.phase_b_kw?.toFixed(2) ?? "—"}</td>
|
||||
<td className="text-right pr-3 tabular-nums">{rack.phase_c_kw?.toFixed(2) ?? "—"}</td>
|
||||
<td className={cn("text-right tabular-nums font-semibold", crit ? "text-destructive" : "text-amber-400")}>
|
||||
{imb.toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PowerPage() {
|
||||
const [kpis, setKpis] = useState<KpiData | null>(null);
|
||||
const [racks, setRacks] = useState<RoomPowerBreakdown[]>([]);
|
||||
const [history, setHistory] = useState<PowerHistoryBucket[]>([]);
|
||||
const [ups, setUps] = useState<UpsAsset[]>([]);
|
||||
const [capacity, setCapacity] = useState<CapacitySummary | null>(null);
|
||||
const [generators, setGenerators] = useState<GeneratorStatus[]>([]);
|
||||
const [atsUnits, setAtsUnits] = useState<AtsStatus[]>([]);
|
||||
const [redundancy, setRedundancy] = useState<PowerRedundancy | null>(null);
|
||||
const [phases, setPhases] = useState<RoomPhase[]>([]);
|
||||
const [historyHours, setHistoryHours] = useState(6);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [phaseExpanded, setPhaseExpanded] = useState(false);
|
||||
const [selectedUps, setSelectedUps] = useState<typeof ups[0] | null>(null);
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
try {
|
||||
const h = await fetchRoomPowerHistory(SITE_ID, historyHours);
|
||||
setHistory(h);
|
||||
} catch { /* keep stale */ }
|
||||
}, [historyHours]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [k, r, h, u, cap, g, a, red, ph] = await Promise.all([
|
||||
fetchKpis(SITE_ID),
|
||||
fetchRackBreakdown(SITE_ID),
|
||||
fetchRoomPowerHistory(SITE_ID, historyHours),
|
||||
fetchUpsStatus(SITE_ID),
|
||||
fetchCapacitySummary(SITE_ID),
|
||||
fetchGeneratorStatus(SITE_ID).catch(() => []),
|
||||
fetchAtsStatus(SITE_ID).catch(() => []),
|
||||
fetchPowerRedundancy(SITE_ID).catch(() => null),
|
||||
fetchPhaseBreakdown(SITE_ID).catch(() => []),
|
||||
]);
|
||||
setKpis(k);
|
||||
setRacks(r);
|
||||
setHistory(h);
|
||||
setUps(u);
|
||||
setCapacity(cap);
|
||||
setGenerators(g);
|
||||
setAtsUnits(a);
|
||||
setRedundancy(red);
|
||||
setPhases(ph);
|
||||
} catch {
|
||||
// keep stale data
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [historyHours]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => { loadHistory(); }, [historyHours, loadHistory]);
|
||||
|
||||
const totalKw = kpis?.total_power_kw ?? 0;
|
||||
const hallAKw = racks.find((r) => r.room_id === "hall-a")?.racks.reduce((s, r) => s + (r.power_kw ?? 0), 0) ?? 0;
|
||||
const hallBKw = racks.find((r) => r.room_id === "hall-b")?.racks.reduce((s, r) => s + (r.power_kw ?? 0), 0) ?? 0;
|
||||
const siteCapacity = capacity ? capacity.rooms.reduce((s, r) => s + r.power.capacity_kw, 0) : 0;
|
||||
|
||||
// Phase summary data
|
||||
const allPhaseRacks = phases.flatMap(r => r.racks);
|
||||
const phaseViolations = allPhaseRacks.filter(r => (r.imbalance_pct ?? 0) > 5);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Power Management</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — refreshes every 30s</p>
|
||||
</div>
|
||||
|
||||
{/* Internal anchor sub-nav */}
|
||||
<div className="sticky top-14 z-20 -mx-6 px-6 py-2 bg-background/95 backdrop-blur-sm border-b border-border/30">
|
||||
<nav className="flex gap-1">
|
||||
{[
|
||||
{ label: "Overview", href: "#power-overview" },
|
||||
{ label: "UPS", href: "#power-ups" },
|
||||
{ label: "Generator", href: "#power-generator" },
|
||||
{ label: "Transfer Switch", href: "#power-ats" },
|
||||
{ label: "Phase Analysis", href: "#power-phase" },
|
||||
].map(({ label, href }) => (
|
||||
<a key={href} href={href} className="px-3 py-1 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors">
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Site capacity bar */}
|
||||
<div id="power-overview">
|
||||
{!loading && siteCapacity > 0 && (
|
||||
<SiteCapacityBar usedKw={totalKw} capacityKw={siteCapacity} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* KPIs */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<KpiCard label="Total Site Load" value={`${totalKw} kW`} icon={Zap} accent />
|
||||
<KpiCard label="PUE" value={kpis?.pue.toFixed(2) ?? "—"} icon={Activity} sub="Target: < 1.4" />
|
||||
<KpiCard label="Hall A" value={`${hallAKw.toFixed(1)} kW`} icon={Zap} />
|
||||
<KpiCard label="Hall B" value={`${hallBKw.toFixed(1)} kW`} icon={Zap} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Power path diagram */}
|
||||
{!loading && redundancy && (
|
||||
<div className="rounded-xl border border-border bg-muted/10 px-5 py-3">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider font-semibold mb-3">Power Path</p>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{[
|
||||
{ label: "Grid", icon: Zap, ok: redundancy.ats_active_feed !== "generator" },
|
||||
{ label: "ATS", icon: ArrowLeftRight, ok: true },
|
||||
{ label: "UPS", icon: Battery, ok: redundancy.ups_online > 0 },
|
||||
{ label: "Racks", icon: Server, ok: true },
|
||||
].map(({ label, icon: Icon, ok }, i, arr) => (
|
||||
<React.Fragment key={label}>
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-3 py-2 border text-xs font-medium",
|
||||
ok ? "border-green-500/30 bg-green-500/5 text-green-400" : "border-amber-500/30 bg-amber-500/5 text-amber-400"
|
||||
)}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</div>
|
||||
{i < arr.length - 1 && (
|
||||
<div className="h-px flex-1 min-w-4 border-t border-dashed border-muted-foreground/30" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{redundancy.ats_active_feed === "generator" && (
|
||||
<span className="ml-auto text-[10px] text-amber-400 font-medium flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" /> Running on generator
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Charts */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Power History</p>
|
||||
<select
|
||||
value={historyHours}
|
||||
onChange={(e) => setHistoryHours(Number(e.target.value))}
|
||||
className="text-xs bg-muted border border-border rounded px-2 py-1 text-foreground"
|
||||
>
|
||||
{[1, 3, 6, 12, 24].map((h) => (
|
||||
<option key={h} value={h}>{h}h</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{loading ? (
|
||||
<>
|
||||
<Skeleton className="h-64" />
|
||||
<Skeleton className="h-64" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{racks.length > 0 && <RackPowerChart rooms={racks} />}
|
||||
<RoomPowerHistoryChart data={history} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Redundancy banner */}
|
||||
{!loading && redundancy && <RedundancyBanner r={redundancy} />}
|
||||
|
||||
{/* UPS */}
|
||||
<div id="power-ups">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">UPS Units</h2>
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Skeleton className="h-48" />
|
||||
<Skeleton className="h-48" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{ups.map((u) => (
|
||||
<UpsCard key={u.ups_id} ups={u} onClick={() => setSelectedUps(u)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Generator */}
|
||||
{(loading || generators.length > 0) && (
|
||||
<div id="power-generator">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">Generators</h2>
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"><Skeleton className="h-48" /></div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{generators.map((g) => <GeneratorCard key={g.gen_id} gen={g} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ATS */}
|
||||
{(loading || atsUnits.length > 0) && (
|
||||
<div id="power-ats">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">Transfer Switches</h2>
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"><Skeleton className="h-40" /></div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{atsUnits.map((a) => <AtsCard key={a.ats_id} ats={a} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UPS detail sheet */}
|
||||
<UpsDetailSheet ups={selectedUps} onClose={() => setSelectedUps(null)} />
|
||||
|
||||
{/* Phase analysis — always visible summary */}
|
||||
<div id="power-phase">
|
||||
{!loading && phases.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">Phase Analysis</h2>
|
||||
{phaseViolations.length === 0 ? (
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-green-500/10 text-green-400">
|
||||
<CheckCircle2 className="w-3 h-3" /> Phase balance OK
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setPhaseExpanded(!phaseExpanded)}
|
||||
className="flex items-center gap-1.5 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 transition-colors"
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{phaseViolations.length} rack{phaseViolations.length !== 1 ? "s" : ""} flagged
|
||||
{phaseExpanded ? " — Hide details" : " — Show details"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phase summary row */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(["Phase A", "Phase B", "Phase C"] as const).map((phase, idx) => {
|
||||
const phaseKey = (["phase_a_kw", "phase_b_kw", "phase_c_kw"] as const)[idx];
|
||||
const total = allPhaseRacks.reduce((s, r) => s + (r[phaseKey] ?? 0), 0);
|
||||
return (
|
||||
<div key={phase} className="rounded-lg bg-muted/20 px-4 py-3 text-center">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">{phase}</p>
|
||||
<p className="text-lg font-bold tabular-nums">{total.toFixed(1)} kW</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Expanded violation table */}
|
||||
{phaseExpanded && phaseViolations.length > 0 && (
|
||||
<PhaseImbalanceTable rooms={phases} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
468
frontend/app/(dashboard)/settings/page.tsx
Normal file
468
frontend/app/(dashboard)/settings/page.tsx
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Settings, Database, Bell, Globe, Sliders, Plug,
|
||||
Save, CheckCircle2, AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
fetchSiteSettings, updateSiteSettings,
|
||||
fetchNotifications, updateNotifications,
|
||||
fetchIntegrations, updateIntegrations,
|
||||
fetchPagePrefs, updatePagePrefs,
|
||||
type SiteSettings, type NotificationSettings,
|
||||
type IntegrationSettings, type PagePrefs,
|
||||
type SensorDevice,
|
||||
} from "@/lib/api";
|
||||
import { SensorTable } from "@/components/settings/SensorTable";
|
||||
import { SensorSheet } from "@/components/settings/SensorSheet";
|
||||
import { SensorDetailSheet } from "@/components/settings/SensorDetailSheet";
|
||||
import { ThresholdEditor } from "@/components/settings/ThresholdEditor";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function FieldGroup({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{label}</label>
|
||||
{children}
|
||||
{hint && <p className="text-[10px] text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextInput({ value, onChange, disabled, placeholder, type = "text" }: {
|
||||
value: string; onChange?: (v: string) => void; disabled?: boolean; placeholder?: string; type?: string;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
onChange={e => onChange?.(e.target.value)}
|
||||
className="w-full border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberInput({ value, onChange, min, max }: { value: number; onChange: (v: number) => void; min?: number; max?: number }) {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={e => onChange(Number(e.target.value))}
|
||||
className="w-full border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
|
||||
checked ? "bg-primary" : "bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform",
|
||||
checked ? "translate-x-4" : "translate-x-0",
|
||||
)} />
|
||||
</button>
|
||||
{label && <span className="text-sm">{label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveButton({ saving, saved, onClick }: { saving: boolean; saved: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<Button size="sm" onClick={onClick} disabled={saving} className="gap-2">
|
||||
{saving ? (
|
||||
<span className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : saved ? (
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{saving ? "Saving..." : saved ? "Saved" : "Save Changes"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3 border-b border-border/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||
{action}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 space-y-4">
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Site Tab ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function SiteTab() {
|
||||
const [form, setForm] = useState<SiteSettings>({ name: "", timezone: "", description: "" });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSiteSettings(SITE_ID).then(setForm).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await updateSiteSettings(SITE_ID, form);
|
||||
setForm(updated);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} catch { /* ignore */ }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const TIMEZONES = [
|
||||
"Asia/Singapore", "Asia/Tokyo", "Asia/Hong_Kong", "Asia/Kuala_Lumpur",
|
||||
"Europe/London", "Europe/Paris", "America/New_York", "America/Los_Angeles",
|
||||
"UTC",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<SectionCard title="Site Information">
|
||||
<FieldGroup label="Site Name">
|
||||
<TextInput value={form.name} onChange={v => setForm(f => ({ ...f, name: v }))} />
|
||||
</FieldGroup>
|
||||
<FieldGroup label="Description">
|
||||
<TextInput value={form.description} onChange={v => setForm(f => ({ ...f, description: v }))} placeholder="Optional description" />
|
||||
</FieldGroup>
|
||||
<FieldGroup label="Timezone">
|
||||
<select
|
||||
value={form.timezone}
|
||||
onChange={e => setForm(f => ({ ...f, timezone: e.target.value }))}
|
||||
className="w-full border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{TIMEZONES.map(tz => <option key={tz} value={tz}>{tz}</option>)}
|
||||
</select>
|
||||
</FieldGroup>
|
||||
<div className="pt-2">
|
||||
<SaveButton saving={saving} saved={saved} onClick={save} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Site Overview">
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
{[
|
||||
{ label: "Site ID", value: SITE_ID },
|
||||
{ label: "Halls", value: "2 (Hall A, Hall B)" },
|
||||
{ label: "Total Racks", value: "80 (40 per hall)" },
|
||||
{ label: "UPS Units", value: "2" },
|
||||
{ label: "Generators", value: "1" },
|
||||
{ label: "CRAC Units", value: "2" },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<p className="text-muted-foreground">{label}</p>
|
||||
<p className="font-medium mt-0.5">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sensors Tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function SensorsTab() {
|
||||
const [editingSensor, setEditingSensor] = useState<SensorDevice | null>(null);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [detailId, setDetailId] = useState<number | null>(null);
|
||||
const [tableKey, setTableKey] = useState(0); // force re-render after save
|
||||
|
||||
const handleSaved = () => {
|
||||
setAddOpen(false);
|
||||
setEditingSensor(null);
|
||||
setTableKey(k => k + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Device-level sensor registry. Each device can be enabled/disabled, and protocol configuration is stored for all supported connection types. Currently only MQTT is active.
|
||||
</div>
|
||||
|
||||
<SensorTable
|
||||
key={tableKey}
|
||||
onAdd={() => setAddOpen(true)}
|
||||
onEdit={s => setEditingSensor(s)}
|
||||
onDetail={id => setDetailId(id)}
|
||||
/>
|
||||
|
||||
<SensorSheet
|
||||
siteId={SITE_ID}
|
||||
sensor={editingSensor}
|
||||
open={addOpen || !!editingSensor}
|
||||
onClose={() => { setAddOpen(false); setEditingSensor(null); }}
|
||||
onSaved={handleSaved}
|
||||
/>
|
||||
|
||||
<SensorDetailSheet
|
||||
sensorId={detailId}
|
||||
onClose={() => setDetailId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Notifications Tab ──────────────────────────────────────────────────────────
|
||||
|
||||
function NotificationsTab() {
|
||||
const [form, setForm] = useState<NotificationSettings>({
|
||||
critical_alarms: true, warning_alarms: true,
|
||||
generator_events: true, maintenance_reminders: true,
|
||||
webhook_url: "", email_recipients: "",
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications(SITE_ID).then(setForm).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await updateNotifications(SITE_ID, form);
|
||||
setForm(updated);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} catch { /* ignore */ }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const toggles: { key: keyof NotificationSettings; label: string; desc: string }[] = [
|
||||
{ key: "critical_alarms", label: "Critical alarms", desc: "Notify on all critical severity alarms" },
|
||||
{ key: "warning_alarms", label: "Warning alarms", desc: "Notify on warning severity alarms" },
|
||||
{ key: "generator_events", label: "Generator events", desc: "Generator start, stop, and fault events" },
|
||||
{ key: "maintenance_reminders", label: "Maintenance reminders", desc: "Upcoming scheduled maintenance windows" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<SectionCard title="Alarm Notifications">
|
||||
<div className="space-y-4">
|
||||
{toggles.map(({ key, label, desc }) => (
|
||||
<div key={key} className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<p className="text-xs text-muted-foreground">{desc}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={form[key] as boolean}
|
||||
onChange={v => setForm(f => ({ ...f, [key]: v }))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Delivery Channels">
|
||||
<FieldGroup label="Webhook URL" hint="POST request sent on each new alarm — leave blank to disable">
|
||||
<TextInput
|
||||
value={form.webhook_url}
|
||||
onChange={v => setForm(f => ({ ...f, webhook_url: v }))}
|
||||
placeholder="https://hooks.example.com/alarm"
|
||||
type="url"
|
||||
/>
|
||||
</FieldGroup>
|
||||
<FieldGroup label="Email Recipients" hint="Comma-separated email addresses">
|
||||
<TextInput
|
||||
value={form.email_recipients}
|
||||
onChange={v => setForm(f => ({ ...f, email_recipients: v }))}
|
||||
placeholder="ops@example.com, oncall@example.com"
|
||||
/>
|
||||
</FieldGroup>
|
||||
</SectionCard>
|
||||
|
||||
<div>
|
||||
<SaveButton saving={saving} saved={saved} onClick={save} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page Preferences Tab ───────────────────────────────────────────────────────
|
||||
|
||||
function PagePrefsTab() {
|
||||
const [form, setForm] = useState<PagePrefs>({ default_time_range_hours: 6, refresh_interval_seconds: 30 });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPagePrefs(SITE_ID).then(setForm).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await updatePagePrefs(SITE_ID, form);
|
||||
setForm(updated);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} catch { /* ignore */ }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<SectionCard title="Dashboard Defaults">
|
||||
<FieldGroup label="Default Time Range" hint="Used by charts on all pages as the initial view">
|
||||
<select
|
||||
value={form.default_time_range_hours}
|
||||
onChange={e => setForm(f => ({ ...f, default_time_range_hours: Number(e.target.value) }))}
|
||||
className="w-full border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{[1, 3, 6, 12, 24].map(h => <option key={h} value={h}>{h} hour{h !== 1 ? "s" : ""}</option>)}
|
||||
</select>
|
||||
</FieldGroup>
|
||||
<FieldGroup label="Auto-refresh Interval" hint="How often pages poll for new data">
|
||||
<select
|
||||
value={form.refresh_interval_seconds}
|
||||
onChange={e => setForm(f => ({ ...f, refresh_interval_seconds: Number(e.target.value) }))}
|
||||
className="w-full border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{[10, 15, 30, 60, 120].map(s => <option key={s} value={s}>Every {s} seconds</option>)}
|
||||
</select>
|
||||
</FieldGroup>
|
||||
<div className="pt-2">
|
||||
<SaveButton saving={saving} saved={saved} onClick={save} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<div className="rounded-lg border border-border/30 bg-muted/10 px-4 py-3 text-xs text-muted-foreground">
|
||||
Per-page configuration (visible panels, default overlays, etc.) is coming in a future update.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Integrations Tab ───────────────────────────────────────────────────────────
|
||||
|
||||
function IntegrationsTab() {
|
||||
const [form, setForm] = useState<IntegrationSettings>({ mqtt_host: "mqtt", mqtt_port: 1883 });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchIntegrations(SITE_ID).then(setForm).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await updateIntegrations(SITE_ID, form);
|
||||
setForm(updated);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} catch { /* ignore */ }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const future = [
|
||||
{ name: "Slack", desc: "Post alarm notifications to a Slack channel" },
|
||||
{ name: "PagerDuty", desc: "Create incidents for critical alarms" },
|
||||
{ name: "Email SMTP", desc: "Send alarm emails via custom SMTP server" },
|
||||
{ name: "Syslog", desc: "Forward alarms to syslog / SIEM" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<SectionCard title="MQTT Broker">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<FieldGroup label="Host" hint="Broker hostname or IP">
|
||||
<TextInput value={form.mqtt_host} onChange={v => setForm(f => ({ ...f, mqtt_host: v }))} />
|
||||
</FieldGroup>
|
||||
<FieldGroup label="Port">
|
||||
<NumberInput value={form.mqtt_port} onChange={v => setForm(f => ({ ...f, mqtt_port: v }))} min={1} max={65535} />
|
||||
</FieldGroup>
|
||||
</div>
|
||||
<div className="rounded-md bg-amber-500/5 border border-amber-500/20 px-3 py-2 text-xs text-amber-400 flex items-start gap-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
Changing the MQTT broker requires a backend restart to take effect.
|
||||
</div>
|
||||
<div>
|
||||
<SaveButton saving={saving} saved={saved} onClick={save} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Future Integrations">
|
||||
<div className="grid gap-2">
|
||||
{future.map(({ name, desc }) => (
|
||||
<div key={name} className="flex items-center justify-between rounded-lg border border-border/30 bg-muted/10 px-3 py-2.5">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{name}</p>
|
||||
<p className="text-xs text-muted-foreground">{desc}</p>
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
|
||||
Coming soon
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-primary" />
|
||||
Settings
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">Site-wide configuration for Singapore DC01</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="site">
|
||||
<TabsList className="h-9 mb-6">
|
||||
<TabsTrigger value="site" className="gap-1.5 text-xs px-3"><Globe className="w-3.5 h-3.5" />Site</TabsTrigger>
|
||||
<TabsTrigger value="sensors" className="gap-1.5 text-xs px-3"><Database className="w-3.5 h-3.5" />Sensors</TabsTrigger>
|
||||
<TabsTrigger value="thresholds" className="gap-1.5 text-xs px-3"><Sliders className="w-3.5 h-3.5" />Thresholds</TabsTrigger>
|
||||
<TabsTrigger value="notifications" className="gap-1.5 text-xs px-3"><Bell className="w-3.5 h-3.5" />Notifications</TabsTrigger>
|
||||
<TabsTrigger value="page-prefs" className="gap-1.5 text-xs px-3"><Settings className="w-3.5 h-3.5" />Page Prefs</TabsTrigger>
|
||||
<TabsTrigger value="integrations" className="gap-1.5 text-xs px-3"><Plug className="w-3.5 h-3.5" />Integrations</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="site"> <SiteTab /> </TabsContent>
|
||||
<TabsContent value="sensors"> <SensorsTab /> </TabsContent>
|
||||
<TabsContent value="thresholds"> <ThresholdEditor siteId={SITE_ID} /> </TabsContent>
|
||||
<TabsContent value="notifications"> <NotificationsTab /> </TabsContent>
|
||||
<TabsContent value="page-prefs"> <PagePrefsTab /> </TabsContent>
|
||||
<TabsContent value="integrations"> <IntegrationsTab /> </TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue