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

41
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
frontend/Dockerfile Normal file
View file

@ -0,0 +1,36 @@
FROM node:22-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile
# Build the app
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG BACKEND_INTERNAL_URL=http://backend:8000
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
RUN corepack enable pnpm && pnpm build
# Production runner
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 5646
ENV PORT=5646
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

36
frontend/README.md Normal file
View file

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

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

23
frontend/components.json Normal file
View file

@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View file

@ -0,0 +1,124 @@
"use client";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { acknowledgeAlarm, type Alarm } from "@/lib/api";
import { useState } from "react";
import { ChevronRight } from "lucide-react";
interface Props {
alarms: Alarm[];
loading?: boolean;
onAcknowledge?: () => void;
onAlarmClick?: (alarm: Alarm) => void;
}
const severityStyles: Record<string, { badge: string; dot: string }> = {
critical: { badge: "bg-destructive/20 text-destructive border-destructive/30", dot: "bg-destructive" },
warning: { badge: "bg-amber-500/20 text-amber-400 border-amber-500/30", dot: "bg-amber-400" },
info: { badge: "bg-primary/20 text-primary border-primary/30", dot: "bg-primary" },
};
function timeAgo(iso: string) {
const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (secs < 60) return `${secs}s ago`;
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
return `${Math.floor(secs / 3600)}h ago`;
}
export function AlarmFeed({ alarms, loading, onAcknowledge, onAlarmClick }: Props) {
const [acking, setAcking] = useState<number | null>(null);
async function handleAck(id: number) {
setAcking(id);
try {
await acknowledgeAlarm(id);
onAcknowledge?.();
} catch { /* ignore */ }
finally { setAcking(null); }
}
const activeCount = alarms.filter((a) => a.state === "active").length;
return (
<Card className="flex flex-col">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold">Active Alarms</CardTitle>
<div className="flex items-center gap-2">
{!loading && (
<Badge variant={activeCount > 0 ? "destructive" : "outline"} className="text-[10px] h-4 px-1.5">
{activeCount > 0 ? `${activeCount} active` : "All clear"}
</Badge>
)}
<Link href="/alarms" className="text-[10px] text-muted-foreground hover:text-primary transition-colors">
View all
</Link>
</div>
</div>
</CardHeader>
<CardContent className="flex-1 space-y-3 overflow-y-auto max-h-72">
{loading ? (
<Skeleton className="h-80 w-full" />
) : alarms.length === 0 ? (
<div className="flex items-center justify-center h-24 text-sm text-muted-foreground">
No active alarms
</div>
) : (
alarms.map((alarm) => {
const style = severityStyles[alarm.severity] ?? severityStyles.info;
const clickable = !!onAlarmClick;
const locationLabel = [alarm.rack_id, alarm.room_id].find(Boolean);
return (
<div
key={alarm.id}
onClick={() => onAlarmClick?.(alarm)}
className={cn(
"flex gap-2.5 group rounded-md px-1 py-1 -mx-1 transition-colors",
clickable && "cursor-pointer hover:bg-muted/40"
)}
>
<div className="mt-1.5 shrink-0">
<span className={cn("block w-2 h-2 rounded-full", style.dot)} />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs leading-snug text-foreground">{alarm.message}</p>
<div className="mt-0.5 flex items-center gap-1.5">
{locationLabel && (
<span className="text-[10px] text-muted-foreground font-medium">{locationLabel}</span>
)}
{locationLabel && <span className="text-[10px] text-muted-foreground/50">·</span>}
<span className="text-[10px] text-muted-foreground">{timeAgo(alarm.triggered_at)}</span>
</div>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<Badge variant="outline" className={cn("text-[9px] h-4 px-1 uppercase tracking-wide", style.badge)}>
{alarm.severity}
</Badge>
{alarm.state === "active" && (
<Button
variant="ghost"
size="sm"
className="h-4 px-1 text-[9px] opacity-0 group-hover:opacity-100 transition-opacity"
disabled={acking === alarm.id}
onClick={(e) => { e.stopPropagation(); handleAck(alarm.id); }}
>
Ack
</Button>
)}
{clickable && (
<ChevronRight className="w-3 h-3 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors mt-auto" />
)}
</div>
</div>
);
})
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,26 @@
import { LucideIcon } from "lucide-react";
interface ComingSoonProps {
title: string;
description: string;
icon: LucideIcon;
phase: number;
}
export function ComingSoon({ title, description, icon: Icon, phase }: ComingSoonProps) {
return (
<div className="flex flex-col items-center justify-center h-96 text-center space-y-4">
<div className="flex items-center justify-center w-16 h-16 rounded-2xl bg-muted">
<Icon className="w-8 h-8 text-muted-foreground" />
</div>
<div className="space-y-1">
<h2 className="text-lg font-semibold">{title}</h2>
<p className="text-sm text-muted-foreground max-w-xs">{description}</p>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary/10 border border-primary/20">
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
<span className="text-xs text-primary font-medium">Planned for Phase {phase}</span>
</div>
</div>
);
}

View file

@ -0,0 +1,473 @@
"use client";
import { useEffect, useState } from "react";
import { fetchCracStatus, fetchCracHistory, type CracStatus, type CracHistoryPoint } from "@/lib/api";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { TimeRangePicker } from "@/components/ui/time-range-picker";
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, ReferenceLine,
} from "recharts";
import { Thermometer, Wind, Zap, Gauge, Settings2, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
interface Props {
siteId: string;
cracId: string | null;
onClose: () => void;
}
function fmt(v: number | null | undefined, dec = 1, unit = "") {
if (v == null) return "—";
return `${v.toFixed(dec)}${unit}`;
}
function formatTime(iso: string) {
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); }
catch { return iso; }
}
function FillBar({
value, max, color, 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 barColor =
crit && value != null && value >= crit ? "#ef4444" :
warn && value != null && value >= warn ? "#f59e0b" :
color;
return (
<div className="h-1.5 rounded-full bg-muted overflow-hidden w-full">
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${pct}%`, backgroundColor: barColor }}
/>
</div>
);
}
function SectionLabel({ icon: Icon, title }: { icon: React.ElementType; title: string }) {
return (
<div className="flex items-center gap-1.5 mt-5 mb-2">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
{title}
</span>
</div>
);
}
function StatRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
return (
<div className="flex justify-between items-center py-1.5 border-b border-border/40 last:border-0">
<span className="text-xs text-muted-foreground">{label}</span>
<span className={cn("text-sm font-mono font-medium", highlight && "text-amber-400")}>{value}</span>
</div>
);
}
function RefrigerantRow({ label, value, status }: { label: string; value: string; status: "ok" | "warn" | "crit" }) {
return (
<div className="flex justify-between items-center py-2 border-b border-border/40 last:border-0">
<span className="text-xs text-muted-foreground">{label}</span>
<div className="flex items-center gap-2">
<span className="text-sm font-mono font-medium">{value}</span>
<span className={cn(
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full uppercase tracking-wide",
status === "ok" ? "bg-green-500/10 text-green-400" :
status === "warn" ? "bg-amber-500/10 text-amber-400" :
"bg-destructive/10 text-destructive",
)}>
{status === "ok" ? "normal" : status}
</span>
</div>
</div>
);
}
function MiniChart({
data, dataKey, color, label, unit, refLine,
}: {
data: CracHistoryPoint[];
dataKey: keyof CracHistoryPoint;
color: string;
label: string;
unit: string;
refLine?: number;
}) {
const vals = data.map(d => d[dataKey] as number | null).filter(v => v != null) as number[];
const last = vals[vals.length - 1];
return (
<div className="mb-5">
<div className="flex justify-between items-baseline mb-1">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-sm font-mono font-medium" style={{ color }}>
{last != null ? `${last.toFixed(1)}${unit}` : "—"}
</span>
</div>
<ResponsiveContainer width="100%" height={60}>
<LineChart data={data} margin={{ top: 2, right: 4, left: -28, bottom: 0 }}>
<XAxis
dataKey="bucket"
tickFormatter={formatTime}
tick={{ fontSize: 9 }}
interval="preserveStartEnd"
/>
<YAxis tick={{ fontSize: 9 }} domain={["auto", "auto"]} />
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
<Tooltip
formatter={(v: unknown) => [`${(v as number).toFixed(1)}${unit}`, label]}
labelFormatter={(l: unknown) => formatTime(String(l))}
contentStyle={{ fontSize: 11, background: "#1e1e2e", border: "1px solid #333" }}
/>
{refLine != null && (
<ReferenceLine y={refLine} stroke="rgba(251,191,36,0.4)" strokeDasharray="4 2" />
)}
<Line
type="monotone"
dataKey={dataKey}
stroke={color}
dot={false}
strokeWidth={1.5}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
// ── Overview tab ──────────────────────────────────────────────────────────────
function OverviewTab({ status }: { status: CracStatus }) {
const deltaWarn = (status.delta ?? 0) > 11;
const deltaCrit = (status.delta ?? 0) > 14;
const capWarn = (status.cooling_capacity_pct ?? 0) > 75;
const capCrit = (status.cooling_capacity_pct ?? 0) > 90;
const copWarn = (status.cop ?? 99) < 1.5;
const filterWarn = (status.filter_dp_pa ?? 0) > 80;
const filterCrit = (status.filter_dp_pa ?? 0) > 120;
const compWarn = (status.compressor_load_pct ?? 0) > 95;
return (
<div>
{/* ── Thermal hero ──────────────────────────────────────────── */}
<SectionLabel icon={Thermometer} title="Thermal" />
<div className="rounded-lg bg-muted/20 px-4 py-3 mb-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-2xl font-bold tabular-nums text-blue-400">
{fmt(status.supply_temp, 1)}°C
</p>
</div>
<div className="flex-1 flex flex-col items-center gap-1 px-4">
<p className={cn(
"text-sm font-bold tabular-nums",
deltaCrit ? "text-destructive" : deltaWarn ? "text-amber-400" : "text-muted-foreground",
)}>
ΔT {fmt(status.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-2xl font-bold tabular-nums",
deltaCrit ? "text-destructive" : deltaWarn ? "text-amber-400" : "text-orange-400",
)}>
{fmt(status.return_temp, 1)}°C
</p>
</div>
</div>
</div>
<div className="bg-muted/30 rounded-lg px-3 py-1 mb-3">
<StatRow label="Supply Humidity" value={fmt(status.supply_humidity, 0, "%")} />
<StatRow label="Return Humidity" value={fmt(status.return_humidity, 0, "%")} />
<StatRow label="Airflow" value={status.airflow_cfm != null ? `${Math.round(status.airflow_cfm).toLocaleString()} CFM` : "—"} />
</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(status.filter_dp_pa, 0)} Pa
{!filterWarn && <span className="text-green-400 ml-1.5"></span>}
</span>
</div>
<FillBar value={status.filter_dp_pa} max={150} color="#94a3b8" warn={80} crit={120} />
</div>
{/* ── Cooling capacity ──────────────────────────────────────── */}
<SectionLabel icon={Gauge} title="Cooling Capacity" />
<div className="mb-1.5">
<div className="flex justify-between items-baseline mb-1.5">
<span className={cn(
"text-sm font-bold tabular-nums",
capCrit ? "text-destructive" : capWarn ? "text-amber-400" : "text-foreground",
)}>
{fmt(status.cooling_capacity_kw, 1)} kW
</span>
<span className="text-xs text-muted-foreground">of {status.rated_capacity_kw} kW rated</span>
</div>
<FillBar value={status.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(status.cooling_capacity_pct, 1)}% utilised
</p>
</div>
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow label="COP" value={fmt(status.cop, 2)} highlight={copWarn} />
<StatRow label="Sensible Heat Ratio" value={fmt(status.sensible_heat_ratio, 2)} />
</div>
{/* ── Compressor ────────────────────────────────────────────── */}
<SectionLabel icon={Settings2} title="Compressor" />
<div className="mb-2">
<div className="flex justify-between text-[10px] mb-1">
<span className="text-muted-foreground">
Load
<span className={cn(
"ml-2 font-semibold px-1.5 py-0.5 rounded-full",
status.compressor_state === 1
? "bg-green-500/10 text-green-400"
: "bg-muted text-muted-foreground",
)}>
{status.compressor_state === 1 ? "● Running" : "○ Off"}
</span>
</span>
<span className={cn("font-mono", compWarn ? "text-amber-400" : "text-foreground")}>
{fmt(status.compressor_load_pct, 1)}%
</span>
</div>
<FillBar value={status.compressor_load_pct} max={100} color="#e879f9" warn={80} crit={95} />
</div>
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow label="Power" value={fmt(status.compressor_power_kw, 2, " kW")} />
<StatRow label="Run Hours" value={status.compressor_run_hours != null ? status.compressor_run_hours.toLocaleString() + " h" : "—"} />
</div>
{/* ── Fan ───────────────────────────────────────────────────── */}
<SectionLabel icon={Wind} title="Fan" />
<div className="mb-2">
<div className="flex justify-between text-[10px] mb-1">
<span className="text-muted-foreground">Speed</span>
<span className="font-mono text-foreground">
{fmt(status.fan_pct, 1)}%
{status.fan_rpm != null ? ` · ${Math.round(status.fan_rpm).toLocaleString()} rpm` : ""}
</span>
</div>
<FillBar value={status.fan_pct} max={100} color="#60a5fa" />
</div>
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow label="Fan Power" value={fmt(status.fan_power_kw, 2, " kW")} />
<StatRow label="Run Hours" value={status.fan_run_hours != null ? status.fan_run_hours.toLocaleString() + " h" : "—"} />
</div>
{/* ── Electrical ────────────────────────────────────────────── */}
<SectionLabel icon={Zap} title="Electrical" />
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow label="Total Unit Power" value={fmt(status.total_unit_power_kw, 2, " kW")} />
<StatRow label="Input Voltage" value={fmt(status.input_voltage_v, 1, " V")} />
<StatRow label="Input Current" value={fmt(status.input_current_a, 1, " A")} />
<StatRow label="Power Factor" value={fmt(status.power_factor, 3)} />
</div>
</div>
);
}
// ── Refrigerant tab ───────────────────────────────────────────────────────────
function RefrigerantTab({ status }: { status: CracStatus }) {
const hiP = status.high_pressure_bar ?? 0;
const loP = status.low_pressure_bar ?? 99;
const sh = status.discharge_superheat_c ?? 0;
const sc = status.liquid_subcooling_c ?? 0;
const load = status.compressor_load_pct ?? 0;
// Normal ranges for R410A-style DX unit
const hiPStatus: "ok" | "warn" | "crit" = hiP > 22 ? "crit" : hiP > 20 ? "warn" : "ok";
const loPStatus: "ok" | "warn" | "crit" = loP < 3 ? "crit" : loP < 4 ? "warn" : "ok";
const shStatus: "ok" | "warn" | "crit" = sh > 16 ? "warn" : sh < 4 ? "warn" : "ok";
const scStatus: "ok" | "warn" | "crit" = sc < 2 ? "warn" : "ok";
const ldStatus: "ok" | "warn" | "crit" = load > 95 ? "warn" : "ok";
const compRunning = status.compressor_state === 1;
return (
<div>
<p className="text-xs text-muted-foreground mt-4 mb-4 leading-relaxed">
Refrigerant circuit data for the DX cooling system. Pressures assume an R410A charge.
Values outside normal range are flagged automatically.
</p>
<div className="rounded-lg bg-muted/20 px-3 mb-4">
<div className="flex justify-between items-center py-3 border-b border-border/30">
<span className="text-xs text-muted-foreground">Compressor State</span>
<span className={cn(
"text-xs font-semibold px-2 py-0.5 rounded-full",
compRunning ? "bg-green-500/10 text-green-400" : "bg-muted text-muted-foreground",
)}>
{compRunning ? "● Running" : "○ Off"}
</span>
</div>
<RefrigerantRow
label="High Side Pressure"
value={fmt(status.high_pressure_bar, 2, " bar")}
status={hiPStatus}
/>
<RefrigerantRow
label="Low Side Pressure"
value={fmt(status.low_pressure_bar, 2, " bar")}
status={loPStatus}
/>
<RefrigerantRow
label="Discharge Superheat"
value={fmt(status.discharge_superheat_c, 1, "°C")}
status={shStatus}
/>
<RefrigerantRow
label="Liquid Subcooling"
value={fmt(status.liquid_subcooling_c, 1, "°C")}
status={scStatus}
/>
<RefrigerantRow
label="Compressor Load"
value={fmt(status.compressor_load_pct, 1, "%")}
status={ldStatus}
/>
<div className="flex justify-between items-center py-2">
<span className="text-xs text-muted-foreground">Compressor Power</span>
<span className="text-sm font-mono font-medium">{fmt(status.compressor_power_kw, 2, " kW")}</span>
</div>
</div>
<div className="rounded-md bg-muted/30 px-3 py-2.5 text-xs text-muted-foreground space-y-1">
<p className="font-semibold text-foreground mb-1.5">Normal Ranges (R410A)</p>
<p>High side pressure: 15 20 bar</p>
<p>Low side pressure: 4 6 bar</p>
<p>Discharge superheat: 5 15°C</p>
<p>Liquid subcooling: 3 8°C</p>
</div>
</div>
);
}
// ── Trends tab ────────────────────────────────────────────────────────────────
function TrendsTab({ history }: { history: CracHistoryPoint[] }) {
if (history.length < 2) {
return (
<p className="text-sm text-muted-foreground mt-6 text-center">
Not enough history yet data accumulates every 5 minutes.
</p>
);
}
return (
<div className="mt-4">
<MiniChart data={history} dataKey="supply_temp" color="#60a5fa" label="Supply Temp" unit="°C" />
<MiniChart data={history} dataKey="return_temp" color="#f97316" label="Return Temp" unit="°C" refLine={36} />
<MiniChart data={history} dataKey="delta_t" color="#a78bfa" label="ΔT" unit="°C" />
<MiniChart data={history} dataKey="capacity_kw" color="#34d399" label="Cooling kW" unit=" kW" />
<MiniChart data={history} dataKey="capacity_pct"color="#fbbf24" label="Utilisation" unit="%" refLine={90} />
<MiniChart data={history} dataKey="cop" color="#38bdf8" label="COP" unit="" refLine={1.5} />
<MiniChart data={history} dataKey="comp_load" color="#e879f9" label="Comp Load" unit="%" refLine={95} />
<MiniChart data={history} dataKey="filter_dp" color="#fb923c" label="Filter ΔP" unit=" Pa" refLine={80} />
<MiniChart data={history} dataKey="fan_pct" color="#94a3b8" label="Fan Speed" unit="%" />
</div>
);
}
// ── Sheet ─────────────────────────────────────────────────────────────────────
export function CracDetailSheet({ siteId, cracId, onClose }: Props) {
const [hours, setHours] = useState(6);
const [status, setStatus] = useState<CracStatus | null>(null);
const [history, setHistory] = useState<CracHistoryPoint[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!cracId) return;
setLoading(true);
Promise.all([
fetchCracStatus(siteId),
fetchCracHistory(siteId, cracId, hours),
]).then(([statuses, hist]) => {
setStatus(statuses.find(c => c.crac_id === cracId) ?? null);
setHistory(hist);
}).finally(() => setLoading(false));
}, [siteId, cracId, hours]);
return (
<Sheet open={cracId != null} onOpenChange={open => { if (!open) onClose(); }}>
<SheetContent className="w-[500px] sm:w-[560px] overflow-y-auto" side="right">
<SheetHeader className="mb-2">
<SheetTitle className="flex items-center justify-between">
<span>{cracId?.toUpperCase() ?? "CRAC Unit"}</span>
{status && (
<Badge
variant={status.state === "online" ? "default" : "destructive"}
className="text-xs"
>
{status.state}
</Badge>
)}
</SheetTitle>
{status && (
<p className="text-xs text-muted-foreground">
{status.room_id ? `Room: ${status.room_id}` : ""}
{status.state === "online" ? " · Mode: Cooling · Setpoint 22°C" : ""}
</p>
)}
</SheetHeader>
{loading && !status ? (
<div className="space-y-3 mt-4">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : status ? (
<Tabs defaultValue="overview">
<div className="flex items-center justify-between mt-3 mb-1">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="refrigerant">Refrigerant</TabsTrigger>
<TabsTrigger value="trends">Trends</TabsTrigger>
</TabsList>
<TimeRangePicker value={hours} onChange={setHours} />
</div>
<TabsContent value="overview">
<OverviewTab status={status} />
</TabsContent>
<TabsContent value="refrigerant">
<RefrigerantTab status={status} />
</TabsContent>
<TabsContent value="trends">
<TrendsTab history={history} />
</TabsContent>
</Tabs>
) : (
<p className="text-sm text-muted-foreground mt-4">No data available for {cracId}.</p>
)}
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,489 @@
"use client";
import { useEffect, useState } from "react";
import {
fetchGeneratorStatus, fetchGeneratorHistory,
type GeneratorStatus, type GeneratorHistoryPoint,
} from "@/lib/api";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { TimeRangePicker } from "@/components/ui/time-range-picker";
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, ReferenceLine,
} from "recharts";
import {
Fuel, Zap, Gauge, Thermometer, Wind, Activity, Battery, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface Props {
siteId: string;
genId: string | null;
onClose: () => void;
}
function fmt(v: number | null | undefined, dec = 1, unit = "") {
if (v == null) return "—";
return `${v.toFixed(dec)}${unit}`;
}
function formatTime(iso: string) {
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); }
catch { return iso; }
}
const STATE_BADGE: Record<string, string> = {
running: "bg-green-500/15 text-green-400 border-green-500/30",
standby: "bg-blue-500/15 text-blue-400 border-blue-500/30",
test: "bg-amber-500/15 text-amber-400 border-amber-500/30",
fault: "bg-destructive/15 text-destructive border-destructive/30",
unknown: "bg-muted/30 text-muted-foreground border-border",
};
function FillBar({
value, max, color, warn, crit, invert = false,
}: {
value: number | null; max: number; color: string; warn?: number; crit?: number; invert?: boolean;
}) {
const pct = value != null ? Math.min(100, (value / max) * 100) : 0;
const v = value ?? 0;
const barColor =
crit && (invert ? v <= crit : v >= crit) ? "#ef4444" :
warn && (invert ? v <= warn : v >= warn) ? "#f59e0b" :
color;
return (
<div className="h-1.5 rounded-full bg-muted overflow-hidden w-full">
<div className="h-full rounded-full transition-all duration-500"
style={{ width: `${pct}%`, backgroundColor: barColor }} />
</div>
);
}
function SectionLabel({ icon: Icon, title }: { icon: React.ElementType; title: string }) {
return (
<div className="flex items-center gap-1.5 mt-5 mb-2">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">{title}</span>
</div>
);
}
function StatRow({
label, value, highlight, status,
}: {
label: string; value: string; highlight?: boolean; status?: "ok" | "warn" | "crit";
}) {
return (
<div className="flex justify-between items-center py-1.5 border-b border-border/40 last:border-0">
<span className="text-xs text-muted-foreground">{label}</span>
<div className="flex items-center gap-2">
<span className={cn(
"text-sm font-mono font-medium",
highlight && "text-amber-400",
status === "crit" && "text-destructive",
status === "warn" && "text-amber-400",
status === "ok" && "text-green-400",
)}>{value}</span>
{status && (
<span className={cn(
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full uppercase tracking-wide",
status === "ok" ? "bg-green-500/10 text-green-400" :
status === "warn" ? "bg-amber-500/10 text-amber-400" :
"bg-destructive/10 text-destructive",
)}>
{status === "ok" ? "normal" : status}
</span>
)}
</div>
</div>
);
}
function MiniChart({
data, dataKey, color, label, unit, refLine,
}: {
data: GeneratorHistoryPoint[];
dataKey: keyof GeneratorHistoryPoint;
color: string;
label: string;
unit: string;
refLine?: number;
}) {
const vals = data.map(d => d[dataKey] as number | null).filter(v => v != null) as number[];
const last = vals[vals.length - 1];
return (
<div className="mb-5">
<div className="flex justify-between items-baseline mb-1">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-sm font-mono font-medium" style={{ color }}>
{last != null ? `${last.toFixed(1)}${unit}` : "—"}
</span>
</div>
<ResponsiveContainer width="100%" height={60}>
<LineChart data={data} margin={{ top: 2, right: 4, left: -28, bottom: 0 }}>
<XAxis dataKey="bucket" tickFormatter={formatTime} tick={{ fontSize: 9 }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 9 }} domain={["auto", "auto"]} />
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
<Tooltip
formatter={(v: unknown) => [`${(v as number).toFixed(1)}${unit}`, label]}
labelFormatter={(l: unknown) => formatTime(String(l))}
contentStyle={{ fontSize: 11, background: "#1e1e2e", border: "1px solid #333" }}
/>
{refLine != null && <ReferenceLine y={refLine} stroke="rgba(251,191,36,0.4)" strokeDasharray="4 2" />}
<Line type="monotone" dataKey={dataKey} stroke={color} dot={false} strokeWidth={1.5} connectNulls />
</LineChart>
</ResponsiveContainer>
</div>
);
}
// ── Overview tab ──────────────────────────────────────────────────────────────
function OverviewTab({ gen }: { gen: GeneratorStatus }) {
const isRunning = gen.state === "running" || gen.state === "test";
const fuelLow = (gen.fuel_pct ?? 100) < 25;
const fuelCrit = (gen.fuel_pct ?? 100) < 10;
const loadWarn = (gen.load_pct ?? 0) > 75;
const loadCrit = (gen.load_pct ?? 0) > 90;
// Estimated runtime from fuel and consumption rate
const runtimeH = gen.fuel_rate_lph && gen.fuel_rate_lph > 0 && gen.fuel_litres
? gen.fuel_litres / gen.fuel_rate_lph
: gen.fuel_litres && gen.load_kw && gen.load_kw > 0
? gen.fuel_litres / (gen.load_kw * 0.27)
: null;
// Computed electrical values
const outputKva = gen.load_kw && gen.power_factor && gen.power_factor > 0
? gen.load_kw / gen.power_factor : null;
const outputCurrent = gen.load_kw && gen.voltage_v && gen.voltage_v > 0 && gen.power_factor
? (gen.load_kw * 1000) / (gen.voltage_v * 1.732 * gen.power_factor) : null;
return (
<div>
{/* Fuel */}
<SectionLabel icon={Fuel} title="Fuel" />
<div className="rounded-lg bg-muted/20 px-4 py-3 space-y-2">
<div className="flex justify-between items-baseline mb-1">
<span className="text-xs text-muted-foreground">Tank Level</span>
<span className={cn(
"text-2xl font-bold tabular-nums",
fuelCrit ? "text-destructive" : fuelLow ? "text-amber-400" : "text-green-400",
)}>
{fmt(gen.fuel_pct, 1)}%
</span>
</div>
<FillBar value={gen.fuel_pct} max={100} color="#22c55e" warn={25} crit={10} />
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>{gen.fuel_litres != null ? `${gen.fuel_litres.toFixed(0)} L remaining` : "—"}</span>
{gen.fuel_rate_lph != null && gen.fuel_rate_lph > 0 && (
<span>{fmt(gen.fuel_rate_lph, 1)} L/hr consumption</span>
)}
</div>
{runtimeH != null && (
<div className={cn(
"text-xs font-semibold text-right",
runtimeH < 4 ? "text-destructive" : runtimeH < 12 ? "text-amber-400" : "text-green-400",
)}>
Est. runtime: {Math.floor(runtimeH)}h {Math.round((runtimeH % 1) * 60)}m
</div>
)}
</div>
{/* Load */}
<SectionLabel icon={Zap} title="Load" />
<div className="rounded-lg bg-muted/20 px-4 py-3 space-y-2">
<div className="flex justify-between items-baseline mb-1">
<span className="text-xs text-muted-foreground">Active Load</span>
<span className={cn(
"text-2xl font-bold tabular-nums",
loadCrit ? "text-destructive" : loadWarn ? "text-amber-400" : "text-foreground",
)}>
{fmt(gen.load_kw, 1)} kW
</span>
</div>
<FillBar value={gen.load_pct} max={100} color="#60a5fa" warn={75} crit={90} />
<div className="flex justify-between text-xs mt-1">
<span className={cn(
"font-medium tabular-nums",
loadCrit ? "text-destructive" : loadWarn ? "text-amber-400" : "text-muted-foreground",
)}>
{fmt(gen.load_pct, 1)}% of 500 kW rated
</span>
{outputKva != null && (
<span className="text-muted-foreground">{outputKva.toFixed(1)} kVA apparent</span>
)}
</div>
</div>
{/* Quick stats */}
<SectionLabel icon={Activity} title="Quick Stats" />
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow label="Output Current" value={outputCurrent != null ? `${outputCurrent.toFixed(1)} A` : "—"} />
<StatRow label="Power Factor" value={fmt(gen.power_factor, 3)} />
<StatRow label="Run Hours" value={gen.run_hours != null ? `${gen.run_hours.toLocaleString()} h` : "—"} />
</div>
</div>
);
}
// ── Engine tab ────────────────────────────────────────────────────────────────
function EngineTab({ gen }: { gen: GeneratorStatus }) {
const coolantWarn = (gen.coolant_temp_c ?? 0) > 85;
const coolantCrit = (gen.coolant_temp_c ?? 0) > 95;
const exhaustWarn = (gen.exhaust_temp_c ?? 0) > 420;
const exhaustCrit = (gen.exhaust_temp_c ?? 0) > 480;
const altTempWarn = (gen.alternator_temp_c ?? 0) > 70;
const altTempCrit = (gen.alternator_temp_c ?? 0) > 85;
const oilLow = (gen.oil_pressure_bar ?? 99) < 2.0;
const oilWarn = (gen.oil_pressure_bar ?? 99) < 3.0;
const battLow = (gen.battery_v ?? 99) < 23.0;
const battWarn = (gen.battery_v ?? 99) < 24.0;
const rpmWarn = gen.engine_rpm != null && gen.engine_rpm > 0 && Math.abs(gen.engine_rpm - 1500) > 20;
return (
<div>
<p className="text-xs text-muted-foreground mt-4 mb-4 leading-relaxed">
Mechanical health of the diesel engine. Normal operating range assumes a 50 Hz, 4-pole synchronous generator at 1500 RPM.
</p>
{/* Temperature gauges */}
<SectionLabel icon={Thermometer} title="Temperatures" />
<div className="space-y-3">
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">Coolant</span>
<span className={cn("font-mono", coolantCrit ? "text-destructive" : coolantWarn ? "text-amber-400" : "text-foreground")}>
{fmt(gen.coolant_temp_c, 1)}°C
</span>
</div>
<FillBar value={gen.coolant_temp_c} max={120} color="#34d399" warn={85} crit={95} />
<p className="text-[10px] text-muted-foreground text-right mt-0.5">Normal: 7090°C</p>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">Exhaust Stack</span>
<span className={cn("font-mono", exhaustCrit ? "text-destructive" : exhaustWarn ? "text-amber-400" : "text-foreground")}>
{fmt(gen.exhaust_temp_c, 0)}°C
</span>
</div>
<FillBar value={gen.exhaust_temp_c} max={600} color="#f97316" warn={420} crit={480} />
<p className="text-[10px] text-muted-foreground text-right mt-0.5">Normal: 200420°C at load</p>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">Alternator Windings</span>
<span className={cn("font-mono", altTempCrit ? "text-destructive" : altTempWarn ? "text-amber-400" : "text-foreground")}>
{fmt(gen.alternator_temp_c, 1)}°C
</span>
</div>
<FillBar value={gen.alternator_temp_c} max={110} color="#a78bfa" warn={70} crit={85} />
<p className="text-[10px] text-muted-foreground text-right mt-0.5">Normal: 4070°C at load</p>
</div>
</div>
{/* Engine mechanical */}
<SectionLabel icon={Settings2} title="Mechanical" />
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow
label="Engine RPM"
value={gen.engine_rpm != null && gen.engine_rpm > 0 ? `${gen.engine_rpm.toFixed(0)} RPM` : "— (stopped)"}
status={rpmWarn ? "warn" : gen.engine_rpm && gen.engine_rpm > 0 ? "ok" : undefined}
/>
<StatRow
label="Oil Pressure"
value={gen.oil_pressure_bar != null && gen.oil_pressure_bar > 0 ? `${gen.oil_pressure_bar.toFixed(2)} bar` : "— (stopped)"}
status={oilLow ? "crit" : oilWarn ? "warn" : gen.oil_pressure_bar && gen.oil_pressure_bar > 0 ? "ok" : undefined}
/>
<StatRow
label="Run Hours"
value={gen.run_hours != null ? `${gen.run_hours.toLocaleString()} h` : "—"}
/>
</div>
{/* Starter battery */}
<SectionLabel icon={Battery} title="Starter Battery" />
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow
label="Battery Voltage (24 V system)"
value={fmt(gen.battery_v, 2, " V")}
status={battLow ? "crit" : battWarn ? "warn" : "ok"}
/>
<div className="py-1.5">
<FillBar value={gen.battery_v} max={29} color="#22c55e" warn={24} crit={23} invert />
</div>
<p className="text-[10px] text-muted-foreground pb-1.5">
Float charge: 27.2 V · Low threshold: 24 V · Critical: 23 V
</p>
</div>
</div>
);
}
// ── Electrical tab ────────────────────────────────────────────────────────────
function ElectricalTab({ gen }: { gen: GeneratorStatus }) {
const freqWarn = gen.frequency_hz != null && gen.frequency_hz > 0 && Math.abs(gen.frequency_hz - 50) > 0.3;
const freqCrit = gen.frequency_hz != null && gen.frequency_hz > 0 && Math.abs(gen.frequency_hz - 50) > 0.5;
const voltWarn = gen.voltage_v != null && gen.voltage_v > 0 && Math.abs(gen.voltage_v - 415) > 10;
const outputKva = gen.load_kw && gen.power_factor && gen.power_factor > 0
? gen.load_kw / gen.power_factor : null;
const outputKvar = outputKva && gen.load_kw
? Math.sqrt(Math.max(0, outputKva ** 2 - gen.load_kw ** 2)) : null;
const outputCurrent = gen.load_kw && gen.voltage_v && gen.voltage_v > 0 && gen.power_factor
? (gen.load_kw * 1000) / (gen.voltage_v * 1.732 * gen.power_factor) : null;
const phaseCurrentA = outputCurrent ? outputCurrent / 3 : null;
return (
<div>
<p className="text-xs text-muted-foreground mt-4 mb-4 leading-relaxed">
AC output electrical parameters. The generator feeds the ATS which transfers site load during utility failure. Rated 500 kW / 555 kVA at 0.90 PF, 415 V, 50 Hz.
</p>
{/* AC output hero */}
<div className="rounded-lg bg-muted/20 px-4 py-3 grid grid-cols-3 gap-3 mb-4 text-center">
{[
{ label: "Output Voltage", value: gen.voltage_v && gen.voltage_v > 0 ? `${gen.voltage_v.toFixed(0)} V` : "—", warn: voltWarn },
{ label: "Frequency", value: gen.frequency_hz && gen.frequency_hz > 0 ? `${gen.frequency_hz.toFixed(2)} Hz` : "—", warn: freqWarn || freqCrit },
{ label: "Power Factor", value: fmt(gen.power_factor, 3), warn: false },
].map(({ label, value, warn }) => (
<div key={label}>
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">{label}</p>
<p className={cn("text-lg font-bold tabular-nums", warn ? "text-amber-400" : "text-foreground")}>
{value}
</p>
</div>
))}
</div>
<SectionLabel icon={Zap} title="Power Output" />
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow label="Active Power (kW)" value={fmt(gen.load_kw, 1, " kW")} />
<StatRow label="Apparent Power (kVA)" value={outputKva != null ? `${outputKva.toFixed(1)} kVA` : "—"} />
<StatRow label="Reactive Power (kVAR)" value={outputKvar != null ? `${outputKvar.toFixed(1)} kVAR` : "—"} />
<StatRow label="Load %" value={fmt(gen.load_pct, 1, "%")} />
</div>
<SectionLabel icon={Activity} title="Current (3-Phase)" />
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow label="Total Output Current" value={outputCurrent != null ? `${outputCurrent.toFixed(1)} A` : "—"} />
<StatRow label="Per Phase (balanced)" value={phaseCurrentA != null ? `${phaseCurrentA.toFixed(1)} A` : "—"} />
<StatRow label="Rated Current (500 kW)" value="694 A" />
</div>
<SectionLabel icon={Wind} title="Frequency" />
<div className="space-y-1">
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">Output Frequency</span>
<span className={cn("font-mono", freqCrit ? "text-destructive" : freqWarn ? "text-amber-400" : "text-green-400")}>
{gen.frequency_hz && gen.frequency_hz > 0 ? `${gen.frequency_hz.toFixed(2)} Hz` : "—"}
</span>
</div>
<FillBar value={gen.frequency_hz ?? 0} max={55} color="#34d399" warn={50.3} crit={50.5} />
<p className="text-[10px] text-muted-foreground text-right mt-0.5">
Nominal 50 Hz · Grid tolerance ±0.5 Hz
</p>
</div>
</div>
);
}
// ── Trends tab ────────────────────────────────────────────────────────────────
function TrendsTab({ history }: { history: GeneratorHistoryPoint[] }) {
if (history.length < 2) {
return (
<p className="text-sm text-muted-foreground mt-6 text-center">
Not enough history yet data accumulates every 5 minutes.
</p>
);
}
return (
<div className="mt-4">
<MiniChart data={history} dataKey="load_pct" color="#60a5fa" label="Load %" unit="%" refLine={90} />
<MiniChart data={history} dataKey="fuel_pct" color="#22c55e" label="Fuel Level" unit="%" refLine={25} />
<MiniChart data={history} dataKey="coolant_temp_c" color="#34d399" label="Coolant Temp" unit="°C" refLine={95} />
<MiniChart data={history} dataKey="exhaust_temp_c" color="#f97316" label="Exhaust Temp" unit="°C" refLine={420} />
<MiniChart data={history} dataKey="frequency_hz" color="#a78bfa" label="Frequency" unit=" Hz" refLine={50.5} />
<MiniChart data={history} dataKey="alternator_temp_c" color="#e879f9" label="Alternator Temp" unit="°C" refLine={85} />
</div>
);
}
// ── Sheet ─────────────────────────────────────────────────────────────────────
export function GeneratorDetailSheet({ siteId, genId, onClose }: Props) {
const [hours, setHours] = useState(6);
const [status, setStatus] = useState<GeneratorStatus | null>(null);
const [history, setHistory] = useState<GeneratorHistoryPoint[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!genId) return;
setLoading(true);
Promise.all([
fetchGeneratorStatus(siteId),
fetchGeneratorHistory(siteId, genId, hours),
]).then(([statuses, hist]) => {
setStatus(statuses.find(g => g.gen_id === genId) ?? null);
setHistory(hist);
}).finally(() => setLoading(false));
}, [siteId, genId, hours]);
return (
<Sheet open={genId != null} onOpenChange={open => { if (!open) onClose(); }}>
<SheetContent className="w-[500px] sm:w-[560px] overflow-y-auto" side="right">
<SheetHeader className="mb-2">
<SheetTitle className="flex items-center justify-between">
<span>{genId?.toUpperCase() ?? "Generator"}</span>
{status && (
<Badge
variant="outline"
className={cn("text-xs capitalize border", STATUS_BADGE_CLASS[status.state] ?? STATUS_BADGE_CLASS.unknown)}
>
{status.state}
</Badge>
)}
</SheetTitle>
{status && (
<p className="text-xs text-muted-foreground">
Diesel generator · 500 kW rated · 2,000 L tank
{status.run_hours != null ? ` · ${status.run_hours.toLocaleString()} run hours` : ""}
</p>
)}
</SheetHeader>
{loading && !status ? (
<div className="space-y-3 mt-4">
{Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-8 w-full" />)}
</div>
) : status ? (
<Tabs defaultValue="overview">
<div className="flex items-center justify-between mt-3 mb-1">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="engine">Engine</TabsTrigger>
<TabsTrigger value="electrical">Electrical</TabsTrigger>
<TabsTrigger value="trends">Trends</TabsTrigger>
</TabsList>
<TimeRangePicker value={hours} onChange={setHours} />
</div>
<TabsContent value="overview"> <OverviewTab gen={status} /></TabsContent>
<TabsContent value="engine"> <EngineTab gen={status} /></TabsContent>
<TabsContent value="electrical"> <ElectricalTab gen={status} /></TabsContent>
<TabsContent value="trends"> <TrendsTab history={history} /></TabsContent>
</Tabs>
) : (
<p className="text-sm text-muted-foreground mt-4">No data available for {genId}.</p>
)}
</SheetContent>
</Sheet>
);
}
const STATUS_BADGE_CLASS: Record<string, string> = STATE_BADGE;

View file

@ -0,0 +1,77 @@
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { LucideIcon, TrendingUp, TrendingDown, Minus } from "lucide-react";
import { cn } from "@/lib/utils";
interface KpiCardProps {
title: string;
value: string;
icon: LucideIcon;
iconColor: string;
status: "ok" | "warning" | "critical";
hint?: string;
loading?: boolean;
trend?: number | null;
trendLabel?: string;
trendInvert?: boolean;
href?: string; // optional navigation target
}
const statusBorder: Record<string, string> = {
ok: "border-l-4 border-l-green-500",
warning: "border-l-4 border-l-amber-500",
critical: "border-l-4 border-l-destructive",
};
export function KpiCard({ title, value, icon: Icon, iconColor, status, hint, loading, trend, trendLabel, trendInvert, href }: KpiCardProps) {
const hasTrend = trend !== null && trend !== undefined;
const isUp = hasTrend && trend! > 0;
const isDown = hasTrend && trend! < 0;
const isFlat = hasTrend && trend! === 0;
// For temp/alarms: up is bad (red), down is good (green)
// For power: up might be warning, down is fine
const trendGood = trendInvert ? isDown : isUp;
const trendBad = trendInvert ? isUp : false;
const trendColor = isFlat ? "text-muted-foreground"
: trendGood ? "text-green-400"
: trendBad ? "text-destructive"
: "text-amber-400";
const inner = (
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div className="space-y-1 flex-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{title}
</p>
{loading ? (
<Skeleton className="h-8 w-24" />
) : (
<p className="text-2xl font-bold tracking-tight">{value}</p>
)}
{hasTrend && !loading ? (
<div className={cn("flex items-center gap-1 text-[10px]", trendColor)}>
{isFlat ? <Minus className="w-3 h-3" /> : isUp ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
<span>{trendLabel ?? (trend! > 0 ? `+${trend}` : `${trend}`)}</span>
<span className="text-muted-foreground">vs prev period</span>
</div>
) : (
hint && <p className="text-[10px] text-muted-foreground">{hint}</p>
)}
</div>
<div className="p-2 rounded-md bg-muted/50 shrink-0">
<Icon className={cn("w-5 h-5", iconColor)} />
</div>
</div>
</CardContent>
);
return (
<Card className={cn("relative overflow-hidden", statusBorder[status], href && "cursor-pointer hover:bg-muted/20 transition-colors")}>
{href ? <Link href={href} className="block">{inner}</Link> : inner}
</Card>
);
}

View file

@ -0,0 +1,144 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Map as MapIcon, Wind } from "lucide-react";
import { cn } from "@/lib/utils";
import { useThresholds } from "@/lib/threshold-context";
import type { RackCapacity } from "@/lib/api";
type RowLayout = { label: string; racks: string[] };
type RoomLayout = { label: string; crac_id: string; rows: RowLayout[] };
type FloorLayout = Record<string, RoomLayout>;
interface Props {
layout: FloorLayout | null;
racks: RackCapacity[];
loading: boolean;
}
function tempBg(temp: number | null, warn: number, crit: number): string {
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)";
}
export function MiniFloorMap({ layout, racks, loading }: Props) {
const { thresholds } = useThresholds();
const warn = thresholds.temp.warn;
const crit = thresholds.temp.critical;
const rackMap: globalThis.Map<string, RackCapacity> = new globalThis.Map(racks.map(r => [r.rack_id, r] as [string, RackCapacity]));
const roomIds = layout ? Object.keys(layout) : [];
const [activeRoom, setActiveRoom] = useState<string>(() => roomIds[0] ?? "");
const currentRoomId = activeRoom || roomIds[0] || "";
return (
<Card className="flex flex-col">
<CardHeader className="pb-2 shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<MapIcon className="w-4 h-4 text-primary" />
Floor Map Temperature
</CardTitle>
<Link
href="/floor-map"
className="text-[10px] text-primary hover:underline"
>
Full map
</Link>
</div>
{/* Room tabs */}
{roomIds.length > 1 && (
<div className="flex gap-1 mt-2">
{roomIds.map(id => (
<button
key={id}
onClick={() => setActiveRoom(id)}
className={cn(
"px-3 py-1 rounded text-[11px] font-medium transition-colors",
currentRoomId === id
? "bg-primary text-primary-foreground"
: "bg-muted/40 text-muted-foreground hover:bg-muted/60"
)}
>
{layout?.[id]?.label ?? id}
</button>
))}
</div>
)}
</CardHeader>
<CardContent className="flex-1 min-h-0">
{loading ? (
<Skeleton className="h-40 w-full rounded-lg" />
) : !layout || roomIds.length === 0 ? (
<div className="h-40 flex items-center justify-center text-sm text-muted-foreground">
No layout configured
</div>
) : (
<Link href="/floor-map" className="block group">
<div className="space-y-2">
{(layout[currentRoomId]?.rows ?? []).map((row, rowIdx, allRows) => (
<div key={row.label}>
{/* Rack row */}
<div className="flex flex-wrap gap-[3px]">
{row.racks.map(rackId => {
const rack = rackMap.get(rackId);
const bg = tempBg(rack?.temp ?? null, warn, crit);
return (
<div
key={rackId}
title={`${rackId}${rack?.temp != null ? ` · ${rack.temp}°C` : " · offline"}`}
className="rounded-[2px] shrink-0"
style={{ width: 14, height: 20, backgroundColor: bg }}
/>
);
})}
</div>
{/* Cold aisle separator between rows */}
{rowIdx < allRows.length - 1 && (
<div className="flex items-center gap-2 my-1.5 text-[9px] font-semibold uppercase tracking-widest"
style={{ color: "oklch(0.62 0.17 212 / 70%)" }}>
<div className="flex-1 h-px border-t border-dashed" style={{ borderColor: "oklch(0.62 0.17 212 / 25%)" }} />
<Wind className="w-2.5 h-2.5 shrink-0" />
<span>Cold Aisle</span>
<div className="flex-1 h-px border-t border-dashed" style={{ borderColor: "oklch(0.62 0.17 212 / 25%)" }} />
</div>
)}
</div>
))}
{/* CRAC label */}
{layout[currentRoomId]?.crac_id && (
<div className="flex items-center justify-center gap-1.5 rounded-md py-1 text-[10px] font-medium"
style={{ backgroundColor: "oklch(0.62 0.17 212 / 8%)", color: "oklch(0.62 0.17 212)" }}>
<Wind className="w-3 h-3" />
{layout[currentRoomId].crac_id.toUpperCase()}
</div>
)}
</div>
{/* Temp legend */}
<div className="flex items-center gap-1 mt-3 text-[10px] text-muted-foreground">
<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-5 h-2.5 rounded-sm inline-block" style={{ backgroundColor: c }} />
))}
<span>Hot</span>
<span className="ml-auto opacity-60 group-hover:opacity-100 transition-opacity">Click to open full map</span>
</div>
</Link>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,58 @@
"use client";
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { PowerBucket } from "@/lib/api";
interface Props {
data: PowerBucket[];
loading?: boolean;
}
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
export function PowerTrendChart({ data, loading }: Props) {
const chartData = data.map((d) => ({ time: formatTime(d.bucket), power: d.total_kw }));
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold">Total Power (kW)</CardTitle>
<span className="text-xs text-muted-foreground">Last 60 minutes</span>
</div>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-64 w-full" />
) : 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>
<linearGradient id="powerGradient" 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="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={(value) => [`${value} kW`, "Power"]}
/>
<Area type="monotone" dataKey="power" stroke="oklch(0.62 0.17 212)" strokeWidth={2} fill="url(#powerGradient)" dot={false} activeDot={{ r: 4 }} />
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,422 @@
"use client";
import { useEffect, useState } from "react";
import {
fetchRackHistory, fetchRackDevices,
type RackHistory, type Device,
} from "@/lib/api";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { TimeRangePicker } from "@/components/ui/time-range-picker";
import { Badge } from "@/components/ui/badge";
import {
LineChart, Line, AreaChart, Area,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from "recharts";
import { Thermometer, Zap, Droplets, Bell, Server } from "lucide-react";
import { cn } from "@/lib/utils";
interface Props {
siteId: string;
rackId: string | null;
onClose: () => void;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function timeAgo(iso: 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 ago`;
return `${Math.floor(m / 60)}h ago`;
}
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
// ── Device type styles ────────────────────────────────────────────────────────
const TYPE_STYLES: Record<string, { bg: string; border: string; text: string; label: string }> = {
server: { bg: "bg-blue-500/15", border: "border-blue-500/40", text: "text-blue-300", label: "Server" },
switch: { bg: "bg-green-500/15", border: "border-green-500/40", text: "text-green-300", label: "Switch" },
patch_panel: { bg: "bg-slate-500/15", border: "border-slate-400/40", text: "text-slate-300", label: "Patch Panel" },
pdu: { bg: "bg-amber-500/15", border: "border-amber-500/40", text: "text-amber-300", label: "PDU" },
storage: { bg: "bg-purple-500/15", border: "border-purple-500/40", text: "text-purple-300", label: "Storage" },
firewall: { bg: "bg-red-500/15", border: "border-red-500/40", text: "text-red-300", label: "Firewall" },
kvm: { bg: "bg-teal-500/15", border: "border-teal-500/40", text: "text-teal-300", label: "KVM" },
};
const TYPE_DOT: Record<string, string> = {
server: "bg-blue-400",
switch: "bg-green-400",
patch_panel: "bg-slate-400",
pdu: "bg-amber-400",
storage: "bg-purple-400",
firewall: "bg-red-400",
kvm: "bg-teal-400",
};
// ── U-diagram ─────────────────────────────────────────────────────────────────
const TOTAL_U = 42;
const U_PX = 20; // height per U in pixels
type Segment = { u: number; height: number; device: Device | null };
function buildSegments(devices: Device[]): Segment[] {
const sorted = [...devices].sort((a, b) => a.u_start - b.u_start);
const segs: Segment[] = [];
let u = 1;
let i = 0;
while (u <= TOTAL_U) {
if (i < sorted.length && sorted[i].u_start === u) {
const d = sorted[i];
segs.push({ u, height: d.u_height, device: d });
u += d.u_height;
i++;
} else {
const nextU = i < sorted.length ? sorted[i].u_start : TOTAL_U + 1;
const empty = Math.min(nextU, TOTAL_U + 1) - u;
if (empty > 0) {
segs.push({ u, height: empty, device: null });
u += empty;
} else {
break;
}
}
}
return segs;
}
function RackDiagram({ devices, loading }: { devices: Device[]; loading: boolean }) {
const [selected, setSelected] = useState<Device | null>(null);
if (loading) {
return (
<div className="mt-3 space-y-1 flex-1">
{Array.from({ length: 12 }).map((_, i) => (
<Skeleton key={i} className="h-5 w-full" />
))}
</div>
);
}
const segments = buildSegments(devices);
const totalPower = devices.reduce((s, d) => s + d.power_draw_w, 0);
return (
<div className="mt-3 flex flex-col flex-1 min-h-0 gap-3">
{/* Legend */}
<div className="flex flex-wrap gap-x-3 gap-y-1 shrink-0">
{Object.entries(TYPE_STYLES).map(([type, style]) => (
devices.some(d => d.type === type) ? (
<div key={type} className="flex items-center gap-1">
<span className={cn("w-2 h-2 rounded-sm", TYPE_DOT[type])} />
<span className="text-[10px] text-muted-foreground">{style.label}</span>
</div>
) : null
))}
</div>
{/* Rack diagram */}
<div className="flex flex-col flex-1 min-h-0 border border-border/50 rounded-lg overflow-hidden">
{/* Header */}
<div className="flex bg-muted/40 border-b border-border/50 px-2 py-1 shrink-0">
<span className="w-8 text-[9px] text-muted-foreground text-right pr-1 shrink-0">U</span>
<span className="flex-1 text-[9px] text-muted-foreground pl-2">
{devices[0]?.rack_id.toUpperCase() ?? "Rack"} 42U
</span>
<span className="text-[9px] text-muted-foreground">{totalPower} W total</span>
</div>
{/* Slots */}
<div className="flex-1 overflow-y-auto">
{segments.map((seg) => {
const style = seg.device ? (TYPE_STYLES[seg.device.type] ?? TYPE_STYLES.server) : null;
const isSelected = selected?.device_id === seg.device?.device_id;
return (
<div
key={seg.u}
style={{ height: seg.height * U_PX }}
className={cn(
"flex items-stretch border-b border-border/20 last:border-0",
seg.device && "cursor-pointer",
)}
onClick={() => setSelected(seg.device && isSelected ? null : seg.device)}
>
{/* U number */}
<div className="w-8 flex items-start justify-end pt-1 pr-1.5 shrink-0">
<span className="text-[9px] text-muted-foreground/50 font-mono leading-none">
{seg.u}
</span>
</div>
{/* Device or empty */}
<div className="flex-1 flex items-stretch py-px pr-1">
{seg.device ? (
<div className={cn(
"flex-1 rounded border flex items-center px-2 gap-2 transition-colors",
style!.bg, style!.border,
isSelected && "ring-1 ring-primary/50",
)}>
<span className={cn("text-xs font-medium truncate flex-1", style!.text)}>
{seg.device.name}
</span>
{seg.height >= 2 && (
<span className="text-[9px] text-muted-foreground/60 font-mono shrink-0">
{seg.device.ip !== "-" ? seg.device.ip : ""}
</span>
)}
<span className="text-[9px] text-muted-foreground/60 font-mono shrink-0">
{seg.device.u_height}U
</span>
</div>
) : (
<div className="flex-1 rounded border border-dashed border-border/25 flex items-center px-2">
{seg.height > 1 && (
<span className="text-[9px] text-muted-foreground/25">
{seg.height}U empty
</span>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Selected device detail */}
{selected && (() => { // shrink-0 via parent gap
const style = TYPE_STYLES[selected.type] ?? TYPE_STYLES.server;
return (
<div className={cn("rounded-lg border p-3 space-y-2", style.bg, style.border)}>
<div className="flex items-start justify-between gap-2">
<div>
<p className={cn("text-sm font-semibold", style.text)}>{selected.name}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">{style.label}</p>
</div>
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-green-500/10 text-green-400">
Online
</span>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div><span className="text-muted-foreground">Serial: </span><span className="font-mono">{selected.serial}</span></div>
<div><span className="text-muted-foreground">IP: </span><span className="font-mono">{selected.ip !== "-" ? selected.ip : "—"}</span></div>
<div><span className="text-muted-foreground">Position: </span><span>U{selected.u_start}U{selected.u_start + selected.u_height - 1}</span></div>
<div><span className="text-muted-foreground">Power: </span><span>{selected.power_draw_w} W</span></div>
</div>
</div>
);
})()}
</div>
);
}
// ── History tab ───────────────────────────────────────────────────────────────
function HistoryTab({ data, hours, onHoursChange, loading }: {
data: RackHistory | null;
hours: number;
onHoursChange: (h: number) => void;
loading: boolean;
}) {
const chartData = (data?.history ?? []).map(p => ({ ...p, time: formatTime(p.bucket) }));
const latest = chartData[chartData.length - 1];
return (
<div>
<div className="flex justify-end mt-3 mb-4">
<TimeRangePicker value={hours} onChange={onHoursChange} />
</div>
{loading ? (
<div className="space-y-4">
<Skeleton className="h-40 w-full" /><Skeleton className="h-40 w-full" />
</div>
) : (
<div className="space-y-5">
{latest && (
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg bg-muted/30 p-3 text-center">
<Thermometer className="w-4 h-4 mx-auto mb-1 text-primary" />
<p className="text-lg font-bold">{latest.temperature !== undefined ? `${latest.temperature}°C` : "—"}</p>
<p className="text-[10px] text-muted-foreground">Temperature</p>
</div>
<div className="rounded-lg bg-muted/30 p-3 text-center">
<Zap className="w-4 h-4 mx-auto mb-1 text-amber-400" />
<p className="text-lg font-bold">{latest.power_kw !== undefined ? `${latest.power_kw} kW` : "—"}</p>
<p className="text-[10px] text-muted-foreground">Power</p>
</div>
<div className="rounded-lg bg-muted/30 p-3 text-center">
<Droplets className="w-4 h-4 mx-auto mb-1 text-blue-400" />
<p className="text-lg font-bold">{latest.humidity !== undefined ? `${latest.humidity}%` : "—"}</p>
<p className="text-[10px] text-muted-foreground">Humidity</p>
</div>
</div>
)}
<div>
<p className="text-xs font-medium text-muted-foreground mb-2 flex items-center gap-1">
<Thermometer className="w-3 h-3" /> Temperature (°C)
</p>
<ResponsiveContainer width="100%" height={140}>
<LineChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
<XAxis dataKey="time" tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} domain={["auto", "auto"]} />
<Tooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "11px" }}
formatter={(v) => [`${v}°C`, "Temp"]}
/>
<Line type="monotone" dataKey="temperature" stroke="oklch(0.62 0.17 212)" strokeWidth={2} dot={false} activeDot={{ r: 3 }} />
</LineChart>
</ResponsiveContainer>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-2 flex items-center gap-1">
<Zap className="w-3 h-3" /> Power Draw (kW)
</p>
<ResponsiveContainer width="100%" height={140}>
<AreaChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="powerGrad" 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%)" />
<XAxis dataKey="time" tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} domain={[0, "auto"]} />
<Tooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "11px" }}
formatter={(v) => [`${v} kW`, "Power"]}
/>
<Area type="monotone" dataKey="power_kw" stroke="oklch(0.78 0.17 84)" fill="url(#powerGrad)" strokeWidth={2} dot={false} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
)}
</div>
);
}
// ── Alarms tab ────────────────────────────────────────────────────────────────
const severityColors: Record<string, string> = {
critical: "bg-destructive/15 text-destructive border-destructive/30",
warning: "bg-amber-500/15 text-amber-400 border-amber-500/30",
info: "bg-blue-500/15 text-blue-400 border-blue-500/30",
};
const stateColors: Record<string, string> = {
active: "bg-destructive/10 text-destructive",
acknowledged: "bg-amber-500/10 text-amber-400",
resolved: "bg-green-500/10 text-green-400",
};
function AlarmsTab({ data }: { data: RackHistory | null }) {
return (
<div className="mt-3">
{!data || data.alarms.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-8">
No alarms on record for this rack.
</p>
) : (
<div className="space-y-2">
{data.alarms.map((alarm) => (
<div
key={alarm.id}
className={cn("rounded-lg border px-3 py-2 text-xs", severityColors[alarm.severity] ?? severityColors.info)}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{alarm.message}</span>
<Badge className={cn("text-[9px] border-0 shrink-0", stateColors[alarm.state] ?? stateColors.active)}>
{alarm.state}
</Badge>
</div>
<p className="mt-0.5 opacity-70">{timeAgo(alarm.triggered_at)}</p>
</div>
))}
</div>
)}
</div>
);
}
// ── Sheet ─────────────────────────────────────────────────────────────────────
export function RackDetailSheet({ siteId, rackId, onClose }: Props) {
const [data, setData] = useState<RackHistory | null>(null);
const [devices, setDevices] = useState<Device[]>([]);
const [hours, setHours] = useState(6);
const [histLoading, setHistLoad] = useState(false);
const [devLoading, setDevLoad] = useState(false);
useEffect(() => {
if (!rackId) { setData(null); setDevices([]); return; }
setHistLoad(true);
setDevLoad(true);
fetchRackHistory(siteId, rackId, hours)
.then(setData).catch(() => setData(null))
.finally(() => setHistLoad(false));
fetchRackDevices(siteId, rackId)
.then(setDevices).catch(() => setDevices([]))
.finally(() => setDevLoad(false));
}, [siteId, rackId, hours]);
const alarmCount = data?.alarms.filter(a => a.state === "active").length ?? 0;
return (
<Sheet open={!!rackId} onOpenChange={open => { if (!open) onClose(); }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col h-dvh overflow-hidden">
<SheetHeader className="mb-2 shrink-0">
<SheetTitle className="flex items-center gap-2">
<Server className="w-4 h-4 text-primary" />
{rackId?.toUpperCase() ?? ""}
</SheetTitle>
<p className="text-xs text-muted-foreground">Singapore DC01</p>
</SheetHeader>
<Tabs defaultValue="layout" className="flex flex-col flex-1 min-h-0">
<TabsList className="w-full shrink-0">
<TabsTrigger value="layout" className="flex-1">Layout</TabsTrigger>
<TabsTrigger value="history" className="flex-1">History</TabsTrigger>
<TabsTrigger value="alarms" className="flex-1">
Alarms
{alarmCount > 0 && (
<span className="ml-1.5 text-[9px] font-bold text-destructive">{alarmCount}</span>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="layout" className="flex flex-col flex-1 min-h-0 overflow-hidden mt-0">
<RackDiagram devices={devices} loading={devLoading} />
</TabsContent>
<TabsContent value="history" className="flex-1 overflow-y-auto">
<HistoryTab
data={data}
hours={hours}
onHoursChange={setHours}
loading={histLoading}
/>
</TabsContent>
<TabsContent value="alarms" className="flex-1 overflow-y-auto">
<AlarmsTab data={data} />
</TabsContent>
</Tabs>
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,97 @@
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Thermometer, Zap, Bell, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import type { RoomStatus } from "@/lib/api";
interface Props {
rooms: RoomStatus[];
loading?: boolean;
}
const statusConfig: Record<string, { label: string; dot: string; bg: string }> = {
ok: { label: "Healthy", dot: "bg-green-500", bg: "bg-green-500/10 text-green-400" },
warning: { label: "Warning", dot: "bg-amber-500", bg: "bg-amber-500/10 text-amber-400" },
critical: { label: "Critical", dot: "bg-destructive", bg: "bg-destructive/10 text-destructive" },
};
const roomLabels: Record<string, string> = {
"hall-a": "Hall A",
"hall-b": "Hall B",
"hall-c": "Hall C",
};
export function RoomStatusGrid({ rooms, loading }: Props) {
const router = useRouter();
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">Room Status Singapore DC01</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-28 w-full rounded-lg" />
))}
</div>
) : rooms.length === 0 ? (
<div className="h-28 flex items-center justify-center text-sm text-muted-foreground">
Waiting for room data...
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{rooms.map((room) => {
const s = statusConfig[room.status];
return (
<button
key={room.room_id}
onClick={() => router.push("/environmental")}
className="rounded-lg border border-border bg-muted/30 p-4 space-y-3 text-left w-full hover:bg-muted/50 hover:border-primary/30 transition-colors group"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{roomLabels[room.room_id] ?? room.room_id}
</span>
<div className="flex items-center gap-2">
<span className={cn("flex items-center gap-1.5 text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide", s.bg)}>
<span className={cn("w-1.5 h-1.5 rounded-full", s.dot)} />
{s.label}
</span>
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground/40 group-hover:text-primary transition-colors" />
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1 text-muted-foreground">
<Thermometer className="w-3 h-3" /> Temp
</span>
<span className="font-semibold">{room.avg_temp.toFixed(1)}°C</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1 text-muted-foreground">
<Zap className="w-3 h-3" /> Power
</span>
<span className="font-semibold">{room.total_kw.toFixed(1)} kW</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1 text-muted-foreground">
<Bell className="w-3 h-3" /> Alarms
</span>
<span className={cn("font-semibold", room.alarm_count > 0 ? "text-destructive" : "")}>
{room.alarm_count === 0 ? "None" : room.alarm_count}
</span>
</div>
</div>
</button>
);
})}
</div>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,75 @@
"use client";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { TempBucket } from "@/lib/api";
interface Props {
data: TempBucket[];
loading?: boolean;
}
type ChartRow = { time: string; [roomId: string]: string | number };
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
export function TemperatureTrendChart({ data, loading }: Props) {
// Pivot flat rows [{bucket, room_id, avg_temp}] into [{time, "hall-a": 23.1, "hall-b": 24.2}]
const bucketMap = new Map<string, ChartRow>();
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.avg_temp;
}
const chartData = Array.from(bucketMap.values());
const roomIds = [...new Set(data.map((d) => d.room_id))].sort();
const LINE_COLORS = ["oklch(0.62 0.17 212)", "oklch(0.7 0.15 162)", "oklch(0.75 0.18 84)"];
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold">Temperature (°C)</CardTitle>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{roomIds.map((id, i) => (
<span key={id} className="flex items-center gap-1">
<span className="w-3 h-0.5 inline-block" style={{ backgroundColor: LINE_COLORS[i] }} />
{id}
</span>
))}
</div>
</div>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-64 w-full" />
) : 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}>
<LineChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
<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} 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={(value, name) => [`${value}°C`, name]}
/>
<ReferenceLine y={26} stroke="oklch(0.78 0.17 84)" strokeDasharray="4 4" strokeWidth={1}
label={{ value: "Warn", fontSize: 9, fill: "oklch(0.78 0.17 84)", position: "right" }} />
{roomIds.map((id, i) => (
<Line key={id} type="monotone" dataKey={id} stroke={LINE_COLORS[i]} strokeWidth={2} dot={false} activeDot={{ r: 4 }} />
))}
</LineChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,357 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
} from "@/components/ui/sheet";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as RechartTooltip,
ResponsiveContainer, ReferenceLine,
} from "recharts";
import { Battery, Zap, Activity, AlertTriangle, CheckCircle2, TrendingDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { fetchUpsHistory, type UpsAsset, type UpsHistoryPoint } from "@/lib/api";
const SITE_ID = "sg-01";
function fmt(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function StatRow({ label, value, color }: { label: string; value: string; color?: string }) {
return (
<div className="flex items-center justify-between py-2 border-b border-border/20 last:border-0">
<span className="text-sm text-muted-foreground">{label}</span>
<span className={cn("text-sm font-semibold tabular-nums", color)}>{value}</span>
</div>
);
}
function GaugeBar({
label, value, max, unit, warnAt, critAt, reverse = false,
}: {
label: string; value: number | null; max: number; unit: string;
warnAt?: number; critAt?: number; reverse?: boolean;
}) {
const v = value ?? 0;
const pct = Math.min(100, (v / max) * 100);
const isWarn = warnAt !== undefined && (reverse ? v < warnAt : v >= warnAt);
const isCrit = critAt !== undefined && (reverse ? v < critAt : v >= critAt);
const barColor = isCrit ? "bg-destructive" : isWarn ? "bg-amber-500" : "bg-green-500";
const textColor = isCrit ? "text-destructive" : isWarn ? "text-amber-400" : "";
return (
<div className="space-y-1.5">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">{label}</span>
<span className={cn("font-semibold", textColor)}>
{value !== null ? `${value}${unit}` : "—"}
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full transition-all duration-500", barColor)}
style={{ width: `${pct}%` }} />
</div>
</div>
);
}
function MiniChart({
data, dataKey, color, refLines = [], unit, domain,
}: {
data: UpsHistoryPoint[]; dataKey: keyof UpsHistoryPoint; color: string;
refLines?: { y: number; color: string; label: string }[];
unit?: string; domain?: [number, number];
}) {
if (data.length === 0) {
return (
<div className="h-36 flex items-center justify-center text-xs text-muted-foreground">
Waiting for data...
</div>
);
}
return (
<ResponsiveContainer width="100%" height={144}>
<LineChart data={data} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
<XAxis dataKey="bucket" tickFormatter={fmt}
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
<YAxis domain={domain} tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
tickLine={false} axisLine={false} />
<RechartTooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "11px" }}
formatter={(v) => [`${v}${unit ?? ""}`, String(dataKey)]}
labelFormatter={(l) => fmt(String(l))}
/>
{refLines.map((r) => (
<ReferenceLine key={r.y} y={r.y} stroke={r.color} strokeDasharray="4 4" strokeWidth={1}
label={{ value: r.label, fontSize: 8, fill: r.color, position: "insideTopRight" }} />
))}
<Line type="monotone" dataKey={dataKey as string} stroke={color}
dot={false} strokeWidth={2} connectNulls />
</LineChart>
</ResponsiveContainer>
);
}
// ── Overview Tab ──────────────────────────────────────────────────────────────
function OverviewTab({ ups }: { ups: UpsAsset }) {
const onBattery = ups.state === "battery";
const overload = ups.state === "overload";
const charge = ups.charge_pct ?? 0;
const runtime = ups.runtime_min ?? null;
const load = ups.load_pct ?? null;
return (
<div className="space-y-5">
{/* State hero */}
<div className={cn(
"rounded-xl border px-5 py-4 flex items-center gap-4",
overload ? "border-destructive/40 bg-destructive/5" :
onBattery ? "border-amber-500/40 bg-amber-500/5" :
"border-green-500/30 bg-green-500/5",
)}>
<Battery className={cn("w-8 h-8",
overload ? "text-destructive" : onBattery ? "text-amber-400" : "text-green-400"
)} />
<div>
<p className={cn("text-xl font-bold",
overload ? "text-destructive" : onBattery ? "text-amber-400" : "text-green-400"
)}>
{overload ? "Overloaded" : onBattery ? "On Battery" : "Mains Power"}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{overload
? "Critical — load exceeds safe capacity"
: onBattery
? "Mains power lost — running on battery"
: "Grid power normal — charging battery"}
</p>
</div>
</div>
{/* Gauges */}
<div className="space-y-4">
<GaugeBar label="Battery charge" value={ups.charge_pct} max={100} unit="%" warnAt={80} critAt={50} reverse />
<GaugeBar label="Load" value={load} max={100} unit="%" warnAt={85} critAt={95} />
</div>
{/* Runtime + voltage row */}
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg bg-muted/20 px-4 py-3 text-center">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Est. Runtime</p>
<p className={cn("text-2xl font-bold tabular-nums",
runtime !== null && runtime < 5 ? "text-destructive" :
runtime !== null && runtime < 15 ? "text-amber-400" : "text-green-400",
)}>
{runtime !== null ? `${Math.round(runtime)}` : "—"}
</p>
<p className="text-[10px] text-muted-foreground">minutes</p>
</div>
<div className="rounded-lg bg-muted/20 px-4 py-3 text-center">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Input Voltage</p>
<p className={cn("text-2xl font-bold tabular-nums",
ups.voltage_v !== null && (ups.voltage_v < 210 || ups.voltage_v > 250) ? "text-amber-400" : "",
)}>
{ups.voltage_v !== null ? `${ups.voltage_v}` : "—"}
</p>
<p className="text-[10px] text-muted-foreground">V AC</p>
</div>
</div>
{/* Quick stats */}
<div className="space-y-0">
<StatRow label="Unit ID" value={ups.ups_id.toUpperCase()} />
<StatRow
label="Battery charge"
value={charge < 50 ? `${charge.toFixed(1)}% — Low` : `${charge.toFixed(1)}%`}
color={charge < 50 ? "text-destructive" : charge < 80 ? "text-amber-400" : "text-green-400"}
/>
<StatRow
label="Runtime remaining"
value={runtime !== null ? `${Math.round(runtime)} min` : "—"}
color={runtime !== null && runtime < 5 ? "text-destructive" : runtime !== null && runtime < 15 ? "text-amber-400" : ""}
/>
<StatRow
label="Load"
value={load !== null ? `${load.toFixed(1)}%` : "—"}
color={load !== null && load >= 95 ? "text-destructive" : load !== null && load >= 85 ? "text-amber-400" : ""}
/>
<StatRow
label="Input voltage"
value={ups.voltage_v !== null ? `${ups.voltage_v} V` : "—"}
color={ups.voltage_v !== null && (ups.voltage_v < 210 || ups.voltage_v > 250) ? "text-amber-400" : ""}
/>
</div>
</div>
);
}
// ── Battery Tab ───────────────────────────────────────────────────────────────
function BatteryTab({ history }: { history: UpsHistoryPoint[] }) {
const charge = history.map((d) => ({ ...d, bucket: d.bucket }));
const runtime = history.map((d) => ({ ...d, bucket: d.bucket }));
return (
<div className="space-y-6">
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Battery Charge (%)
</p>
<MiniChart
data={charge}
dataKey="charge_pct"
color="oklch(0.65 0.16 145)"
unit="%"
domain={[0, 100]}
refLines={[
{ y: 80, color: "oklch(0.72 0.18 84)", label: "Warn 80%" },
{ y: 50, color: "oklch(0.55 0.22 25)", label: "Crit 50%" },
]}
/>
</div>
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Estimated Runtime (min)
</p>
<MiniChart
data={runtime}
dataKey="runtime_min"
color="oklch(0.62 0.17 212)"
unit=" min"
refLines={[
{ y: 15, color: "oklch(0.72 0.18 84)", label: "Warn 15m" },
{ y: 5, color: "oklch(0.55 0.22 25)", label: "Crit 5m" },
]}
/>
</div>
</div>
);
}
// ── Load Tab ──────────────────────────────────────────────────────────────────
function LoadTab({ history }: { history: UpsHistoryPoint[] }) {
return (
<div className="space-y-6">
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Load (%)
</p>
<MiniChart
data={history}
dataKey="load_pct"
color="oklch(0.7 0.15 50)"
unit="%"
domain={[0, 100]}
refLines={[
{ y: 85, color: "oklch(0.72 0.18 84)", label: "Warn 85%" },
{ y: 95, color: "oklch(0.55 0.22 25)", label: "Crit 95%" },
]}
/>
</div>
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Input Voltage (V)
</p>
<MiniChart
data={history}
dataKey="voltage_v"
color="oklch(0.62 0.17 280)"
unit=" V"
refLines={[
{ y: 210, color: "oklch(0.55 0.22 25)", label: "Low 210V" },
{ y: 250, color: "oklch(0.55 0.22 25)", label: "High 250V" },
]}
/>
</div>
</div>
);
}
// ── Sheet ─────────────────────────────────────────────────────────────────────
interface Props {
ups: UpsAsset | null;
onClose: () => void;
}
export function UpsDetailSheet({ ups, onClose }: Props) {
const [history, setHistory] = useState<UpsHistoryPoint[]>([]);
const [hours, setHours] = useState(6);
const loadHistory = useCallback(async () => {
if (!ups) return;
try {
const h = await fetchUpsHistory(SITE_ID, ups.ups_id, hours);
setHistory(h);
} catch { /* keep stale */ }
}, [ups, hours]);
useEffect(() => {
if (ups) loadHistory();
}, [ups, loadHistory]);
if (!ups) return null;
const overload = ups.state === "overload";
const onBattery = ups.state === "battery";
return (
<Sheet open={!!ups} onOpenChange={(open) => { if (!open) onClose(); }}>
<SheetContent side="right" className="w-full sm:max-w-lg overflow-y-auto">
<SheetHeader className="pb-4 border-b border-border/30">
<div className="flex items-center justify-between">
<SheetTitle className="flex items-center gap-2">
<Battery className="w-5 h-5 text-primary" />
{ups.ups_id.toUpperCase()}
</SheetTitle>
<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",
)}>
{overload || onBattery
? <AlertTriangle className="w-3 h-3" />
: <CheckCircle2 className="w-3 h-3" />}
{overload ? "Overloaded" : onBattery ? "On Battery" : "Mains"}
</span>
</div>
</SheetHeader>
<div className="pt-4">
<Tabs defaultValue="overview">
<div className="flex items-center justify-between mb-4 gap-3">
<TabsList className="h-8">
<TabsTrigger value="overview" className="text-xs px-3">Overview</TabsTrigger>
<TabsTrigger value="battery" className="text-xs px-3">Battery</TabsTrigger>
<TabsTrigger value="load" className="text-xs px-3">Load & Voltage</TabsTrigger>
</TabsList>
<select
value={hours}
onChange={(e) => setHours(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>
<TabsContent value="overview" className="mt-0">
<OverviewTab ups={ups} />
</TabsContent>
<TabsContent value="battery" className="mt-0">
<BatteryTab history={history} />
</TabsContent>
<TabsContent value="load" className="mt-0">
<LoadTab history={history} />
</TabsContent>
</Tabs>
</div>
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,47 @@
"use client";
import React from "react";
import { ErrorCard } from "@/components/ui/error-card";
interface Props {
children: React.ReactNode;
fallback?: React.ReactNode;
}
interface State {
hasError: boolean;
message: string;
}
export class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, message: "" };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, message: error.message };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("[ErrorBoundary]", error, info);
}
handleRetry = () => {
this.setState({ hasError: false, message: "" });
};
render() {
if (this.state.hasError) {
return (
this.props.fallback ?? (
<ErrorCard
message={this.state.message || "An unexpected error occurred."}
onRetry={this.handleRetry}
/>
)
);
}
return this.props.children;
}
}

View file

@ -0,0 +1,18 @@
import { cn } from "@/lib/utils";
interface PageShellProps {
children: React.ReactNode;
className?: string;
}
/**
* Standard page wrapper enforces consistent vertical spacing across all pages.
* Every (dashboard) page should wrap its content in this component.
*/
export function PageShell({ children, className }: PageShellProps) {
return (
<div className={cn("space-y-6", className)}>
{children}
</div>
);
}

View file

@ -0,0 +1,211 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
Thermometer,
Zap,
Wind,
Server,
Bell,
BarChart3,
Settings,
Database,
ChevronLeft,
ChevronRight,
Map,
Gauge,
Fuel,
Droplets,
Flame,
Leaf,
Network,
Wrench,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useState } from "react";
interface NavItem {
href: string;
label: string;
icon: React.ElementType;
}
interface NavGroup {
label: string;
items: NavItem[];
}
const navGroups: NavGroup[] = [
{
label: "Overview",
items: [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/floor-map", label: "Floor Map", icon: Map },
],
},
{
label: "Infrastructure",
items: [
{ href: "/power", label: "Power", icon: Zap },
{ href: "/generator", label: "Generator", icon: Fuel },
{ href: "/cooling", label: "Cooling", icon: Wind },
{ href: "/environmental", label: "Environmental", icon: Thermometer },
{ href: "/network", label: "Network", icon: Network },
],
},
{
label: "Safety",
items: [
{ href: "/leak", label: "Leak Detection", icon: Droplets },
{ href: "/fire", label: "Fire & Safety", icon: Flame },
],
},
{
label: "Operations",
items: [
{ href: "/assets", label: "Assets", icon: Server },
{ href: "/alarms", label: "Alarms", icon: Bell },
{ href: "/capacity", label: "Capacity", icon: Gauge },
],
},
{
label: "Management",
items: [
{ href: "/reports", label: "Reports", icon: BarChart3 },
{ href: "/energy", label: "Energy & CO₂", icon: Leaf },
{ href: "/maintenance", label: "Maintenance", icon: Wrench },
],
},
];
export function Sidebar() {
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);
return (
<aside
className={cn(
"flex flex-col h-screen border-r border-sidebar-border bg-sidebar text-sidebar-foreground transition-all duration-300 ease-in-out shrink-0",
collapsed ? "w-16" : "w-60"
)}
>
{/* Logo */}
<div className={cn(
"flex items-center gap-3 px-4 py-5 border-b border-sidebar-border shrink-0",
collapsed && "justify-center px-2"
)}>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary shrink-0">
<Database className="w-4 h-4 text-primary-foreground" />
</div>
{!collapsed && (
<div className="flex flex-col leading-tight">
<span className="text-sm font-bold tracking-tight text-sidebar-foreground">DemoBMS</span>
<span className="text-[10px] text-sidebar-foreground/50 uppercase tracking-widest">Infrastructure</span>
</div>
)}
</div>
{/* Main nav */}
<nav className="flex-1 px-2 py-3 overflow-y-auto space-y-4">
{navGroups.map((group) => (
<div key={group.label}>
{/* Section header — hidden when collapsed */}
{!collapsed && (
<p className="px-3 mb-1 text-[10px] font-semibold uppercase tracking-widest text-sidebar-foreground/35 select-none">
{group.label}
</p>
)}
{collapsed && (
<div className="my-1 mx-2 border-t border-sidebar-border/60" />
)}
<div className="space-y-0.5">
{group.items.map(({ href, label, icon: Icon }) => {
const active = pathname === href || pathname.startsWith(href + "/");
return (
<Tooltip key={href} delayDuration={0}>
<TooltipTrigger asChild>
<Link
href={href}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:ring-offset-background",
active
? "bg-sidebar-accent text-primary"
: "text-sidebar-foreground/70",
collapsed && "justify-center px-2"
)}
>
<Icon className={cn("w-4 h-4 shrink-0", active && "text-primary")} />
{!collapsed && <span className="flex-1">{label}</span>}
</Link>
</TooltipTrigger>
{collapsed && (
<TooltipContent side="right">{label}</TooltipContent>
)}
</Tooltip>
);
})}
</div>
</div>
))}
</nav>
{/* Bottom section: collapse toggle + settings */}
<div className="px-2 pb-4 border-t border-sidebar-border pt-3 space-y-0.5 shrink-0">
{/* Collapse toggle */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
onClick={() => setCollapsed(!collapsed)}
className={cn(
"flex items-center gap-3 w-full rounded-md px-3 py-2.5 text-sm font-medium transition-colors",
"text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:ring-offset-background",
collapsed && "justify-center px-2"
)}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{collapsed ? (
<ChevronRight className="w-4 h-4 shrink-0" />
) : (
<>
<ChevronLeft className="w-4 h-4 shrink-0" />
<span>Collapse</span>
</>
)}
</button>
</TooltipTrigger>
{collapsed && (
<TooltipContent side="right">Expand sidebar</TooltipContent>
)}
</Tooltip>
{/* Settings */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Link
href="/settings"
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:ring-offset-background",
pathname === "/settings" ? "bg-sidebar-accent text-primary" : "text-sidebar-foreground/70",
collapsed && "justify-center px-2"
)}
>
<Settings className="w-4 h-4 shrink-0" />
{!collapsed && <span>Settings</span>}
</Link>
</TooltipTrigger>
{collapsed && (
<TooltipContent side="right">Settings</TooltipContent>
)}
</Tooltip>
</div>
</aside>
);
}

View file

@ -0,0 +1,100 @@
"use client";
import { Bell, Menu, Moon, Sun } from "lucide-react";
import { SimulatorPanel } from "@/components/simulator/SimulatorPanel";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { UserButton } from "@clerk/nextjs";
import { useTheme } from "next-themes";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useAlarmCount } from "@/lib/alarm-context";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Sidebar } from "./sidebar";
const pageTitles: Record<string, string> = {
"/dashboard": "Overview",
"/floor-map": "Floor Map",
"/power": "Power Management",
"/generator": "Generator & Transfer",
"/cooling": "Cooling & Optimisation",
"/environmental": "Environmental Monitoring",
"/leak": "Leak Detection",
"/fire": "Fire & Safety",
"/network": "Network Infrastructure",
"/assets": "Asset Management",
"/alarms": "Alarms & Events",
"/capacity": "Capacity Planning",
"/reports": "Reports",
"/energy": "Energy & Sustainability",
"/maintenance": "Maintenance Windows",
"/settings": "Settings",
};
export function Topbar() {
const pathname = usePathname();
const pageTitle = pageTitles[pathname] ?? "DemoBMS";
const { active: activeAlarms } = useAlarmCount();
const { theme, setTheme } = useTheme();
return (
<header className="sticky top-0 z-30 flex items-center justify-between h-14 px-4 border-b border-border bg-background/80 backdrop-blur-sm shrink-0">
{/* Left: mobile menu + page title */}
<div className="flex items-center gap-3">
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden" aria-label="Open menu">
<Menu className="w-4 h-4" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-60">
<Sidebar />
</SheetContent>
</Sheet>
<h1 className="text-sm font-semibold text-foreground">{pageTitle}</h1>
</div>
{/* Right: alarm bell + user */}
<div className="flex items-center gap-2">
{/* Alarm bell — single canonical alarm indicator */}
<Button variant="ghost" size="icon" className="relative h-8 w-8" asChild>
<Link href="/alarms" aria-label={`Alarms${activeAlarms > 0 ? `${activeAlarms} active` : ""}`}>
<Bell className="w-4 h-4" />
{activeAlarms > 0 && (
<Badge
variant="destructive"
className="absolute -top-0.5 -right-0.5 h-4 min-w-4 px-1 text-[9px] leading-none"
>
{activeAlarms > 99 ? "99+" : activeAlarms}
</Badge>
)}
</Link>
</Button>
{/* Scenario simulator */}
<SimulatorPanel />
{/* Theme toggle */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
aria-label="Toggle theme"
>
{theme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</Button>
{/* Clerk user button */}
<UserButton
appearance={{
elements: {
avatarBox: "w-7 h-7",
},
}}
/>
</div>
</header>
);
}

View file

@ -0,0 +1,289 @@
"use client"
import React, { useEffect, useState } from "react"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { fetchSensor, type SensorDevice } from "@/lib/api"
import { cn } from "@/lib/utils"
import { Cpu, Radio, WifiOff } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
// ── Props ─────────────────────────────────────────────────────────────────────
interface Props {
sensorId: number | null
onClose: () => void
}
// ── Label maps ────────────────────────────────────────────────────────────────
const DEVICE_TYPE_LABELS: Record<string, string> = {
ups: "UPS",
generator: "Generator",
crac: "CRAC Unit",
chiller: "Chiller",
ats: "Transfer Switch (ATS)",
rack: "Rack PDU",
network_switch: "Network Switch",
leak: "Leak Sensor",
fire_zone: "Fire / VESDA Zone",
custom: "Custom",
}
const PROTOCOL_LABELS: Record<string, string> = {
mqtt: "MQTT",
modbus_tcp: "Modbus TCP",
modbus_rtu: "Modbus RTU",
snmp: "SNMP",
bacnet: "BACnet",
http: "HTTP Poll",
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatTime(iso: string): string {
try {
return new Date(iso).toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
} catch {
return iso
}
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString("en-GB", {
dateStyle: "medium",
timeStyle: "short",
})
} catch {
return iso
}
}
// ── Sub-components ────────────────────────────────────────────────────────────
function SectionHeading({ children }: { children: React.ReactNode }) {
return (
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
{children}
</h3>
)
}
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between gap-4 py-1.5 border-b border-border/50 last:border-0">
<span className="text-xs text-muted-foreground shrink-0">{label}</span>
<span className="text-xs text-foreground text-right break-all">{value ?? "—"}</span>
</div>
)
}
// ── Loading skeleton ──────────────────────────────────────────────────────────
function LoadingSkeleton() {
return (
<div className="flex flex-col gap-4 px-6 pb-6">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-24" />
<div className="flex flex-col gap-2 mt-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
<div className="flex flex-col gap-2 mt-4">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
</div>
)
}
// ── Badge ─────────────────────────────────────────────────────────────────────
function Badge({
children,
variant = "default",
}: {
children: React.ReactNode
variant?: "default" | "success" | "muted"
}) {
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
variant === "success" && "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
variant === "muted" && "bg-muted text-muted-foreground",
variant === "default" && "bg-primary/10 text-primary",
)}
>
{children}
</span>
)
}
// ── Main component ────────────────────────────────────────────────────────────
export function SensorDetailSheet({ sensorId, onClose }: Props) {
const open = sensorId !== null
const [sensor, setSensor] = useState<SensorDevice | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// ── Fetch when sensorId changes to a non-null value ───────────────────────
useEffect(() => {
if (sensorId === null) {
setSensor(null)
setError(null)
return
}
let cancelled = false
setLoading(true)
setError(null)
fetchSensor(sensorId)
.then(s => { if (!cancelled) setSensor(s) })
.catch(err => { if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load sensor") })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [sensorId])
// ── Protocol config rows ──────────────────────────────────────────────────
function renderProtocolConfig(config: Record<string, unknown>) {
const entries = Object.entries(config)
if (entries.length === 0) {
return <p className="text-xs text-muted-foreground">No config stored.</p>
}
return (
<div className="rounded-md border border-border overflow-hidden">
{entries.map(([k, v]) => (
<div key={k} className="flex justify-between gap-4 px-3 py-1.5 border-b border-border/50 last:border-0 bg-muted/20">
<span className="text-xs text-muted-foreground font-mono shrink-0">{k}</span>
<span className="text-xs text-foreground font-mono text-right break-all">{String(v)}</span>
</div>
))}
</div>
)
}
// ── Recent readings table ─────────────────────────────────────────────────
function renderRecentReadings(readings: NonNullable<SensorDevice["recent_readings"]>) {
if (readings.length === 0) {
return (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-3 text-xs text-muted-foreground">
<WifiOff className="size-3.5 shrink-0" />
<span>No readings in the last 10 minutes check connection</span>
</div>
)
}
return (
<div className="rounded-md border border-border overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-muted/40">
<th className="px-3 py-2 text-left font-medium text-muted-foreground">Sensor Type</th>
<th className="px-3 py-2 text-right font-medium text-muted-foreground">Value</th>
<th className="px-3 py-2 text-right font-medium text-muted-foreground">Recorded At</th>
</tr>
</thead>
<tbody>
{readings.map((r, i) => (
<tr
key={i}
className="border-b border-border/50 last:border-0 hover:bg-muted/20 transition-colors"
>
<td className="px-3 py-1.5 font-mono text-foreground">{r.sensor_type}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-foreground">
{r.value}
{r.unit && (
<span className="ml-1 text-muted-foreground">{r.unit}</span>
)}
</td>
<td className="px-3 py-1.5 text-right tabular-nums text-muted-foreground">
{formatTime(r.recorded_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// ── Render ────────────────────────────────────────────────────────────────
return (
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col overflow-y-auto">
<SheetHeader className="px-6 pt-6 pb-2">
<SheetTitle className="flex items-center gap-2">
<Cpu className="size-4 text-muted-foreground" />
{loading
? <Skeleton className="h-5 w-40" />
: (sensor?.name ?? "Sensor Detail")
}
</SheetTitle>
{/* Header badges */}
{sensor && !loading && (
<div className="flex flex-wrap gap-2 mt-1">
<Badge variant="default">
{DEVICE_TYPE_LABELS[sensor.device_type] ?? sensor.device_type}
</Badge>
<Badge variant={sensor.enabled ? "success" : "muted"}>
{sensor.enabled ? "Enabled" : "Disabled"}
</Badge>
<Badge variant="muted">
<Radio className="size-2.5 mr-1" />
{PROTOCOL_LABELS[sensor.protocol] ?? sensor.protocol}
</Badge>
</div>
)}
</SheetHeader>
<div className="flex flex-col flex-1 px-6 pb-6 gap-6 overflow-y-auto">
{loading && <LoadingSkeleton />}
{!loading && error && (
<p className="text-xs text-destructive">{error}</p>
)}
{!loading && sensor && (
<>
{/* ── Device Info ── */}
<section>
<SectionHeading>Device Info</SectionHeading>
<div className="rounded-md border border-border px-3">
<InfoRow label="Device ID" value={<span className="font-mono">{sensor.device_id}</span>} />
<InfoRow label="Type" value={DEVICE_TYPE_LABELS[sensor.device_type] ?? sensor.device_type} />
<InfoRow label="Room" value={sensor.room_id ?? "—"} />
<InfoRow label="Protocol" value={PROTOCOL_LABELS[sensor.protocol] ?? sensor.protocol} />
<InfoRow label="Created" value={formatDate(sensor.created_at)} />
</div>
</section>
{/* ── Protocol Config ── */}
<section>
<SectionHeading>Protocol Config</SectionHeading>
{renderProtocolConfig(sensor.protocol_config ?? {})}
</section>
{/* ── Recent Readings ── */}
<section>
<SectionHeading>Recent Readings (last 10 mins)</SectionHeading>
{renderRecentReadings(sensor.recent_readings ?? [])}
</section>
</>
)}
</div>
</SheetContent>
</Sheet>
)
}

View file

@ -0,0 +1,574 @@
"use client"
import React, { useEffect, useState } from "react"
import { Loader2, Info } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { cn } from "@/lib/utils"
import {
type SensorDevice,
type SensorCreate,
type DeviceType,
type Protocol,
createSensor,
updateSensor,
} from "@/lib/api"
// ── Option maps ───────────────────────────────────────────────────────────────
const DEVICE_TYPE_OPTIONS: { value: DeviceType; label: string }[] = [
{ value: "ups", label: "UPS" },
{ value: "generator", label: "Generator" },
{ value: "crac", label: "CRAC Unit" },
{ value: "chiller", label: "Chiller" },
{ value: "ats", label: "Transfer Switch (ATS)" },
{ value: "rack", label: "Rack PDU" },
{ value: "network_switch", label: "Network Switch" },
{ value: "leak", label: "Leak Sensor" },
{ value: "fire_zone", label: "Fire / VESDA Zone" },
{ value: "custom", label: "Custom" },
]
const PROTOCOL_OPTIONS: { value: Protocol; label: string }[] = [
{ value: "mqtt", label: "MQTT" },
{ value: "modbus_tcp", label: "Modbus TCP" },
{ value: "modbus_rtu", label: "Modbus RTU" },
{ value: "snmp", label: "SNMP" },
{ value: "bacnet", label: "BACnet" },
{ value: "http", label: "HTTP Poll" },
]
// ── Props ─────────────────────────────────────────────────────────────────────
interface Props {
siteId: string
sensor: SensorDevice | null // null = add mode
open: boolean
onClose: () => void
onSaved: (s: SensorDevice) => void
}
// ── Shared input / select class ───────────────────────────────────────────────
const inputCls =
"border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground w-full focus:outline-none focus:ring-1 focus:ring-primary"
// ── Protocol-specific config state types ──────────────────────────────────────
type MqttConfig = { topic: string }
type ModbusTcpConfig = { host: string; port: number; unit_id: number }
type ModbusRtuConfig = { serial_port: string; baud_rate: number; unit_id: number }
type SnmpConfig = { host: string; community: string; version: "v1" | "v2c" | "v3" }
type BacnetConfig = { host: string; device_id: number }
type HttpConfig = { url: string; poll_interval_s: number; json_path: string }
type ProtocolConfig =
| MqttConfig
| ModbusTcpConfig
| ModbusRtuConfig
| SnmpConfig
| BacnetConfig
| HttpConfig
function defaultProtocolConfig(protocol: Protocol): ProtocolConfig {
switch (protocol) {
case "mqtt": return { topic: "" }
case "modbus_tcp": return { host: "", port: 502, unit_id: 1 }
case "modbus_rtu": return { serial_port: "", baud_rate: 9600, unit_id: 1 }
case "snmp": return { host: "", community: "public", version: "v2c" }
case "bacnet": return { host: "", device_id: 0 }
case "http": return { url: "", poll_interval_s: 30, json_path: "$.value" }
}
}
function configFromSensor(sensor: SensorDevice): ProtocolConfig {
const raw = sensor.protocol_config ?? {}
switch (sensor.protocol) {
case "mqtt":
return { topic: (raw.topic as string) ?? "" }
case "modbus_tcp":
return {
host: (raw.host as string) ?? "",
port: (raw.port as number) ?? 502,
unit_id: (raw.unit_id as number) ?? 1,
}
case "modbus_rtu":
return {
serial_port: (raw.serial_port as string) ?? "",
baud_rate: (raw.baud_rate as number) ?? 9600,
unit_id: (raw.unit_id as number) ?? 1,
}
case "snmp":
return {
host: (raw.host as string) ?? "",
community: (raw.community as string) ?? "public",
version: (raw.version as "v1" | "v2c" | "v3") ?? "v2c",
}
case "bacnet":
return {
host: (raw.host as string) ?? "",
device_id: (raw.device_id as number) ?? 0,
}
case "http":
return {
url: (raw.url as string) ?? "",
poll_interval_s: (raw.poll_interval_s as number) ?? 30,
json_path: (raw.json_path as string) ?? "$.value",
}
}
}
// ── Protocol-specific field editors ──────────────────────────────────────────
interface ConfigEditorProps<T> {
config: T
onChange: (next: T) => void
}
function MqttEditor({ config, onChange }: ConfigEditorProps<MqttConfig>) {
return (
<Field label="MQTT Topic">
<input
className={inputCls}
value={config.topic}
placeholder="sensors/site/device/metric"
onChange={e => onChange({ ...config, topic: e.target.value })}
/>
</Field>
)
}
function ModbusTcpEditor({ config, onChange }: ConfigEditorProps<ModbusTcpConfig>) {
return (
<>
<Field label="Host">
<input
className={inputCls}
value={config.host}
placeholder="192.168.1.10"
onChange={e => onChange({ ...config, host: e.target.value })}
/>
</Field>
<Field label="Port">
<input
type="number"
className={inputCls}
value={config.port}
onChange={e => onChange({ ...config, port: Number(e.target.value) })}
/>
</Field>
<Field label="Unit ID">
<input
type="number"
className={inputCls}
value={config.unit_id}
onChange={e => onChange({ ...config, unit_id: Number(e.target.value) })}
/>
</Field>
</>
)
}
function ModbusRtuEditor({ config, onChange }: ConfigEditorProps<ModbusRtuConfig>) {
return (
<>
<Field label="Serial Port">
<input
className={inputCls}
value={config.serial_port}
placeholder="/dev/ttyUSB0"
onChange={e => onChange({ ...config, serial_port: e.target.value })}
/>
</Field>
<Field label="Baud Rate">
<input
type="number"
className={inputCls}
value={config.baud_rate}
onChange={e => onChange({ ...config, baud_rate: Number(e.target.value) })}
/>
</Field>
<Field label="Unit ID">
<input
type="number"
className={inputCls}
value={config.unit_id}
onChange={e => onChange({ ...config, unit_id: Number(e.target.value) })}
/>
</Field>
</>
)
}
function SnmpEditor({ config, onChange }: ConfigEditorProps<SnmpConfig>) {
return (
<>
<Field label="Host">
<input
className={inputCls}
value={config.host}
placeholder="192.168.1.20"
onChange={e => onChange({ ...config, host: e.target.value })}
/>
</Field>
<Field label="Community">
<input
className={inputCls}
value={config.community}
placeholder="public"
onChange={e => onChange({ ...config, community: e.target.value })}
/>
</Field>
<Field label="Version">
<select
className={inputCls}
value={config.version}
onChange={e => onChange({ ...config, version: e.target.value as "v1" | "v2c" | "v3" })}
>
<option value="v1">v1</option>
<option value="v2c">v2c</option>
<option value="v3">v3</option>
</select>
</Field>
</>
)
}
function BacnetEditor({ config, onChange }: ConfigEditorProps<BacnetConfig>) {
return (
<>
<Field label="Host">
<input
className={inputCls}
value={config.host}
placeholder="192.168.1.30"
onChange={e => onChange({ ...config, host: e.target.value })}
/>
</Field>
<Field label="Device ID">
<input
type="number"
className={inputCls}
value={config.device_id}
onChange={e => onChange({ ...config, device_id: Number(e.target.value) })}
/>
</Field>
</>
)
}
function HttpEditor({ config, onChange }: ConfigEditorProps<HttpConfig>) {
return (
<>
<Field label="URL">
<input
className={inputCls}
value={config.url}
placeholder="http://device/api/status"
onChange={e => onChange({ ...config, url: e.target.value })}
/>
</Field>
<Field label="Poll Interval (seconds)">
<input
type="number"
className={inputCls}
value={config.poll_interval_s}
onChange={e => onChange({ ...config, poll_interval_s: Number(e.target.value) })}
/>
</Field>
<Field label="JSON Path">
<input
className={inputCls}
value={config.json_path}
placeholder="$.value"
onChange={e => onChange({ ...config, json_path: e.target.value })}
/>
</Field>
</>
)
}
// ── Reusable field wrapper ────────────────────────────────────────────────────
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">{label}</span>
{children}
</div>
)
}
// ── Toggle switch ─────────────────────────────────────────────────────────────
function Toggle({
checked,
onChange,
}: {
checked: boolean
onChange: (v: boolean) => void
}) {
return (
<button
type="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 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2",
checked ? "bg-primary" : "bg-muted"
)}
>
<span
className={cn(
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0"
)}
/>
</button>
)
}
// ── Main component ────────────────────────────────────────────────────────────
export function SensorSheet({ siteId, sensor, open, onClose, onSaved }: Props) {
const isEdit = sensor !== null
// ── Form state ────────────────────────────────────────────────────────────
const [deviceId, setDeviceId] = useState("")
const [name, setName] = useState("")
const [deviceType, setDeviceType] = useState<DeviceType>("ups")
const [room, setRoom] = useState("")
const [enabled, setEnabled] = useState(true)
const [protocol, setProtocol] = useState<Protocol>("mqtt")
const [protoConfig, setProtoConfig] = useState<ProtocolConfig>(defaultProtocolConfig("mqtt"))
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
// ── Populate form when sheet opens / sensor changes ───────────────────────
useEffect(() => {
if (!open) return
setError(null)
setSaving(false)
if (sensor) {
setDeviceId(sensor.device_id)
setName(sensor.name)
setDeviceType(sensor.device_type)
setRoom(sensor.room_id ?? "")
setEnabled(sensor.enabled)
setProtocol(sensor.protocol)
setProtoConfig(configFromSensor(sensor))
} else {
setDeviceId("")
setName("")
setDeviceType("ups")
setRoom("")
setEnabled(true)
setProtocol("mqtt")
setProtoConfig(defaultProtocolConfig("mqtt"))
}
}, [open, sensor])
// ── Protocol change — reset config to defaults ────────────────────────────
function handleProtocolChange(p: Protocol) {
setProtocol(p)
setProtoConfig(defaultProtocolConfig(p))
}
// ── Submit ────────────────────────────────────────────────────────────────
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setSaving(true)
try {
const body: SensorCreate = {
device_id: deviceId.trim(),
name: name.trim(),
device_type: deviceType,
room_id: room.trim() || null,
protocol,
protocol_config: protoConfig as Record<string, unknown>,
enabled,
}
const saved = isEdit
? await updateSensor(sensor!.id, body)
: await createSensor(siteId, body)
onSaved(saved)
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed")
} finally {
setSaving(false)
}
}
// ── Protocol config editor ────────────────────────────────────────────────
function renderProtoFields() {
switch (protocol) {
case "mqtt":
return (
<MqttEditor
config={protoConfig as MqttConfig}
onChange={c => setProtoConfig(c)}
/>
)
case "modbus_tcp":
return (
<ModbusTcpEditor
config={protoConfig as ModbusTcpConfig}
onChange={c => setProtoConfig(c)}
/>
)
case "modbus_rtu":
return (
<ModbusRtuEditor
config={protoConfig as ModbusRtuConfig}
onChange={c => setProtoConfig(c)}
/>
)
case "snmp":
return (
<SnmpEditor
config={protoConfig as SnmpConfig}
onChange={c => setProtoConfig(c)}
/>
)
case "bacnet":
return (
<BacnetEditor
config={protoConfig as BacnetConfig}
onChange={c => setProtoConfig(c)}
/>
)
case "http":
return (
<HttpEditor
config={protoConfig as HttpConfig}
onChange={c => setProtoConfig(c)}
/>
)
}
}
// ── Render ────────────────────────────────────────────────────────────────
return (
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col overflow-y-auto">
<SheetHeader className="px-6 pt-6 pb-2">
<SheetTitle>{isEdit ? "Edit Sensor" : "Add Sensor"}</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 px-6 pb-6 gap-5">
{/* ── Device ID ── */}
<Field label="Device ID *">
<input
className={cn(inputCls, isEdit && "opacity-60 cursor-not-allowed")}
value={deviceId}
required
disabled={isEdit}
placeholder="ups-01"
onChange={e => setDeviceId(e.target.value)}
/>
</Field>
{/* ── Name ── */}
<Field label="Name *">
<input
className={inputCls}
value={name}
required
placeholder="Main UPS — Hall A"
onChange={e => setName(e.target.value)}
/>
</Field>
{/* ── Device Type ── */}
<Field label="Device Type">
<select
className={inputCls}
value={deviceType}
onChange={e => setDeviceType(e.target.value as DeviceType)}
>
{DEVICE_TYPE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</Field>
{/* ── Room ── */}
<Field label="Room (optional)">
<input
className={inputCls}
value={room}
placeholder="hall-a"
onChange={e => setRoom(e.target.value)}
/>
</Field>
{/* ── Enabled toggle ── */}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Enabled</span>
<Toggle checked={enabled} onChange={setEnabled} />
</div>
{/* ── Divider ── */}
<hr className="border-border" />
{/* ── Protocol ── */}
<Field label="Protocol">
<select
className={inputCls}
value={protocol}
onChange={e => handleProtocolChange(e.target.value as Protocol)}
>
{PROTOCOL_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</Field>
{/* ── Protocol-specific fields ── */}
{renderProtoFields()}
{/* ── Non-MQTT collector notice ── */}
{protocol !== "mqtt" && (
<div className="flex gap-2 rounded-md border border-border bg-muted/40 p-3 text-xs text-muted-foreground">
<Info className="mt-0.5 size-3.5 shrink-0 text-primary" />
<span>
Protocol config stored active polling not yet implemented.
Data will appear once the collector is enabled.
</span>
</div>
)}
{/* ── Error ── */}
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
{/* ── Actions ── */}
<div className="mt-auto flex gap-2 pt-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={onClose}
disabled={saving}
>
Cancel
</Button>
<Button type="submit" className="flex-1" disabled={saving}>
{saving && <Loader2 className="animate-spin" />}
{saving ? "Saving…" : isEdit ? "Save Changes" : "Add Sensor"}
</Button>
</div>
</form>
</SheetContent>
</Sheet>
)
}

View file

@ -0,0 +1,243 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Plus, Pencil, Trash2, Search, Eye, ToggleLeft, ToggleRight, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { fetchSensors, updateSensor, deleteSensor, type SensorDevice, type DeviceType, type Protocol } from "@/lib/api";
const SITE_ID = "sg-01";
export const DEVICE_TYPE_LABELS: Record<string, string> = {
ups: "UPS",
generator: "Generator",
crac: "CRAC Unit",
chiller: "Chiller",
ats: "Transfer Switch",
rack: "Rack PDU",
network_switch: "Network Switch",
leak: "Leak Sensor",
fire_zone: "Fire / VESDA",
custom: "Custom",
};
export const PROTOCOL_LABELS: Record<string, string> = {
mqtt: "MQTT",
modbus_tcp: "Modbus TCP",
modbus_rtu: "Modbus RTU",
snmp: "SNMP",
bacnet: "BACnet",
http: "HTTP Poll",
};
const TYPE_COLORS: Record<string, string> = {
ups: "bg-blue-500/10 text-blue-400",
generator: "bg-amber-500/10 text-amber-400",
crac: "bg-cyan-500/10 text-cyan-400",
chiller: "bg-sky-500/10 text-sky-400",
ats: "bg-purple-500/10 text-purple-400",
rack: "bg-green-500/10 text-green-400",
network_switch: "bg-indigo-500/10 text-indigo-400",
leak: "bg-teal-500/10 text-teal-400",
fire_zone: "bg-red-500/10 text-red-400",
custom: "bg-muted text-muted-foreground",
};
const PROTOCOL_COLORS: Record<string, string> = {
mqtt: "bg-green-500/10 text-green-400",
modbus_tcp: "bg-orange-500/10 text-orange-400",
modbus_rtu: "bg-orange-500/10 text-orange-400",
snmp: "bg-violet-500/10 text-violet-400",
bacnet: "bg-pink-500/10 text-pink-400",
http: "bg-yellow-500/10 text-yellow-400",
};
interface Props {
onAdd: () => void;
onEdit: (s: SensorDevice) => void;
onDetail: (id: number) => void;
}
export function SensorTable({ onAdd, onEdit, onDetail }: Props) {
const [sensors, setSensors] = useState<SensorDevice[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [confirmDel, setConfirmDel] = useState<number | null>(null);
const [toggling, setToggling] = useState<number | null>(null);
const load = useCallback(async () => {
try {
const data = await fetchSensors(SITE_ID);
setSensors(data);
} catch { /* keep stale */ }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const handleToggle = async (s: SensorDevice) => {
setToggling(s.id);
try {
const updated = await updateSensor(s.id, { enabled: !s.enabled });
setSensors(prev => prev.map(x => x.id === s.id ? updated : x));
} catch { /* ignore */ }
finally { setToggling(null); }
};
const handleDelete = async (id: number) => {
try {
await deleteSensor(id);
setSensors(prev => prev.filter(x => x.id !== id));
} catch { /* ignore */ }
finally { setConfirmDel(null); }
};
const filtered = sensors.filter(s => {
const matchType = typeFilter === "all" || s.device_type === typeFilter;
const q = search.toLowerCase();
const matchSearch = !q || s.device_id.toLowerCase().includes(q) || s.name.toLowerCase().includes(q) || (s.room_id ?? "").toLowerCase().includes(q);
return matchType && matchSearch;
});
const typeOptions = [...new Set(sensors.map(s => s.device_type))].sort();
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 min-w-48">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search by name or ID..."
className="w-full pl-8 pr-3 py-1.5 text-sm bg-muted/30 border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<select
value={typeFilter}
onChange={e => setTypeFilter(e.target.value)}
className="text-sm bg-muted/30 border border-border rounded-md px-2 py-1.5 text-foreground focus:outline-none"
>
<option value="all">All types</option>
{typeOptions.map(t => (
<option key={t} value={t}>{DEVICE_TYPE_LABELS[t] ?? t}</option>
))}
</select>
<Button size="sm" variant="ghost" onClick={load} className="gap-1.5">
<RefreshCw className="w-3.5 h-3.5" /> Refresh
</Button>
<Button size="sm" onClick={onAdd} className="gap-1.5">
<Plus className="w-3.5 h-3.5" /> Add Sensor
</Button>
</div>
<div className="text-xs text-muted-foreground">
{filtered.length} of {sensors.length} devices
</div>
{/* Table */}
{loading ? (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-10 rounded bg-muted/30 animate-pulse" />
))}
</div>
) : filtered.length === 0 ? (
<div className="text-center py-12 text-sm text-muted-foreground">
No sensors found{search ? ` matching "${search}"` : ""}
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-border/40">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/30 bg-muted/10">
<th className="text-left px-3 py-2 text-xs font-medium text-muted-foreground">Device ID</th>
<th className="text-left px-3 py-2 text-xs font-medium text-muted-foreground">Name</th>
<th className="text-left px-3 py-2 text-xs font-medium text-muted-foreground">Type</th>
<th className="text-left px-3 py-2 text-xs font-medium text-muted-foreground">Room</th>
<th className="text-left px-3 py-2 text-xs font-medium text-muted-foreground">Protocol</th>
<th className="text-center px-3 py-2 text-xs font-medium text-muted-foreground">Enabled</th>
<th className="text-right px-3 py-2 text-xs font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{filtered.map(s => (
<tr key={s.id} className="border-b border-border/10 hover:bg-muted/10 transition-colors">
<td className="px-3 py-2 font-mono text-xs text-muted-foreground">{s.device_id}</td>
<td className="px-3 py-2 font-medium">{s.name}</td>
<td className="px-3 py-2">
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", TYPE_COLORS[s.device_type] ?? "bg-muted text-muted-foreground")}>
{DEVICE_TYPE_LABELS[s.device_type] ?? s.device_type}
</span>
</td>
<td className="px-3 py-2 text-xs text-muted-foreground">{s.room_id ?? "—"}</td>
<td className="px-3 py-2">
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", PROTOCOL_COLORS[s.protocol] ?? "bg-muted text-muted-foreground")}>
{PROTOCOL_LABELS[s.protocol] ?? s.protocol}
</span>
</td>
<td className="px-3 py-2 text-center">
<button
onClick={() => handleToggle(s)}
disabled={toggling === s.id}
className={cn("transition-opacity", toggling === s.id && "opacity-50")}
>
{s.enabled
? <ToggleRight className="w-5 h-5 text-green-400" />
: <ToggleLeft className="w-5 h-5 text-muted-foreground" />
}
</button>
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => onDetail(s.id)}
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
title="View details"
>
<Eye className="w-3.5 h-3.5" />
</button>
<button
onClick={() => onEdit(s)}
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
title="Edit"
>
<Pencil className="w-3.5 h-3.5" />
</button>
{confirmDel === s.id ? (
<div className="flex items-center gap-1 ml-1">
<button
onClick={() => handleDelete(s.id)}
className="text-[10px] px-1.5 py-0.5 rounded bg-destructive/10 text-destructive hover:bg-destructive/20 font-medium"
>
Confirm
</button>
<button
onClick={() => setConfirmDel(null)}
className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground hover:bg-muted/80"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmDel(s.id)}
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-destructive"
title="Delete"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,719 @@
"use client"
import React, { useCallback, useEffect, useRef, useState } from "react"
import {
AlertTriangle,
ChevronDown,
ChevronRight,
Plus,
RotateCcw,
Trash2,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
AlarmThreshold,
ThresholdUpdate,
ThresholdCreate,
fetchThresholds,
updateThreshold,
createThreshold,
deleteThreshold,
resetThresholds,
} from "@/lib/api"
// ── Labels ────────────────────────────────────────────────────────────────────
const SENSOR_TYPE_LABELS: Record<string, string> = {
temperature: "Temperature (°C)",
humidity: "Humidity (%)",
power_kw: "Rack Power (kW)",
pdu_imbalance: "Phase Imbalance (%)",
ups_charge: "UPS Battery Charge (%)",
ups_load: "UPS Load (%)",
ups_runtime: "UPS Runtime (min)",
gen_fuel_pct: "Generator Fuel (%)",
gen_load_pct: "Generator Load (%)",
gen_coolant_c: "Generator Coolant (°C)",
gen_oil_press: "Generator Oil Pressure (bar)",
cooling_cap_pct: "CRAC Capacity (%)",
cooling_cop: "CRAC COP",
cooling_comp_load: "Compressor Load (%)",
cooling_high_press: "High-Side Pressure (bar)",
cooling_low_press: "Low-Side Pressure (bar)",
cooling_superheat: "Discharge Superheat (°C)",
cooling_filter_dp: "Filter Delta-P (Pa)",
cooling_return: "Return Air Temp (°C)",
net_pkt_loss_pct: "Packet Loss (%)",
net_temp_c: "Switch Temperature (°C)",
ats_ua_v: "Utility A Voltage (V)",
chiller_cop: "Chiller COP",
}
// ── Groups ────────────────────────────────────────────────────────────────────
type GroupIcon = "Thermometer" | "Zap" | "Battery" | "Fuel" | "Wind" | "Network"
interface Group {
label: string
icon: GroupIcon
types: string[]
}
const GROUPS: Group[] = [
{ label: "Temperature & Humidity", icon: "Thermometer", types: ["temperature", "humidity"] },
{ label: "Rack Power", icon: "Zap", types: ["power_kw", "pdu_imbalance"] },
{ label: "UPS", icon: "Battery", types: ["ups_charge", "ups_load", "ups_runtime"] },
{ label: "Generator", icon: "Fuel", types: ["gen_fuel_pct", "gen_load_pct", "gen_coolant_c", "gen_oil_press"] },
{ label: "Cooling / CRAC", icon: "Wind", types: ["cooling_cap_pct", "cooling_cop", "cooling_comp_load", "cooling_high_press", "cooling_low_press", "cooling_superheat", "cooling_filter_dp", "cooling_return"] },
{ label: "Network", icon: "Network", types: ["net_pkt_loss_pct", "net_temp_c", "ats_ua_v", "chiller_cop"] },
]
// ── Group icon renderer ───────────────────────────────────────────────────────
function GroupIconEl({ icon }: { icon: GroupIcon }) {
const cls = "size-4 shrink-0"
switch (icon) {
case "Thermometer":
return (
<svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z" />
</svg>
)
case "Zap":
return (
<svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
)
case "Battery":
return (
<svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<rect x="1" y="6" width="18" height="12" rx="2" ry="2" />
<line x1="23" y1="13" x2="23" y2="11" />
</svg>
)
case "Fuel":
return (
<svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M3 22V8l6-6h6l6 6v14H3z" />
<rect x="8" y="13" width="8" height="5" />
<path d="M8 5h8" />
</svg>
)
case "Wind":
return (
<svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M9.59 4.59A2 2 0 1 1 11 8H2m10.59 11.41A2 2 0 1 0 14 16H2m15.73-8.27A2.5 2.5 0 1 1 19.5 12H2" />
</svg>
)
case "Network":
return (
<svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<rect x="9" y="2" width="6" height="4" rx="1" />
<rect x="1" y="18" width="6" height="4" rx="1" />
<rect x="17" y="18" width="6" height="4" rx="1" />
<path d="M12 6v4M4 18v-4h16v4M12 10h8v4M12 10H4v4" />
</svg>
)
}
}
// ── Status toast ──────────────────────────────────────────────────────────────
interface Toast {
id: number
message: string
type: "success" | "error"
}
// ── Save indicator per row ────────────────────────────────────────────────────
type SaveState = "idle" | "saving" | "saved" | "error"
// ── Row component ─────────────────────────────────────────────────────────────
interface RowProps {
threshold: AlarmThreshold
onUpdate: (id: number, patch: ThresholdUpdate) => Promise<void>
onDelete: (id: number) => void
}
function ThresholdRow({ threshold, onUpdate, onDelete }: RowProps) {
const [localValue, setLocalValue] = useState(String(threshold.threshold_value))
const [saveState, setSaveState] = useState<SaveState>("idle")
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Keep local value in sync if parent updates (e.g. after reset)
useEffect(() => {
setLocalValue(String(threshold.threshold_value))
}, [threshold.threshold_value])
const signalSave = async (patch: ThresholdUpdate) => {
setSaveState("saving")
try {
await onUpdate(threshold.id, patch)
setSaveState("saved")
} catch {
setSaveState("error")
} finally {
if (saveTimer.current) clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => setSaveState("idle"), 1800)
}
}
const handleValueBlur = () => {
const parsed = parseFloat(localValue)
if (isNaN(parsed)) {
setLocalValue(String(threshold.threshold_value))
return
}
if (parsed !== threshold.threshold_value) {
signalSave({ threshold_value: parsed })
}
}
const handleSeverityToggle = () => {
const next = threshold.severity === "warning" ? "critical" : "warning"
signalSave({ severity: next })
}
const handleEnabledToggle = () => {
signalSave({ enabled: !threshold.enabled })
}
const directionBadge =
threshold.direction === "above" ? (
<span className="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs font-semibold bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300">
Above
</span>
) : (
<span className="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs font-semibold bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
Below
</span>
)
const severityBadge =
threshold.severity === "critical" ? (
<button
onClick={handleSeverityToggle}
title="Click to toggle severity"
className="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs font-semibold bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 hover:opacity-80 transition-opacity cursor-pointer"
>
Critical
</button>
) : (
<button
onClick={handleSeverityToggle}
title="Click to toggle severity"
className="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs font-semibold bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300 hover:opacity-80 transition-opacity cursor-pointer"
>
Warning
</button>
)
const saveIndicator =
saveState === "saving" ? (
<span className="text-xs text-muted-foreground animate-pulse">Saving</span>
) : saveState === "saved" ? (
<span className="text-xs text-emerald-600 dark:text-emerald-400">Saved</span>
) : saveState === "error" ? (
<span className="text-xs text-red-500">Error</span>
) : null
return (
<tr
className={cn(
"border-b border-border/50 last:border-0 transition-colors",
!threshold.enabled && "opacity-50"
)}
>
{/* Sensor type */}
<td className="py-2 pl-4 pr-3 text-sm font-medium text-foreground whitespace-nowrap">
{SENSOR_TYPE_LABELS[threshold.sensor_type] ?? threshold.sensor_type}
</td>
{/* Direction */}
<td className="py-2 px-3 text-sm">
{directionBadge}
</td>
{/* Severity */}
<td className="py-2 px-3 text-sm">
{severityBadge}
</td>
{/* Value */}
<td className="py-2 px-3">
<div className="flex items-center gap-2">
<input
type="number"
step="any"
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
onBlur={handleValueBlur}
disabled={threshold.locked}
className={cn(
"w-24 rounded-md border border-input bg-background px-2 py-1 text-sm text-right tabular-nums",
"focus:outline-none focus:ring-2 focus:ring-ring/50",
"disabled:cursor-not-allowed disabled:opacity-50"
)}
/>
<span className="w-12 text-xs">{saveIndicator}</span>
</div>
</td>
{/* Enabled toggle */}
<td className="py-2 px-3 text-center">
<button
onClick={handleEnabledToggle}
role="switch"
aria-checked={threshold.enabled}
title={threshold.enabled ? "Disable threshold" : "Enable threshold"}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
"focus:outline-none focus:ring-2 focus:ring-ring/50",
threshold.enabled
? "bg-primary"
: "bg-muted"
)}
>
<span
className={cn(
"pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition-transform",
threshold.enabled ? "translate-x-4" : "translate-x-0"
)}
/>
</button>
</td>
{/* Delete */}
<td className="py-2 pl-3 pr-4 text-right">
{!threshold.locked && (
<button
onClick={() => onDelete(threshold.id)}
title="Delete threshold"
className="text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="size-4" />
</button>
)}
</td>
</tr>
)
}
// ── Group section ─────────────────────────────────────────────────────────────
interface GroupSectionProps {
group: Group
thresholds: AlarmThreshold[]
onUpdate: (id: number, patch: ThresholdUpdate) => Promise<void>
onDelete: (id: number) => void
}
function GroupSection({ group, thresholds, onUpdate, onDelete }: GroupSectionProps) {
const [expanded, setExpanded] = useState(true)
const rows = thresholds.filter((t) => group.types.includes(t.sensor_type))
return (
<div className="rounded-lg border border-border bg-card overflow-hidden">
{/* Header */}
<button
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center justify-between px-4 py-3 bg-muted/40 hover:bg-muted/60 transition-colors text-left"
>
<div className="flex items-center gap-2 font-semibold text-sm text-foreground">
<GroupIconEl icon={group.icon} />
{group.label}
<span className="ml-1 text-xs font-normal text-muted-foreground">
({rows.length} rule{rows.length !== 1 ? "s" : ""})
</span>
</div>
{expanded ? (
<ChevronDown className="size-4 text-muted-foreground" />
) : (
<ChevronRight className="size-4 text-muted-foreground" />
)}
</button>
{/* Table */}
{expanded && (
<div className="overflow-x-auto">
{rows.length === 0 ? (
<p className="px-4 py-4 text-sm text-muted-foreground italic">
No rules configured for this group.
</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50 text-xs text-muted-foreground">
<th className="py-2 pl-4 pr-3 text-left font-medium">Sensor Type</th>
<th className="py-2 px-3 text-left font-medium">Direction</th>
<th className="py-2 px-3 text-left font-medium">Severity</th>
<th className="py-2 px-3 text-left font-medium">Value</th>
<th className="py-2 px-3 text-center font-medium">Enabled</th>
<th className="py-2 pl-3 pr-4 text-right font-medium">Delete</th>
</tr>
</thead>
<tbody>
{rows.map((t) => (
<ThresholdRow
key={t.id}
threshold={t}
onUpdate={onUpdate}
onDelete={onDelete}
/>
))}
</tbody>
</table>
)}
</div>
)}
</div>
)
}
// ── Add custom rule form ──────────────────────────────────────────────────────
interface AddRuleFormProps {
siteId: string
onCreated: (t: AlarmThreshold) => void
onError: (msg: string) => void
}
const EMPTY_FORM: ThresholdCreate = {
sensor_type: "",
threshold_value: 0,
direction: "above",
severity: "warning",
message_template: "",
}
function AddRuleForm({ siteId, onCreated, onError }: AddRuleFormProps) {
const [form, setForm] = useState<ThresholdCreate>(EMPTY_FORM)
const [busy, setBusy] = useState(false)
const set = <K extends keyof ThresholdCreate>(k: K, v: ThresholdCreate[K]) =>
setForm((f) => ({ ...f, [k]: v }))
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.sensor_type.trim()) return
setBusy(true)
try {
const created = await createThreshold(siteId, form)
onCreated(created)
setForm(EMPTY_FORM)
} catch {
onError("Failed to create threshold rule.")
} finally {
setBusy(false)
}
}
return (
<div className="rounded-lg border border-dashed border-border bg-card p-4">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground mb-3">
<Plus className="size-4" />
Add Custom Rule
</h3>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{/* Sensor type */}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">Sensor Type</label>
<input
type="text"
placeholder="e.g. temperature"
value={form.sensor_type}
onChange={(e) => set("sensor_type", e.target.value)}
className="rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring/50"
required
/>
</div>
{/* Direction */}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">Direction</label>
<select
value={form.direction}
onChange={(e) => set("direction", e.target.value)}
className="rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring/50"
>
<option value="above">Above</option>
<option value="below">Below</option>
</select>
</div>
{/* Threshold value */}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">Threshold Value</label>
<input
type="number"
step="any"
value={form.threshold_value}
onChange={(e) => set("threshold_value", parseFloat(e.target.value) || 0)}
className="rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring/50"
required
/>
</div>
{/* Severity */}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">Severity</label>
<select
value={form.severity}
onChange={(e) => set("severity", e.target.value)}
className="rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring/50"
>
<option value="warning">Warning</option>
<option value="critical">Critical</option>
</select>
</div>
{/* Message template */}
<div className="flex flex-col gap-1 sm:col-span-2 lg:col-span-2">
<label className="text-xs font-medium text-muted-foreground">
Message Template{" "}
<span className="font-normal opacity-70">
use <code className="text-xs">{"{sensor_id}"}</code> and <code className="text-xs">{"{value:.1f}"}</code>
</span>
</label>
<input
type="text"
placeholder="{sensor_id} value {value:.1f} exceeded threshold"
value={form.message_template}
onChange={(e) => set("message_template", e.target.value)}
className="rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring/50"
/>
</div>
</div>
<div className="mt-3 flex justify-end">
<Button type="submit" size="sm" disabled={busy || !form.sensor_type.trim()}>
<Plus className="size-4" />
{busy ? "Adding…" : "Add Rule"}
</Button>
</div>
</form>
</div>
)
}
// ── Main component ────────────────────────────────────────────────────────────
interface Props {
siteId: string
}
export function ThresholdEditor({ siteId }: Props) {
const [thresholds, setThresholds] = useState<AlarmThreshold[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [confirmReset, setConfirmReset] = useState(false)
const [resetting, setResetting] = useState(false)
const [toasts, setToasts] = useState<Toast[]>([])
const toastId = useRef(0)
// ── Toast helpers ───────────────────────────────────────────────────────────
const pushToast = useCallback((message: string, type: Toast["type"]) => {
const id = ++toastId.current
setToasts((prev) => [...prev, { id, message, type }])
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000)
}, [])
// ── Data loading ────────────────────────────────────────────────────────────
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await fetchThresholds(siteId)
setThresholds(data)
} catch {
setError("Failed to load alarm thresholds.")
} finally {
setLoading(false)
}
}, [siteId])
useEffect(() => { load() }, [load])
// ── Update handler ──────────────────────────────────────────────────────────
const handleUpdate = useCallback(async (id: number, patch: ThresholdUpdate) => {
// Optimistic update
setThresholds((prev) =>
prev.map((t) =>
t.id === id
? {
...t,
...(patch.threshold_value !== undefined && { threshold_value: patch.threshold_value }),
...(patch.severity !== undefined && { severity: patch.severity as AlarmThreshold["severity"] }),
...(patch.enabled !== undefined && { enabled: patch.enabled }),
}
: t
)
)
await updateThreshold(id, patch)
}, [])
// ── Delete handler ──────────────────────────────────────────────────────────
const handleDelete = useCallback(async (id: number) => {
setThresholds((prev) => prev.filter((t) => t.id !== id))
try {
await deleteThreshold(id)
pushToast("Threshold deleted.", "success")
} catch {
pushToast("Failed to delete threshold.", "error")
// Reload to restore
load()
}
}, [load, pushToast])
// ── Create handler ──────────────────────────────────────────────────────────
const handleCreated = useCallback((t: AlarmThreshold) => {
setThresholds((prev) => [...prev, t])
pushToast("Rule added successfully.", "success")
}, [pushToast])
// ── Reset handler ───────────────────────────────────────────────────────────
const handleReset = async () => {
setResetting(true)
try {
await resetThresholds(siteId)
setConfirmReset(false)
pushToast("Thresholds reset to defaults.", "success")
await load()
} catch {
pushToast("Failed to reset thresholds.", "error")
} finally {
setResetting(false)
}
}
// ── Render ──────────────────────────────────────────────────────────────────
return (
<div className="relative space-y-4">
{/* Toast container */}
{toasts.length > 0 && (
<div className="fixed bottom-6 right-6 z-50 flex flex-col gap-2 pointer-events-none">
{toasts.map((toast) => (
<div
key={toast.id}
className={cn(
"rounded-lg px-4 py-2.5 text-sm font-medium shadow-lg animate-in fade-in slide-in-from-bottom-2",
toast.type === "success"
? "bg-emerald-600 text-white"
: "bg-destructive text-white"
)}
>
{toast.message}
</div>
))}
</div>
)}
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
<AlertTriangle className="size-5 text-amber-500" />
Alarm Thresholds
</h2>
<p className="text-sm text-muted-foreground mt-0.5">
Configure threshold values that trigger alarms. Click a severity badge to toggle it; blur the value field to save.
</p>
</div>
{/* Reset controls */}
<div className="flex items-center gap-2 shrink-0">
{confirmReset ? (
<>
<span className="text-sm text-muted-foreground">Are you sure?</span>
<Button
size="sm"
variant="destructive"
onClick={handleReset}
disabled={resetting}
>
<RotateCcw className="size-4" />
{resetting ? "Resetting…" : "Confirm Reset"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setConfirmReset(false)}
disabled={resetting}
>
Cancel
</Button>
</>
) : (
<Button
size="sm"
variant="outline"
onClick={() => setConfirmReset(true)}
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<RotateCcw className="size-4" />
Reset to Defaults
</Button>
)}
</div>
</div>
{/* Loading / Error */}
{loading && (
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground">
<svg className="animate-spin size-5 mr-2 text-muted-foreground" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
Loading thresholds
</div>
)}
{!loading && error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive flex items-center gap-2">
<AlertTriangle className="size-4 shrink-0" />
{error}
<Button size="xs" variant="outline" onClick={load} className="ml-auto">
Retry
</Button>
</div>
)}
{/* Groups */}
{!loading && !error && (
<div className="space-y-3">
{GROUPS.map((group) => (
<GroupSection
key={group.label}
group={group}
thresholds={thresholds}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
))}
</div>
)}
{/* Add custom rule */}
{!loading && !error && (
<AddRuleForm
siteId={siteId}
onCreated={handleCreated}
onError={(msg) => pushToast(msg, "error")}
/>
)}
</div>
)
}

View file

@ -0,0 +1,281 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { FlaskConical, Play, RotateCcw, ChevronDown, Clock, Zap } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { fetchScenarios, triggerScenario, type ScenarioInfo } from "@/lib/api";
// ── Small select for target override ─────────────────────────────────────────
function TargetSelect({
targets,
value,
onChange,
}: {
targets: string[];
value: string;
onChange: (v: string) => void;
}) {
if (targets.length <= 1) return null;
return (
<div className="relative inline-flex items-center">
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="appearance-none text-xs bg-muted border border-border rounded px-2 py-1 pr-6 text-foreground cursor-pointer focus:outline-none focus:ring-1 focus:ring-ring"
>
{targets.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-1.5 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground" />
</div>
);
}
// ── Single scenario card ──────────────────────────────────────────────────────
function ScenarioCard({
scenario,
active,
onRun,
}: {
scenario: ScenarioInfo;
active: boolean;
onRun: (name: string, target?: string) => void;
}) {
const [target, setTarget] = useState(scenario.default_target ?? "");
return (
<div
className={`rounded-lg border px-4 py-3 flex flex-col gap-2 transition-colors ${
active
? "border-amber-500/60 bg-amber-500/5"
: "border-border bg-card"
}`}
>
{/* Header row */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-foreground leading-tight">
{scenario.label}
</span>
{scenario.compound && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
compound
</Badge>
)}
{active && (
<Badge className="text-[10px] px-1.5 py-0 bg-amber-500 text-white animate-pulse">
running
</Badge>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{!scenario.compound && (
<TargetSelect
targets={scenario.targets}
value={target}
onChange={setTarget}
/>
)}
<Button
size="sm"
variant={active ? "secondary" : "default"}
className="h-7 px-2.5 text-xs gap-1"
onClick={() => onRun(scenario.name, scenario.compound ? undefined : target || undefined)}
>
<Play className="w-3 h-3" />
Run
</Button>
</div>
</div>
{/* Description */}
<p className="text-xs text-muted-foreground leading-relaxed">
{scenario.description}
</p>
{/* Footer */}
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Clock className="w-3 h-3" />
{scenario.duration}
</div>
</div>
);
}
// ── Main panel ────────────────────────────────────────────────────────────────
export function SimulatorPanel() {
const [open, setOpen] = useState(false);
const [scenarios, setScenarios] = useState<ScenarioInfo[]>([]);
const [loading, setLoading] = useState(false);
const [activeScenario, setActiveScenario] = useState<string | null>(null);
const [status, setStatus] = useState<{ message: string; ok: boolean } | null>(null);
// Load scenario list once when panel first opens
useEffect(() => {
if (!open || scenarios.length > 0) return;
setLoading(true);
fetchScenarios()
.then(setScenarios)
.catch(() => setStatus({ message: "Failed to load scenarios", ok: false }))
.finally(() => setLoading(false));
}, [open, scenarios.length]);
const showStatus = useCallback((message: string, ok: boolean) => {
setStatus({ message, ok });
setTimeout(() => setStatus(null), 3000);
}, []);
const handleRun = useCallback(
async (name: string, target?: string) => {
try {
await triggerScenario(name, target);
setActiveScenario(name);
showStatus(
`${name}${target ? `${target}` : ""} triggered`,
true
);
} catch {
showStatus("Failed to trigger scenario", false);
}
},
[showStatus]
);
const handleReset = useCallback(async () => {
try {
await triggerScenario("RESET");
setActiveScenario(null);
showStatus("All scenarios reset", true);
} catch {
showStatus("Failed to reset", false);
}
}, [showStatus]);
const compound = scenarios.filter((s) => s.compound);
const single = scenarios.filter((s) => !s.compound);
return (
<Sheet open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label="Scenario simulator"
>
<FlaskConical className="w-4 h-4" />
</Button>
</SheetTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Scenario Simulator</TooltipContent>
</Tooltip>
<SheetContent
side="right"
className="w-[420px] sm:w-[480px] flex flex-col gap-0 p-0"
>
{/* Header */}
<SheetHeader className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FlaskConical className="w-4 h-4 text-muted-foreground" />
<SheetTitle className="text-base">Scenario Simulator</SheetTitle>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
demo only
</Badge>
</div>
</div>
<p className="text-xs text-muted-foreground mt-1">
Inject realistic fault scenarios into the live simulator. Changes are reflected immediately across all dashboard pages.
</p>
</SheetHeader>
{/* Reset bar */}
<div className="px-5 py-3 border-b border-border shrink-0 flex items-center justify-between gap-3">
<Button
variant="destructive"
size="sm"
className="gap-1.5"
onClick={handleReset}
>
<RotateCcw className="w-3.5 h-3.5" />
Reset All
</Button>
{status && (
<span
className={`text-xs transition-opacity ${
status.ok ? "text-emerald-500" : "text-destructive"
}`}
>
{status.message}
</span>
)}
</div>
{/* Scrollable scenario list */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
{loading && (
<p className="text-xs text-muted-foreground text-center py-8">
Loading scenarios
</p>
)}
{!loading && compound.length > 0 && (
<section className="space-y-2">
<div className="flex items-center gap-2">
<Zap className="w-3.5 h-3.5 text-amber-500" />
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Compound Scenarios
</h3>
</div>
<p className="text-[11px] text-muted-foreground -mt-1">
Multi-device, time-sequenced chains fires automatically across the site.
</p>
<div className="space-y-2">
{compound.map((s) => (
<ScenarioCard
key={s.name}
scenario={s}
active={activeScenario === s.name}
onRun={handleRun}
/>
))}
</div>
</section>
)}
{!loading && single.length > 0 && (
<section className="space-y-2">
<div className="flex items-center gap-2">
<Play className="w-3.5 h-3.5 text-muted-foreground" />
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Single Fault Scenarios
</h3>
</div>
<div className="space-y-2">
{single.map((s) => (
<ScenarioCard
key={s.name}
scenario={s}
active={activeScenario === s.name}
onRun={handleRun}
/>
))}
</div>
</section>
)}
</div>
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,11 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View file

@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View file

@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View file

@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View file

@ -0,0 +1,45 @@
import { Inbox } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
interface EmptyCardProps {
message?: string;
description?: string;
icon?: React.ReactNode;
className?: string;
/** Render as a compact inline row instead of a full card */
inline?: boolean;
}
export function EmptyCard({
message = "No data available",
description,
icon,
className,
inline = false,
}: EmptyCardProps) {
if (inline) {
return (
<div className={cn("flex items-center gap-2 text-sm text-muted-foreground", className)}>
{icon ?? <Inbox className="w-4 h-4 shrink-0" />}
<span>{message}</span>
</div>
);
}
return (
<Card className={cn("border-dashed", className)}>
<CardContent className="flex flex-col items-center justify-center gap-3 py-10 text-center">
<div className="text-muted-foreground/40">
{icon ?? <Inbox className="w-8 h-8" />}
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">{message}</p>
{description && (
<p className="text-xs text-muted-foreground/60 mt-1">{description}</p>
)}
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,54 @@
import { AlertTriangle, RefreshCw } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface ErrorCardProps {
message?: string;
onRetry?: () => void;
className?: string;
/** Render as a compact inline row instead of a full card */
inline?: boolean;
}
export function ErrorCard({
message = "Failed to load data.",
onRetry,
className,
inline = false,
}: ErrorCardProps) {
if (inline) {
return (
<div className={cn("flex items-center gap-2 text-sm text-muted-foreground", className)}>
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
<span>{message}</span>
{onRetry && (
<button
onClick={onRetry}
className="text-xs underline underline-offset-2 hover:text-foreground transition-colors"
>
Retry
</button>
)}
</div>
);
}
return (
<Card className={cn("border-destructive/30 bg-destructive/5", className)}>
<CardContent className="flex flex-col items-center justify-center gap-3 py-10 text-center">
<AlertTriangle className="w-8 h-8 text-destructive/70" />
<div>
<p className="text-sm font-medium text-foreground">Something went wrong</p>
<p className="text-xs text-muted-foreground mt-1">{message}</p>
</div>
{onRetry && (
<Button variant="outline" size="sm" onClick={onRetry} className="gap-2 mt-1">
<RefreshCw className="w-3.5 h-3.5" />
Try again
</Button>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View file

@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View file

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
)
}
export { Skeleton }

View file

@ -0,0 +1,48 @@
"use client";
import * as React from "react";
import { Tabs as TabsPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
);
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-50",
"data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
);
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };

View file

@ -0,0 +1,37 @@
"use client";
import { cn } from "@/lib/utils";
const OPTIONS = [
{ label: "1h", value: 1 },
{ label: "6h", value: 6 },
{ label: "24h", value: 24 },
{ label: "7d", value: 168 },
];
interface TimeRangePickerProps {
value: number;
onChange: (hours: number) => void;
options?: { label: string; value: number }[];
}
export function TimeRangePicker({ value, onChange, options = OPTIONS }: TimeRangePickerProps) {
return (
<div className="flex items-center gap-0.5 rounded-md border border-border bg-muted/40 p-0.5">
{options.map((opt) => (
<button
key={opt.value}
onClick={() => onChange(opt.value)}
className={cn(
"px-2 py-0.5 text-[11px] font-medium rounded transition-colors",
value === opt.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{opt.label}
</button>
))}
</div>
);
}

View file

@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View file

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View file

@ -0,0 +1,34 @@
"use client";
import { createContext, useContext, useEffect, useState, useCallback } from "react";
import { fetchAlarmStats, type AlarmStats } from "@/lib/api";
const SITE_ID = "sg-01";
type AlarmContextValue = { active: number; critical: number };
const AlarmContext = createContext<AlarmContextValue>({ active: 0, critical: 0 });
export function AlarmProvider({ children }: { children: React.ReactNode }) {
const [counts, setCounts] = useState<AlarmContextValue>({ active: 0, critical: 0 });
const poll = useCallback(async () => {
try {
const s: AlarmStats = await fetchAlarmStats(SITE_ID);
setCounts({ active: s.active, critical: s.critical });
} catch {
// keep previous value
}
}, []);
useEffect(() => {
poll();
const id = setInterval(poll, 15_000);
return () => clearInterval(id);
}, [poll]);
return <AlarmContext.Provider value={counts}>{children}</AlarmContext.Provider>;
}
export function useAlarmCount() {
return useContext(AlarmContext);
}

711
frontend/lib/api.ts Normal file
View file

@ -0,0 +1,711 @@
// BASE is the Next.js rewrite prefix defined in next.config.ts:
// /api/backend/:path* → http://backend:8000/:path*
// All fetch paths below start with /api/... giving the full path /api/backend/api/...
// This double "api" is intentional: /api/backend is the proxy mount point, /api/... is the FastAPI route.
const BASE = process.env.NEXT_PUBLIC_API_URL ?? "/api/backend"
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, { cache: "no-store", ...init })
if (!res.ok) throw new Error(`API ${path}${res.status}`)
if (res.status === 204) return undefined as T
return res.json()
}
// ── Types ────────────────────────────────────────────────────────
export type KpiData = {
total_power_kw: number
pue: number
avg_temperature: number
active_alarms: number
}
export type PowerBucket = {
bucket: string
total_kw: number
}
export type TempBucket = {
bucket: string
room_id: string
avg_temp: number
}
export type Alarm = {
id: number
sensor_id: string | null
site_id: string
room_id: string | null
rack_id: string | null
severity: "critical" | "warning" | "info"
message: string
state: string
triggered_at: string
}
export type RoomStatus = {
room_id: string
avg_temp: number
total_kw: number
alarm_count: number
status: "ok" | "warning" | "critical"
}
// ── Fetchers ─────────────────────────────────────────────────────
export const fetchKpis = (siteId: string) =>
apiFetch<KpiData>(`/api/readings/kpis?site_id=${siteId}`)
export const fetchPowerHistory = (siteId: string, hours = 1) =>
apiFetch<PowerBucket[]>(`/api/readings/site-power-history?site_id=${siteId}&hours=${hours}`)
export const fetchTempHistory = (siteId: string, hours = 1) =>
apiFetch<TempBucket[]>(`/api/readings/room-temp-history?site_id=${siteId}&hours=${hours}`)
export const fetchAlarms = (siteId: string, state = "active", limit = 100) =>
apiFetch<Alarm[]>(`/api/alarms?site_id=${siteId}&state=${state}&limit=${limit}`)
export const fetchRoomStatus = (siteId: string) =>
apiFetch<RoomStatus[]>(`/api/readings/room-status?site_id=${siteId}`)
export type AlarmStats = {
active: number
acknowledged: number
resolved: number
critical: number
warning: number
}
export const fetchAlarmStats = (siteId: string) =>
apiFetch<AlarmStats>(`/api/alarms/stats?site_id=${siteId}`)
export const acknowledgeAlarm = async (alarmId: number): Promise<void> => {
const res = await fetch(`${BASE}/api/alarms/${alarmId}/acknowledge`, { method: "POST" })
if (!res.ok) throw new Error(`Acknowledge failed: ${res.status}`)
}
export const resolveAlarm = async (alarmId: number): Promise<void> => {
const res = await fetch(`${BASE}/api/alarms/${alarmId}/resolve`, { method: "POST" })
if (!res.ok) throw new Error(`Resolve failed: ${res.status}`)
}
export type RackAsset = {
rack_id: string
temp: number | null
power_kw: number | null
status: "ok" | "warning" | "critical" | "unknown"
alarm_count: number
}
export type CracAsset = {
crac_id: string
state: "online" | "fault" | "unknown"
supply_temp: number | null
return_temp: number | null
fan_pct: number | null
}
export type UpsAsset = {
ups_id: string
state: "online" | "battery" | "overload" | "unknown"
charge_pct: number | null
load_pct: number | null
runtime_min: number | null
voltage_v: number | null
}
export type UpsHistoryPoint = {
bucket: string
charge_pct: number | null
load_pct: number | null
runtime_min: number | null
voltage_v: number | null
}
export type AssetsData = {
site_id: string
rooms: { room_id: string; crac: CracAsset; racks: RackAsset[] }[]
ups_units: UpsAsset[]
}
export const fetchAssets = (siteId: string) =>
apiFetch<AssetsData>(`/api/assets?site_id=${siteId}`)
export type RackPower = { rack_id: string; power_kw: number | null }
export type RoomPowerBreakdown = { room_id: string; racks: RackPower[] }
export type PowerHistoryBucket = { bucket: string; room_id: string; total_kw: number }
export const fetchRackBreakdown = (siteId: string) =>
apiFetch<RoomPowerBreakdown[]>(`/api/power/rack-breakdown?site_id=${siteId}`)
export const fetchRoomPowerHistory = (siteId: string, hours = 6) =>
apiFetch<PowerHistoryBucket[]>(`/api/power/room-history?site_id=${siteId}&hours=${hours}`)
export const fetchUpsStatus = (siteId: string) =>
apiFetch<UpsAsset[]>(`/api/power/ups?site_id=${siteId}`)
export const fetchUpsHistory = (siteId: string, upsId: string, hours = 6) =>
apiFetch<UpsHistoryPoint[]>(`/api/power/ups/history?site_id=${siteId}&ups_id=${upsId}&hours=${hours}`)
export type RackEnvReading = { rack_id: string; temperature: number | null; humidity: number | null }
export type RoomEnvReadings = { room_id: string; racks: RackEnvReading[] }
export type HumidityBucket = { bucket: string; room_id: string; avg_humidity: number }
export type CracStatus = {
crac_id: string
room_id: string | null
state: "online" | "fault"
delta: number | null
rated_capacity_kw: number
// Thermal
supply_temp: number | null
return_temp: number | null
supply_humidity: number | null
return_humidity: number | null
airflow_cfm: number | null
filter_dp_pa: number | null
// Capacity
cooling_capacity_kw: number | null
cooling_capacity_pct: number | null
cop: number | null
sensible_heat_ratio: number | null
// Compressor
compressor_state: number | null
compressor_load_pct: number | null
compressor_power_kw: number | null
compressor_run_hours: number | null
high_pressure_bar: number | null
low_pressure_bar: number | null
discharge_superheat_c: number | null
liquid_subcooling_c: number | null
// Fan
fan_pct: number | null
fan_rpm: number | null
fan_power_kw: number | null
fan_run_hours: number | null
// Electrical
total_unit_power_kw: number | null
input_voltage_v: number | null
input_current_a: number | null
power_factor: number | null
}
export type CracHistoryPoint = {
bucket: string
supply_temp: number | null
return_temp: number | null
delta_t: number | null
capacity_kw: number | null
capacity_pct: number | null
cop: number | null
comp_load: number | null
filter_dp: number | null
fan_pct: number | null
}
export const fetchRackEnvReadings = (siteId: string) =>
apiFetch<RoomEnvReadings[]>(`/api/env/rack-readings?site_id=${siteId}`)
export const fetchHumidityHistory = (siteId: string, hours = 6) =>
apiFetch<HumidityBucket[]>(`/api/env/humidity-history?site_id=${siteId}&hours=${hours}`)
export const fetchCracStatus = (siteId: string) =>
apiFetch<CracStatus[]>(`/api/env/crac-status?site_id=${siteId}`)
export type RackHistoryPoint = { bucket: string; temperature?: number; humidity?: number; power_kw?: number }
export type RackHistory = { rack_id: string; site_id: string; history: RackHistoryPoint[]; alarms: Alarm[] }
export const fetchRackHistory = (siteId: string, rackId: string, hours = 6) =>
apiFetch<RackHistory>(`/api/env/rack-history?site_id=${siteId}&rack_id=${rackId}&hours=${hours}`)
export type CracDeltaPoint = { bucket: string; delta: number }
export const fetchCracDeltaHistory = (siteId: string, cracId: string, hours = 1) =>
apiFetch<CracDeltaPoint[]>(`/api/env/crac-delta-history?site_id=${siteId}&crac_id=${cracId}&hours=${hours}`)
export const fetchCracHistory = (siteId: string, cracId: string, hours = 6) =>
apiFetch<CracHistoryPoint[]>(`/api/env/crac-history?site_id=${siteId}&crac_id=${cracId}&hours=${hours}`)
export type ReportSummary = {
site_id: string
generated_at: string
kpis: { total_power_kw: number; avg_temperature: number }
alarm_stats: { active: number; acknowledged: number; resolved: number; critical: number; warning: number }
crac_uptime: { crac_id: string; room_id: string; uptime_pct: number }[]
ups_uptime: { ups_id: string; uptime_pct: number }[]
}
export const fetchReportSummary = (siteId: string) =>
apiFetch<ReportSummary>(`/api/reports/summary?site_id=${siteId}`)
export const reportExportUrl = (type: "power" | "temperature" | "alarms", siteId: string, hours = 24) =>
type === "alarms"
? `${BASE}/api/reports/export/alarms?site_id=${siteId}`
: `${BASE}/api/reports/export/${type}?site_id=${siteId}&hours=${hours}`
export type RackCapacity = {
rack_id: string
room_id: string
power_kw: number | null
power_capacity_kw: number
power_pct: number | null
temp: number | null
}
export type RoomCapacity = {
room_id: string
power: { used_kw: number; capacity_kw: number; pct: number; headroom_kw: number }
cooling: { load_kw: number; capacity_kw: number; pct: number; headroom_kw: number }
space: { racks_total: number; racks_populated: number; pct: number }
}
export type CapacitySummary = {
site_id: string
config: { rack_power_kw: number; room_power_kw: number; crac_cooling_kw: number; rack_u_total: number }
rooms: RoomCapacity[]
racks: RackCapacity[]
}
export const fetchCapacitySummary = (siteId: string) =>
apiFetch<CapacitySummary>(`/api/capacity/summary?site_id=${siteId}`)
export type Device = {
device_id: string
name: string
type: "server" | "switch" | "patch_panel" | "pdu" | "storage" | "firewall" | "kvm"
rack_id: string
room_id: string
site_id: string
u_start: number
u_height: number
ip: string
serial: string
model: string
status: "online" | "offline" | "unknown"
power_draw_w: number
}
export const fetchAllDevices = (siteId: string) =>
apiFetch<Device[]>(`/api/assets/devices?site_id=${siteId}`)
export const fetchRackDevices = (siteId: string, rackId: string) =>
apiFetch<Device[]>(`/api/assets/rack-devices?site_id=${siteId}&rack_id=${rackId}`)
// ── Generator ─────────────────────────────────────────────────────
export type GeneratorStatus = {
gen_id: string
state: "standby" | "running" | "test" | "fault" | "unknown"
fuel_pct: number | null
fuel_litres: number | null
fuel_rate_lph: number | null
load_kw: number | null
load_pct: number | null
run_hours: number | null
voltage_v: number | null
frequency_hz: number | null
engine_rpm: number | null
oil_pressure_bar: number | null
coolant_temp_c: number | null
exhaust_temp_c: number | null
alternator_temp_c: number | null
power_factor: number | null
battery_v: number | null
}
export type GeneratorHistoryPoint = {
bucket: string
load_pct: number | null
fuel_pct: number | null
coolant_temp_c: number | null
exhaust_temp_c: number | null
frequency_hz: number | null
alternator_temp_c: number | null
}
export const fetchGeneratorStatus = (siteId: string) =>
apiFetch<GeneratorStatus[]>(`/api/generator/status?site_id=${siteId}`)
export const fetchGeneratorHistory = (siteId: string, genId: string, hours = 6) =>
apiFetch<GeneratorHistoryPoint[]>(`/api/generator/history?site_id=${siteId}&gen_id=${genId}&hours=${hours}`)
// ── ATS ───────────────────────────────────────────────────────────
export type AtsStatus = {
ats_id: string
state: "stable" | "transferring"
active_feed: "utility-a" | "utility-b" | "generator"
transfer_count: number
last_transfer_ms: number | null
utility_a_v: number | null
utility_b_v: number | null
generator_v: number | null
}
export const fetchAtsStatus = (siteId: string) =>
apiFetch<AtsStatus[]>(`/api/power/ats?site_id=${siteId}`)
// ── Phase breakdown ───────────────────────────────────────────────
export type RackPhase = {
rack_id: string
room_id: string
phase_a_kw: number | null
phase_b_kw: number | null
phase_c_kw: number | null
phase_a_a: number | null
phase_b_a: number | null
phase_c_a: number | null
imbalance_pct: number | null
}
export type RoomPhase = { room_id: string; racks: RackPhase[] }
export const fetchPhaseBreakdown = (siteId: string) =>
apiFetch<RoomPhase[]>(`/api/power/phase?site_id=${siteId}`)
// ── Power redundancy ──────────────────────────────────────────────
export type PowerRedundancy = {
site_id: string
level: "2N" | "N+1" | "N"
ups_total: number
ups_online: number
generator_ok: boolean
ats_active_feed: string | null
notes: string
}
export const fetchPowerRedundancy = (siteId: string) =>
apiFetch<PowerRedundancy>(`/api/power/redundancy?site_id=${siteId}`)
// ── Utility power ─────────────────────────────────────────────────
export type UtilityPower = {
site_id: string
total_kw: number
tariff_sgd_kwh: number
kwh_month_to_date: number
cost_sgd_mtd: number
kwh_annual_est: number
cost_sgd_annual_est: number
currency: string
}
export const fetchUtilityPower = (siteId: string) =>
apiFetch<UtilityPower>(`/api/power/utility?site_id=${siteId}`)
// ── Chiller ───────────────────────────────────────────────────────
export type ChillerStatus = {
chiller_id: string
state: "online" | "fault" | "unknown"
chw_supply_c: number | null
chw_return_c: number | null
chw_delta_c: number | null
flow_gpm: number | null
cooling_load_kw: number | null
cooling_load_pct: number | null
cop: number | null
compressor_load_pct: number | null
condenser_pressure_bar: number | null
evaporator_pressure_bar: number | null
cw_supply_c: number | null
cw_return_c: number | null
run_hours: number | null
}
export type ChillerHistoryPoint = {
bucket: string
cop: number | null
load_kw: number | null
load_pct: number | null
chw_supply_c: number | null
chw_return_c: number | null
comp_load: number | null
}
export const fetchChillerStatus = (siteId: string) =>
apiFetch<ChillerStatus[]>(`/api/cooling/status?site_id=${siteId}`)
export const fetchChillerHistory = (siteId: string, chillerId: string, hours = 6) =>
apiFetch<ChillerHistoryPoint[]>(`/api/cooling/history?site_id=${siteId}&chiller_id=${chillerId}&hours=${hours}`)
// ── Fire / VESDA ──────────────────────────────────────────────────
export type FireZoneStatus = {
zone_id: string
room_id: string | null
level: "normal" | "alert" | "action" | "fire"
obscuration_pct_m: number | null
detector_1_ok: boolean
detector_2_ok: boolean
power_ok: boolean
flow_ok: boolean
}
export const fetchFireStatus = (siteId: string) =>
apiFetch<FireZoneStatus[]>(`/api/fire/status?site_id=${siteId}`)
// ── Leak sensors ──────────────────────────────────────────────────
export type LeakSensorStatus = {
sensor_id: string
floor_zone: string
under_floor: boolean
near_crac: boolean
room_id: string | null
state: "clear" | "detected" | "unknown"
recorded_at: string | null
}
export const fetchLeakStatus = (siteId: string) =>
apiFetch<LeakSensorStatus[]>(`/api/leak/status?site_id=${siteId}`)
// ── Energy report ─────────────────────────────────────────────────
export type EnergyReport = {
site_id: string
period_days: number
from_date: string
to_date: string
kwh_total: number
cost_sgd: number
tariff_sgd_kwh: number
currency: string
pue_estimated: number
pue_trend: { day: string; avg_it_kw: number; pue_est: number }[]
}
export const fetchEnergyReport = (siteId: string, days = 30) =>
apiFetch<EnergyReport>(`/api/reports/energy?site_id=${siteId}&days=${days}`)
// ── Network ───────────────────────────────────────────────────────
export type NetworkSwitchStatus = {
switch_id: string
name: string
model: string
room_id: string
rack_id: string
role: "core" | "edge" | "access"
port_count: number
state: "up" | "degraded" | "down" | "unknown"
uptime_s: number | null
active_ports: number | null
bandwidth_in_mbps: number | null
bandwidth_out_mbps: number | null
cpu_pct: number | null
mem_pct: number | null
temperature_c: number | null
packet_loss_pct: number | null
}
export const fetchNetworkStatus = (siteId: string) =>
apiFetch<NetworkSwitchStatus[]>(`/api/network/status?site_id=${siteId}`)
export type PduReading = {
rack_id: string
room_id: string
total_kw: number | null
phase_a_kw: number | null
phase_b_kw: number | null
phase_c_kw: number | null
phase_a_a: number | null
phase_b_a: number | null
phase_c_a: number | null
imbalance_pct: number | null
status: "ok" | "warning" | "critical"
}
export const fetchPduReadings = (siteId: string) =>
apiFetch<PduReading[]>(`/api/assets/pdus?site_id=${siteId}`)
// ── Maintenance Windows ───────────────────────────────────────────
export type MaintenanceWindow = {
id: string
site_id: string
title: string
target: string
target_label: string
start_dt: string
end_dt: string
suppress_alarms: boolean
notes: string
created_at: string
status: "active" | "scheduled" | "expired"
}
export const fetchMaintenanceWindows = (siteId: string) =>
apiFetch<MaintenanceWindow[]>(`/api/maintenance?site_id=${siteId}`)
export const fetchActiveMaintenanceWindows = (siteId: string) =>
apiFetch<MaintenanceWindow[]>(`/api/maintenance/active?site_id=${siteId}`)
export const createMaintenanceWindow = (body: {
site_id: string; title: string; target: string; target_label: string;
start_dt: string; end_dt: string; suppress_alarms: boolean; notes: string;
}) => apiFetch<MaintenanceWindow>("/api/maintenance", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
export const deleteMaintenanceWindow = (id: string) =>
apiFetch<void>(`/api/maintenance/${id}`, { method: "DELETE" })
// ── Floor layout ──────────────────────────────────────────────────
export const fetchFloorLayout = (siteId: string) =>
apiFetch<Record<string, unknown>>(`/api/floor-layout?site_id=${siteId}`)
export const saveFloorLayout = (siteId: string, layout: Record<string, unknown>) =>
apiFetch<{ ok: boolean }>(`/api/floor-layout?site_id=${siteId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(layout),
})
export type ParticleStatus = {
room_id: string
particles_0_5um: number | null
particles_5um: number | null
iso_class: number | null
}
export const fetchParticleStatus = (siteId: string) =>
apiFetch<ParticleStatus[]>(`/api/env/particles?site_id=${siteId}`)
// ── Settings: Sensors ─────────────────────────────────────────────────────────
export type DeviceType = 'ups' | 'generator' | 'crac' | 'chiller' | 'ats' | 'rack' | 'network_switch' | 'leak' | 'fire_zone' | 'custom'
export type Protocol = 'mqtt' | 'modbus_tcp' | 'modbus_rtu' | 'snmp' | 'bacnet' | 'http'
export type SensorDevice = {
id: number
site_id: string
device_id: string
name: string
device_type: DeviceType
room_id: string | null
rack_id: string | null
protocol: Protocol
protocol_config: Record<string, unknown>
enabled: boolean
created_at: string
updated_at: string
recent_readings?: { sensor_type: string; value: number; unit: string; recorded_at: string }[]
}
export type SensorCreate = {
device_id: string
name: string
device_type: DeviceType
room_id?: string | null
rack_id?: string | null
protocol: Protocol
protocol_config: Record<string, unknown>
enabled: boolean
}
export type SensorUpdate = Partial<Omit<SensorCreate, 'device_id'>>
export const fetchSensors = (siteId: string, params?: { device_type?: string; room_id?: string; protocol?: string }) => {
const p: Record<string, string> = { site_id: siteId }
if (params?.device_type) p.device_type = params.device_type
if (params?.room_id) p.room_id = params.room_id
if (params?.protocol) p.protocol = params.protocol
return apiFetch<SensorDevice[]>(`/api/settings/sensors?${new URLSearchParams(p)}`)
}
export const fetchSensor = (id: number) =>
apiFetch<SensorDevice>(`/api/settings/sensors/${id}`)
export const createSensor = (siteId: string, body: SensorCreate) =>
apiFetch<SensorDevice>(`/api/settings/sensors?site_id=${siteId}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
})
export const updateSensor = (id: number, body: SensorUpdate) =>
apiFetch<SensorDevice>(`/api/settings/sensors/${id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
})
export const deleteSensor = (id: number) =>
apiFetch<void>(`/api/settings/sensors/${id}`, { method: 'DELETE' })
// ── Settings: Thresholds ───────────────────────────────────────────────────────
export type AlarmThreshold = {
id: number
site_id: string
sensor_type: string
threshold_value: number
direction: 'above' | 'below'
severity: 'warning' | 'critical'
message_template: string
enabled: boolean
locked: boolean
created_at: string
updated_at: string
}
export type ThresholdUpdate = { threshold_value?: number; severity?: string; enabled?: boolean }
export type ThresholdCreate = { sensor_type: string; threshold_value: number; direction: string; severity: string; message_template: string }
export const fetchThresholds = (siteId: string) =>
apiFetch<AlarmThreshold[]>(`/api/settings/thresholds?site_id=${siteId}`)
export const updateThreshold = (id: number, body: ThresholdUpdate) =>
apiFetch<AlarmThreshold>(`/api/settings/thresholds/${id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
})
export const createThreshold = (siteId: string, body: ThresholdCreate) =>
apiFetch<AlarmThreshold>(`/api/settings/thresholds?site_id=${siteId}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
})
export const deleteThreshold = (id: number) =>
apiFetch<void>(`/api/settings/thresholds/${id}`, { method: 'DELETE' })
export const resetThresholds = (siteId: string) =>
apiFetch<{ ok: boolean; count: number }>(`/api/settings/thresholds/reset?site_id=${siteId}`, { method: 'POST' })
// ── Settings: Site / Notifications / Integrations ─────────────────────────────
export type SiteSettings = { name: string; timezone: string; description: string }
export type NotificationSettings = { critical_alarms: boolean; warning_alarms: boolean; generator_events: boolean; maintenance_reminders: boolean; webhook_url: string; email_recipients: string }
export type IntegrationSettings = { mqtt_host: string; mqtt_port: number }
export type PagePrefs = { default_time_range_hours: number; refresh_interval_seconds: number }
const _settingsPut = <T>(path: string, value: Partial<T>) =>
apiFetch<T>(`/api/settings/${path}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value }),
})
export const fetchSiteSettings = (siteId: string) => apiFetch<SiteSettings>(`/api/settings/site?site_id=${siteId}`)
export const updateSiteSettings = (siteId: string, v: Partial<SiteSettings>) => _settingsPut<SiteSettings>(`site?site_id=${siteId}`, v)
export const fetchNotifications = (siteId: string) => apiFetch<NotificationSettings>(`/api/settings/notifications?site_id=${siteId}`)
export const updateNotifications = (siteId: string, v: Partial<NotificationSettings>) => _settingsPut<NotificationSettings>(`notifications?site_id=${siteId}`, v)
export const fetchIntegrations = (siteId: string) => apiFetch<IntegrationSettings>(`/api/settings/integrations?site_id=${siteId}`)
export const updateIntegrations = (siteId: string, v: Partial<IntegrationSettings>) => _settingsPut<IntegrationSettings>(`integrations?site_id=${siteId}`, v)
export const fetchPagePrefs = (siteId: string) => apiFetch<PagePrefs>(`/api/settings/page-prefs?site_id=${siteId}`)
export const updatePagePrefs = (siteId: string, v: Partial<PagePrefs>) => _settingsPut<PagePrefs>(`page-prefs?site_id=${siteId}`, v)
// ── Scenarios (simulator control) ─────────────────────────────────
export type ScenarioInfo = {
name: string
label: string
description: string
duration: string
compound: boolean
default_target: string | null
targets: string[]
}
export const fetchScenarios = () =>
apiFetch<ScenarioInfo[]>("/api/scenarios")
export const triggerScenario = (scenario: string, target?: string) =>
apiFetch<{ ok: boolean }>("/api/scenarios/trigger", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scenario, target: target ?? null }),
})

View file

@ -0,0 +1,101 @@
"use client";
import { createContext, useContext, useState, useEffect, useMemo } from "react";
import { THRESHOLDS } from "@/lib/thresholds";
const LS_KEY = "bms_thresholds";
/** Subset of THRESHOLDS that the Settings page allows overriding */
export interface ThresholdOverrides {
temp?: { warn?: number; critical?: number };
humidity?: { low?: number; warn?: number; critical?: number };
power?: { warn?: number; critical?: number };
}
/** Mutable version of THRESHOLDS for runtime use */
export interface MergedThresholds {
temp: { warn: number; critical: number };
humidity: { low: number; warn: number; critical: number };
dewPoint: { warn: number };
power: { warn: number; critical: number };
rackPower: { warn: number; critical: number; rated: number };
filter: { warn: number; critical: number; ratePerDay: number; replaceAt: number };
cop: { warn: number };
compressor: { warn: number; critical: number };
battery: { warn: number; critical: number };
fuel: { warn: number; critical: number };
ups: { loadWarn: number; loadCritical: number };
pue: { target: number; warn: number; critical: number };
phaseImbalance: { warn: number; critical: number };
network: { cpuWarn: number; cpuCritical: number; memWarn: number; memCritical: number; tempWarn: number; tempCritical: number };
ashrae: { tempMin: number; tempMax: number; rhMin: number; rhMax: number };
}
interface ThresholdContextValue {
thresholds: MergedThresholds;
setThresholds: (patch: ThresholdOverrides) => void;
}
const ThresholdContext = createContext<ThresholdContextValue | null>(null);
function loadOverrides(): ThresholdOverrides {
if (typeof window === "undefined") return {};
try {
const raw = localStorage.getItem(LS_KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
function merge(overrides: ThresholdOverrides): MergedThresholds {
return {
temp: { warn: THRESHOLDS.temp.warn, critical: THRESHOLDS.temp.critical, ...overrides.temp },
humidity: { low: THRESHOLDS.humidity.low, warn: THRESHOLDS.humidity.warn, critical: THRESHOLDS.humidity.critical, ...overrides.humidity },
dewPoint: { warn: THRESHOLDS.dewPoint.warn },
power: { warn: THRESHOLDS.power.warn, critical: THRESHOLDS.power.critical, ...overrides.power },
rackPower: { warn: THRESHOLDS.rackPower.warn, critical: THRESHOLDS.rackPower.critical, rated: THRESHOLDS.rackPower.rated },
filter: { warn: THRESHOLDS.filter.warn, critical: THRESHOLDS.filter.critical, ratePerDay: THRESHOLDS.filter.ratePerDay, replaceAt: THRESHOLDS.filter.replaceAt },
cop: { warn: THRESHOLDS.cop.warn },
compressor: { warn: THRESHOLDS.compressor.warn, critical: THRESHOLDS.compressor.critical },
battery: { warn: THRESHOLDS.battery.warn, critical: THRESHOLDS.battery.critical },
fuel: { warn: THRESHOLDS.fuel.warn, critical: THRESHOLDS.fuel.critical },
ups: { loadWarn: THRESHOLDS.ups.loadWarn, loadCritical: THRESHOLDS.ups.loadCritical },
pue: { target: THRESHOLDS.pue.target, warn: THRESHOLDS.pue.warn, critical: THRESHOLDS.pue.critical },
phaseImbalance: { warn: THRESHOLDS.phaseImbalance.warn, critical: THRESHOLDS.phaseImbalance.critical },
network: { cpuWarn: THRESHOLDS.network.cpuWarn, cpuCritical: THRESHOLDS.network.cpuCritical, memWarn: THRESHOLDS.network.memWarn, memCritical: THRESHOLDS.network.memCritical, tempWarn: THRESHOLDS.network.tempWarn, tempCritical: THRESHOLDS.network.tempCritical },
ashrae: { tempMin: THRESHOLDS.ashrae.tempMin, tempMax: THRESHOLDS.ashrae.tempMax, rhMin: THRESHOLDS.ashrae.rhMin, rhMax: THRESHOLDS.ashrae.rhMax },
};
}
export function ThresholdProvider({ children }: { children: React.ReactNode }) {
const [overrides, setOverrides] = useState<ThresholdOverrides>({});
useEffect(() => {
setOverrides(loadOverrides());
}, []);
const thresholds = useMemo(() => merge(overrides), [overrides]);
function setThresholds(patch: ThresholdOverrides) {
const next: ThresholdOverrides = {
temp: { ...overrides.temp, ...patch.temp },
humidity: { ...overrides.humidity, ...patch.humidity },
power: { ...overrides.power, ...patch.power },
};
setOverrides(next);
localStorage.setItem(LS_KEY, JSON.stringify(next));
}
return (
<ThresholdContext.Provider value={{ thresholds, setThresholds }}>
{children}
</ThresholdContext.Provider>
);
}
export function useThresholds(): ThresholdContextValue {
const ctx = useContext(ThresholdContext);
if (!ctx) throw new Error("useThresholds must be used inside ThresholdProvider");
return ctx;
}

114
frontend/lib/thresholds.ts Normal file
View file

@ -0,0 +1,114 @@
/**
* Centralised operational thresholds.
* Import from here instead of hardcoding values in individual pages.
*/
export const THRESHOLDS = {
temp: {
warn: 26, // °C
critical: 28, // °C
},
humidity: {
low: 30, // % RH — static risk below this
warn: 65, // % RH
critical: 80, // % RH
},
dewPoint: {
warn: 15, // °C — condensation risk zone
},
power: {
warn: 0.75, // fraction of rated capacity
critical: 0.85,
},
rackPower: {
warn: 7.5, // kW per rack
critical: 9.5, // kW per rack
rated: 10, // kW per rack (default rated capacity)
},
filter: {
warn: 80, // Pa differential pressure
critical: 120, // Pa
ratePerDay: 1.2, // Pa/day assumed fouling rate
replaceAt: 120, // Pa
},
cop: {
warn: 1.5, // COP below this is inefficient
},
compressor: {
warn: 0.80, // fraction of capacity
critical: 0.95,
},
battery: {
warn: 30, // % state of charge
critical: 20, // %
},
fuel: {
warn: 30, // % fuel level
critical: 15, // %
},
ups: {
loadWarn: 0.75, // fraction of rated load
loadCritical: 0.90,
},
pue: {
target: 1.4,
warn: 1.6,
critical: 2.0,
},
phaseImbalance: {
warn: 5, // %
critical: 15, // %
},
network: {
cpuWarn: 70, // %
cpuCritical: 90, // %
memWarn: 70, // %
memCritical: 85, // %
tempWarn: 55, // °C
tempCritical: 70, // °C
},
ashrae: {
// ASHRAE A1 Class envelope
tempMin: 15, // °C
tempMax: 32, // °C
rhMin: 20, // %
rhMax: 80, // %
},
} as const;
/** Colour helper: returns a Tailwind text/bg colour token based on a value vs warn/critical pair */
export function severityColor(
value: number,
warn: number,
critical: number,
invert = false, // set true when lower is worse (e.g. battery %)
): "green" | "amber" | "red" {
if (invert) {
if (value <= critical) return "red";
if (value <= warn) return "amber";
return "green";
}
if (value >= critical) return "red";
if (value >= warn) return "amber";
return "green";
}
export const COLOR_CLASSES = {
green: {
text: "text-green-400",
bg: "bg-green-500/15",
border: "border-green-500/30",
badge: "bg-green-500/20 text-green-300",
},
amber: {
text: "text-amber-400",
bg: "bg-amber-500/15",
border: "border-amber-500/30",
badge: "bg-amber-500/20 text-amber-300",
},
red: {
text: "text-red-400",
bg: "bg-red-500/15",
border: "border-red-500/30",
badge: "bg-red-500/20 text-red-300",
},
} as const;

6
frontend/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

16
frontend/next.config.ts Normal file
View file

@ -0,0 +1,16 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
async rewrites() {
const backendUrl = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:8000";
return [
{
source: "/api/backend/:path*",
destination: `${backendUrl}/:path*`,
},
];
},
};
export default nextConfig;

38
frontend/package.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@clerk/nextjs": "^7.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-zoom-pan-pinch": "^3.7.0",
"recharts": "^3.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"shadcn": "^3.8.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

8195
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,3 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver

View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

15
frontend/proxy.ts Normal file
View file

@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
// Auth is disabled for demo mode — all routes are open.
// To enable Clerk auth, replace this with clerkMiddleware and add keys to .env.local
export default function proxy(_request: NextRequest) {
return NextResponse.next();
}
export const config = {
matcher: [
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
"/(api|trpc)(.*)",
],
};

1
frontend/public/file.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1 KiB

1
frontend/public/next.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

34
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}