first commit

This commit is contained in:
mega 2026-03-19 11:32:17 +00:00
commit 4b98219bf7
144 changed files with 31561 additions and 0 deletions

View 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 &amp; 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>
);
}

View 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>
);
}

View 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 &amp; 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 &amp; 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: &lt; 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 ?? "—"} tCOe</p>
<p className="text-[10px] text-muted-foreground">
30-day estimate · {energy?.kwh_total.toFixed(0) ?? "—"} kWh × {GRID_EF_KG_CO2_KWH} kgCOe/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} kgCOe/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 &lt; 1.4</p>
<p className="text-muted-foreground/50 text-[10px] pt-1">
COe and WUE estimates are indicative. Actual values depend on metered chilled water and cooling tower data.
</p>
</div>
</div>
);
}

View 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 &nbsp;|&nbsp; 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: 3065% &nbsp;|&nbsp; 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 &amp; 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 (1827°C / 2080% RH)
</p>
</CardContent>
</Card>
);
}
// ── ASHRAE A1 Compliance Table ────────────────────────────────────────────────
// ASHRAE A1: 1532°C, 2080% 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 (1532°C, 2080% 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: 1532°C dry bulb, 2080% 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>
);
}

View 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); // 05 %/m mapped to 0100%
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 &amp; 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>
);
}

View 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: "12" },
{ 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 &nbsp;|&nbsp; Critical: {thresholds.temp.critical}°C</span>}
{overlay === "power" && <span className="ml-auto">Warn: 75% &nbsp;|&nbsp; Critical: 90%</span>}
</>
)}
<span className="text-muted-foreground/50">Click any rack to drill down</span>
</div>
</>
)}
</div>
);
}

View 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 &amp; 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 &amp; 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 &quot;New Window&quot; 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 &amp; 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 &lt;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 &lt; 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>
);
}

View 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>
);
}

BIN
frontend/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

126
frontend/app/globals.css Normal file
View file

@ -0,0 +1,126 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.5rem;
--background: oklch(0.97 0.003 247);
--foreground: oklch(0.13 0.042 265);
--card: oklch(1 0 0);
--card-foreground: oklch(0.13 0.042 265);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.13 0.042 265);
--primary: oklch(0.62 0.17 212);
--primary-foreground: oklch(0.98 0.003 248);
--secondary: oklch(0.96 0.007 248);
--secondary-foreground: oklch(0.21 0.042 266);
--muted: oklch(0.96 0.007 248);
--muted-foreground: oklch(0.55 0.046 257);
--accent: oklch(0.96 0.007 248);
--accent-foreground: oklch(0.21 0.042 266);
--destructive: oklch(0.58 0.245 27);
--border: oklch(0.93 0.013 256);
--input: oklch(0.93 0.013 256);
--ring: oklch(0.62 0.17 212);
--chart-1: oklch(0.62 0.17 212);
--chart-2: oklch(0.7 0.15 162);
--chart-3: oklch(0.75 0.18 84);
--chart-4: oklch(0.65 0.22 30);
--chart-5: oklch(0.63 0.27 304);
--sidebar: oklch(0.16 0.04 265);
--sidebar-foreground: oklch(0.92 0.008 248);
--sidebar-primary: oklch(0.62 0.17 212);
--sidebar-primary-foreground: oklch(0.98 0.003 248);
--sidebar-accent: oklch(0.22 0.04 265);
--sidebar-accent-foreground: oklch(0.92 0.008 248);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.62 0.17 212);
}
.dark {
--background: oklch(0.11 0.03 265);
--foreground: oklch(0.94 0.005 248);
--card: oklch(0.16 0.04 265);
--card-foreground: oklch(0.94 0.005 248);
--popover: oklch(0.16 0.04 265);
--popover-foreground: oklch(0.94 0.005 248);
--primary: oklch(0.62 0.17 212);
--primary-foreground: oklch(0.98 0.003 248);
--secondary: oklch(0.22 0.04 265);
--secondary-foreground: oklch(0.94 0.005 248);
--muted: oklch(0.22 0.04 265);
--muted-foreground: oklch(0.65 0.04 257);
--accent: oklch(0.22 0.04 265);
--accent-foreground: oklch(0.94 0.005 248);
--destructive: oklch(0.65 0.24 27);
--border: oklch(1 0 0 / 9%);
--input: oklch(1 0 0 / 12%);
--ring: oklch(0.62 0.17 212);
--chart-1: oklch(0.62 0.17 212);
--chart-2: oklch(0.7 0.15 162);
--chart-3: oklch(0.75 0.18 84);
--chart-4: oklch(0.65 0.22 30);
--chart-5: oklch(0.63 0.27 304);
--sidebar: oklch(0.14 0.035 265);
--sidebar-foreground: oklch(0.92 0.008 248);
--sidebar-primary: oklch(0.62 0.17 212);
--sidebar-primary-foreground: oklch(0.98 0.003 248);
--sidebar-accent: oklch(0.22 0.04 265);
--sidebar-accent-foreground: oklch(0.92 0.008 248);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.62 0.17 212);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

46
frontend/app/layout.tsx Normal file
View file

@ -0,0 +1,46 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ClerkProvider } from "@clerk/nextjs";
import { ThemeProvider } from "@/components/theme-provider";
import { TooltipProvider } from "@/components/ui/tooltip";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "DemoBMS",
description: "Intelligent Data Center Infrastructure Management",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<ClerkProvider>
<html lang="en" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem={false}
disableTransitionOnChange
>
<TooltipProvider delayDuration={0}>
{children}
</TooltipProvider>
</ThemeProvider>
</body>
</html>
</ClerkProvider>
);
}

5
frontend/app/page.tsx Normal file
View file

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/dashboard");
}

View file

@ -0,0 +1,21 @@
import { SignIn } from "@clerk/nextjs";
import { Database } from "lucide-react";
export default function SignInPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-8">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-primary">
<Database className="w-5 h-5 text-primary-foreground" />
</div>
<div>
<p className="text-lg font-bold tracking-tight">DemoBMS</p>
<p className="text-xs text-muted-foreground">Infrastructure Management</p>
</div>
</div>
<SignIn />
</div>
</div>
);
}

View file

@ -0,0 +1,21 @@
import { SignUp } from "@clerk/nextjs";
import { Database } from "lucide-react";
export default function SignUpPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-8">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-primary">
<Database className="w-5 h-5 text-primary-foreground" />
</div>
<div>
<p className="text-lg font-bold tracking-tight">DemoBMS</p>
<p className="text-xs text-muted-foreground">Infrastructure Management</p>
</div>
</div>
<SignUp />
</div>
</div>
);
}