first commit
This commit is contained in:
commit
4b98219bf7
144 changed files with 31561 additions and 0 deletions
41
frontend/.gitignore
vendored
Normal file
41
frontend/.gitignore
vendored
Normal 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
36
frontend/Dockerfile
Normal 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
36
frontend/README.md
Normal 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.
|
||||
753
frontend/app/(dashboard)/alarms/page.tsx
Normal file
753
frontend/app/(dashboard)/alarms/page.tsx
Normal file
|
|
@ -0,0 +1,753 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
fetchAlarms, fetchAlarmStats, acknowledgeAlarm, resolveAlarm,
|
||||
type Alarm, type AlarmStats,
|
||||
} from "@/lib/api";
|
||||
import { RackDetailSheet } from "@/components/dashboard/rack-detail-sheet";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
AlertTriangle, CheckCircle2, Clock, XCircle, Bell,
|
||||
ChevronsUpDown, ChevronUp, ChevronDown, Activity,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
BarChart, Bar, XAxis, Tooltip, ResponsiveContainer, Cell,
|
||||
} from "recharts";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
type StateFilter = "active" | "acknowledged" | "resolved" | "all";
|
||||
type SeverityFilter = "all" | "critical" | "warning" | "info";
|
||||
type SortKey = "severity" | "triggered_at" | "state";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h`;
|
||||
return `${Math.floor(h / 24)}d`;
|
||||
}
|
||||
|
||||
function useNow(intervalMs = 30_000): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [intervalMs]);
|
||||
return now;
|
||||
}
|
||||
|
||||
function escalationMinutes(triggeredAt: string, now: number): number {
|
||||
return Math.floor((now - new Date(triggeredAt).getTime()) / 60_000);
|
||||
}
|
||||
|
||||
function EscalationTimer({ triggeredAt, now }: { triggeredAt: string; now: number }) {
|
||||
const mins = escalationMinutes(triggeredAt, now);
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = mins % 60;
|
||||
const label = h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
|
||||
const colorClass =
|
||||
mins >= 60 ? "text-destructive" :
|
||||
mins >= 15 ? "text-amber-400" :
|
||||
mins >= 5 ? "text-amber-300" :
|
||||
"text-muted-foreground";
|
||||
|
||||
const pulse = mins >= 60;
|
||||
|
||||
return (
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1 text-xs font-mono font-semibold tabular-nums",
|
||||
colorClass,
|
||||
pulse && "animate-pulse",
|
||||
)}>
|
||||
<Clock className="w-3 h-3 shrink-0" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function alarmCategory(sensorId: string | null | undefined): { label: string; className: string } {
|
||||
if (!sensorId) return { label: "System", className: "bg-muted/50 text-muted-foreground" };
|
||||
const s = sensorId.toLowerCase();
|
||||
if (s.includes("cooling") || s.includes("crac") || s.includes("refrigerant") || s.includes("cop"))
|
||||
return { label: "Refrigerant", className: "bg-cyan-500/10 text-cyan-400" };
|
||||
if (s.includes("temp") || s.includes("thermal") || s.includes("humidity") || s.includes("hum"))
|
||||
return { label: "Thermal", className: "bg-orange-500/10 text-orange-400" };
|
||||
if (s.includes("power") || s.includes("ups") || s.includes("pdu") || s.includes("kw") || s.includes("watt"))
|
||||
return { label: "Power", className: "bg-yellow-500/10 text-yellow-400" };
|
||||
if (s.includes("leak") || s.includes("water") || s.includes("flood"))
|
||||
return { label: "Leak", className: "bg-blue-500/10 text-blue-400" };
|
||||
return { label: "System", className: "bg-muted/50 text-muted-foreground" };
|
||||
}
|
||||
|
||||
const severityConfig: Record<string, { label: string; bg: string; dot: string }> = {
|
||||
critical: { label: "Critical", bg: "bg-destructive/15 text-destructive border-destructive/30", dot: "bg-destructive" },
|
||||
warning: { label: "Warning", bg: "bg-amber-500/15 text-amber-400 border-amber-500/30", dot: "bg-amber-500" },
|
||||
info: { label: "Info", bg: "bg-blue-500/15 text-blue-400 border-blue-500/30", dot: "bg-blue-500" },
|
||||
};
|
||||
|
||||
const stateConfig: Record<string, { label: string; className: string }> = {
|
||||
active: { label: "Active", className: "bg-destructive/10 text-destructive" },
|
||||
acknowledged: { label: "Acknowledged", className: "bg-amber-500/10 text-amber-400" },
|
||||
resolved: { label: "Resolved", className: "bg-green-500/10 text-green-400" },
|
||||
};
|
||||
|
||||
function SeverityBadge({ severity }: { severity: string }) {
|
||||
const c = severityConfig[severity] ?? severityConfig.info;
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wide border", c.bg)}>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", c.dot)} />
|
||||
{c.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, icon: Icon, highlight }: { label: string; value: number; icon: React.ElementType; highlight?: boolean }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className={cn("p-2 rounded-lg", highlight && value > 0 ? "bg-destructive/10" : "bg-muted")}>
|
||||
<Icon className={cn("w-4 h-4", highlight && value > 0 ? "text-destructive" : "text-muted-foreground")} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={cn("text-2xl font-bold", highlight && value > 0 ? "text-destructive" : "")}>{value}</p>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AvgAgeCard({ alarms }: { alarms: Alarm[] }) {
|
||||
const activeAlarms = alarms.filter(a => a.state === "active");
|
||||
const avgMins = useMemo(() => {
|
||||
if (activeAlarms.length === 0) return 0;
|
||||
const now = Date.now();
|
||||
const totalMins = activeAlarms.reduce((sum, a) => {
|
||||
return sum + Math.floor((now - new Date(a.triggered_at).getTime()) / 60_000);
|
||||
}, 0);
|
||||
return Math.round(totalMins / activeAlarms.length);
|
||||
}, [activeAlarms]);
|
||||
|
||||
const label = avgMins >= 60
|
||||
? `${Math.floor(avgMins / 60)}h ${avgMins % 60}m`
|
||||
: `${avgMins}m`;
|
||||
|
||||
const colorClass = avgMins > 60 ? "text-destructive"
|
||||
: avgMins > 15 ? "text-amber-400"
|
||||
: "text-green-400";
|
||||
|
||||
const iconColor = avgMins > 60 ? "text-destructive"
|
||||
: avgMins > 15 ? "text-amber-400"
|
||||
: "text-muted-foreground";
|
||||
|
||||
const bgColor = avgMins > 60 ? "bg-destructive/10"
|
||||
: avgMins > 15 ? "bg-amber-500/10"
|
||||
: "bg-muted";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className={cn("p-2 rounded-lg", bgColor)}>
|
||||
<Clock className={cn("w-4 h-4", iconColor)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={cn("text-2xl font-bold", activeAlarms.length > 0 ? colorClass : "")}>{activeAlarms.length > 0 ? label : "—"}</p>
|
||||
<p className="text-xs text-muted-foreground">Avg Age</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type Correlation = {
|
||||
id: string
|
||||
title: string
|
||||
severity: "critical" | "warning"
|
||||
description: string
|
||||
alarmIds: number[]
|
||||
}
|
||||
|
||||
function correlateAlarms(alarms: Alarm[]): Correlation[] {
|
||||
const active = alarms.filter(a => a.state === "active");
|
||||
const results: Correlation[] = [];
|
||||
|
||||
// Rule 1: ≥2 thermal alarms in the same room → probable CRAC issue
|
||||
const thermalByRoom = new Map<string, Alarm[]>();
|
||||
for (const a of active) {
|
||||
const isThermal = a.sensor_id
|
||||
? /temp|thermal|humidity|hum/i.test(a.sensor_id)
|
||||
: /temp|thermal|hot|cool/i.test(a.message);
|
||||
const room = a.room_id;
|
||||
if (isThermal && room) {
|
||||
if (!thermalByRoom.has(room)) thermalByRoom.set(room, []);
|
||||
thermalByRoom.get(room)!.push(a);
|
||||
}
|
||||
}
|
||||
for (const [room, roomAlarms] of thermalByRoom.entries()) {
|
||||
if (roomAlarms.length >= 2) {
|
||||
results.push({
|
||||
id: `thermal-${room}`,
|
||||
title: `Thermal event — ${room.replace("hall-", "Hall ")}`,
|
||||
severity: roomAlarms.some(a => a.severity === "critical") ? "critical" : "warning",
|
||||
description: `${roomAlarms.length} thermal alarms in the same room. Probable cause: CRAC cooling degradation or containment breach.`,
|
||||
alarmIds: roomAlarms.map(a => a.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 2: ≥3 power alarms across different racks → PDU or UPS path issue
|
||||
const powerAlarms = active.filter(a =>
|
||||
a.sensor_id ? /power|pdu|ups|kw|watt/i.test(a.sensor_id) : /power|overload|circuit/i.test(a.message)
|
||||
);
|
||||
const powerRacks = new Set(powerAlarms.map(a => a.rack_id).filter(Boolean));
|
||||
if (powerRacks.size >= 2) {
|
||||
results.push({
|
||||
id: "power-multi-rack",
|
||||
title: "Multi-rack power event",
|
||||
severity: powerAlarms.some(a => a.severity === "critical") ? "critical" : "warning",
|
||||
description: `Power alarms on ${powerRacks.size} racks simultaneously. Probable cause: upstream PDU, busway tap, or UPS transfer.`,
|
||||
alarmIds: powerAlarms.map(a => a.id),
|
||||
});
|
||||
}
|
||||
|
||||
// Rule 3: Generator + ATS alarms together → power path / utility failure
|
||||
const genAlarm = active.find(a => a.sensor_id ? /gen/i.test(a.sensor_id) : /generator/i.test(a.message));
|
||||
const atsAlarm = active.find(a => a.sensor_id ? /ats/i.test(a.sensor_id) : /transfer|utility/i.test(a.message));
|
||||
if (genAlarm && atsAlarm) {
|
||||
results.push({
|
||||
id: "gen-ats-event",
|
||||
title: "Power path event — generator + ATS",
|
||||
severity: "critical",
|
||||
description: "Generator and ATS alarms are co-active. Possible utility failure with generator transfer in progress.",
|
||||
alarmIds: [genAlarm.id, atsAlarm.id],
|
||||
});
|
||||
}
|
||||
|
||||
// Rule 4: ≥2 leak alarms → site-wide leak / pipe burst
|
||||
const leakAlarms = active.filter(a =>
|
||||
a.sensor_id ? /leak|water|flood/i.test(a.sensor_id) : /leak|water/i.test(a.message)
|
||||
);
|
||||
if (leakAlarms.length >= 2) {
|
||||
results.push({
|
||||
id: "multi-leak",
|
||||
title: "Multiple leak sensors triggered",
|
||||
severity: "critical",
|
||||
description: `${leakAlarms.length} leak sensors active. Probable cause: pipe burst, chilled water leak, or CRAC drain overflow.`,
|
||||
alarmIds: leakAlarms.map(a => a.id),
|
||||
});
|
||||
}
|
||||
|
||||
// Rule 5: VESDA + high temp in same room → fire / smoke event
|
||||
const vesdaAlarm = active.find(a => a.sensor_id ? /vesda|fire/i.test(a.sensor_id) : /fire|smoke|vesda/i.test(a.message));
|
||||
const hotRooms = new Set(active.filter(a => a.severity === "critical" && a.room_id && /temp/i.test(a.message + (a.sensor_id ?? ""))).map(a => a.room_id));
|
||||
if (vesdaAlarm && hotRooms.size > 0) {
|
||||
results.push({
|
||||
id: "fire-temp-event",
|
||||
title: "Fire / smoke event suspected",
|
||||
severity: "critical",
|
||||
description: "VESDA alarm co-active with critical temperature alarms. Possible fire or smoke event — check fire safety systems immediately.",
|
||||
alarmIds: active.filter(a => hotRooms.has(a.room_id)).map(a => a.id).concat(vesdaAlarm.id),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function RootCausePanel({ alarms }: { alarms: Alarm[] }) {
|
||||
const correlations = correlateAlarms(alarms);
|
||||
if (correlations.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card className="border-amber-500/30 bg-amber-500/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-amber-400" />
|
||||
Root Cause Analysis
|
||||
<span className="text-[10px] font-normal text-muted-foreground ml-1">
|
||||
{correlations.length} pattern{correlations.length > 1 ? "s" : ""} detected
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{correlations.map(c => (
|
||||
<div key={c.id} className={cn(
|
||||
"flex items-start gap-3 rounded-lg px-3 py-2.5 border",
|
||||
c.severity === "critical"
|
||||
? "bg-destructive/10 border-destructive/20"
|
||||
: "bg-amber-500/10 border-amber-500/20",
|
||||
)}>
|
||||
<AlertTriangle className={cn(
|
||||
"w-3.5 h-3.5 shrink-0 mt-0.5",
|
||||
c.severity === "critical" ? "text-destructive" : "text-amber-400",
|
||||
)} />
|
||||
<div className="space-y-0.5 min-w-0">
|
||||
<p className={cn(
|
||||
"text-xs font-semibold",
|
||||
c.severity === "critical" ? "text-destructive" : "text-amber-400",
|
||||
)}>
|
||||
{c.title}
|
||||
<span className="text-muted-foreground font-normal ml-2">
|
||||
({c.alarmIds.length} alarm{c.alarmIds.length !== 1 ? "s" : ""})
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">{c.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AlarmsPage() {
|
||||
const router = useRouter();
|
||||
const now = useNow(30_000);
|
||||
const [alarms, setAlarms] = useState<Alarm[]>([]);
|
||||
const [allAlarms, setAllAlarms] = useState<Alarm[]>([]);
|
||||
const [stats, setStats] = useState<AlarmStats | null>(null);
|
||||
const [stateFilter, setStateFilter] = useState<StateFilter>("active");
|
||||
const [sevFilter, setSevFilter] = useState<SeverityFilter>("all");
|
||||
const [sortKey, setSortKey] = useState<SortKey>("triggered_at");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [acting, setActing] = useState<number | null>(null);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [bulkActing, setBulkActing] = useState(false);
|
||||
const [selectedRack, setSelectedRack] = useState<string | null>(null);
|
||||
const [assignments, setAssignments] = useState<Record<number, string>>({});
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setAssignments(JSON.parse(localStorage.getItem("alarm-assignments") ?? "{}"));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
function setAssignment(id: number, assignee: string) {
|
||||
const next = { ...assignments, [id]: assignee };
|
||||
setAssignments(next);
|
||||
localStorage.setItem("alarm-assignments", JSON.stringify(next));
|
||||
}
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [a, s, all] = await Promise.all([
|
||||
fetchAlarms(SITE_ID, stateFilter),
|
||||
fetchAlarmStats(SITE_ID),
|
||||
fetchAlarms(SITE_ID, "all", 200),
|
||||
]);
|
||||
setAlarms(a);
|
||||
setStats(s);
|
||||
setAllAlarms(all);
|
||||
} catch {
|
||||
toast.error("Failed to load alarms");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
load();
|
||||
const id = setInterval(load, 15_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
// Reset page when filters change
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [stateFilter, sevFilter]);
|
||||
|
||||
async function handleAcknowledge(id: number) {
|
||||
setActing(id);
|
||||
try { await acknowledgeAlarm(id); toast.success("Alarm acknowledged"); await load(); } finally { setActing(null); }
|
||||
}
|
||||
|
||||
async function handleResolve(id: number) {
|
||||
setActing(id);
|
||||
try { await resolveAlarm(id); toast.success("Alarm resolved"); await load(); } finally { setActing(null); }
|
||||
}
|
||||
|
||||
async function handleBulkResolve() {
|
||||
setBulkActing(true);
|
||||
const count = selected.size;
|
||||
try {
|
||||
await Promise.all(Array.from(selected).map((id) => resolveAlarm(id)));
|
||||
toast.success(`${count} alarm${count !== 1 ? "s" : ""} resolved`);
|
||||
setSelected(new Set());
|
||||
await load();
|
||||
} finally { setBulkActing(false); }
|
||||
}
|
||||
|
||||
function toggleSelect(id: number) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
const resolvable = visible.filter((a) => a.state !== "resolved").map((a) => a.id);
|
||||
if (resolvable.every((id) => selected.has(id))) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(resolvable));
|
||||
}
|
||||
}
|
||||
|
||||
const sevOrder: Record<string, number> = { critical: 0, warning: 1, info: 2 };
|
||||
const stateOrder: Record<string, number> = { active: 0, acknowledged: 1, resolved: 2 };
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) setSortDir((d) => d === "asc" ? "desc" : "asc");
|
||||
else { setSortKey(key); setSortDir("desc"); }
|
||||
}
|
||||
|
||||
function SortIcon({ col }: { col: SortKey }) {
|
||||
if (sortKey !== col) return <ChevronsUpDown className="w-3 h-3 opacity-40" />;
|
||||
return sortDir === "asc" ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />;
|
||||
}
|
||||
|
||||
const visible = (sevFilter === "all" ? alarms : alarms.filter((a) => a.severity === sevFilter))
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortKey === "severity") cmp = (sevOrder[a.severity] ?? 9) - (sevOrder[b.severity] ?? 9);
|
||||
if (sortKey === "triggered_at") cmp = new Date(a.triggered_at).getTime() - new Date(b.triggered_at).getTime();
|
||||
if (sortKey === "state") cmp = (stateOrder[a.state] ?? 9) - (stateOrder[b.state] ?? 9);
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
|
||||
const pageCount = Math.ceil(visible.length / PAGE_SIZE);
|
||||
const paginated = visible.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<RackDetailSheet siteId={SITE_ID} rackId={selectedRack} onClose={() => setSelectedRack(null)} />
|
||||
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Alarms & Events</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — refreshes every 15s</p>
|
||||
</div>
|
||||
|
||||
{/* Escalation banner — longest unacknowledged critical */}
|
||||
{(() => {
|
||||
const critActive = alarms.filter(a => a.severity === "critical" && a.state === "active");
|
||||
if (critActive.length === 0) return null;
|
||||
const oldest = critActive.reduce((a, b) =>
|
||||
new Date(a.triggered_at) < new Date(b.triggered_at) ? a : b
|
||||
);
|
||||
const mins = escalationMinutes(oldest.triggered_at, now);
|
||||
const urgency = mins >= 60 ? "bg-destructive/10 border-destructive/30 text-destructive"
|
||||
: mins >= 15 ? "bg-amber-500/10 border-amber-500/30 text-amber-400"
|
||||
: "bg-amber-500/5 border-amber-500/20 text-amber-300";
|
||||
return (
|
||||
<div className={cn("flex items-center gap-3 rounded-lg border px-4 py-2.5 text-xs", urgency)}>
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>
|
||||
<strong>{critActive.length} critical alarm{critActive.length > 1 ? "s" : ""}</strong> unacknowledged
|
||||
{" — "}longest open for <strong><EscalationTimer triggeredAt={oldest.triggered_at} now={now} /></strong>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Root cause correlation panel */}
|
||||
{!loading && <RootCausePanel alarms={allAlarms} />}
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{stats ? (
|
||||
<>
|
||||
<StatCard label="Active" value={stats.active} icon={Bell} highlight />
|
||||
<AvgAgeCard alarms={allAlarms} />
|
||||
<StatCard label="Acknowledged" value={stats.acknowledged} icon={Clock} />
|
||||
<StatCard label="Resolved" value={stats.resolved} icon={CheckCircle2} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sticky filter bar */}
|
||||
<div className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm -mx-6 px-6 py-3 border-b border-border/30">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Tabs value={stateFilter} onValueChange={(v) => setStateFilter(v as StateFilter)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="active">Active</TabsTrigger>
|
||||
<TabsTrigger value="acknowledged">Acknowledged</TabsTrigger>
|
||||
<TabsTrigger value="resolved">Resolved</TabsTrigger>
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{(["all", "critical", "warning", "info"] as SeverityFilter[]).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSevFilter(s)}
|
||||
className={cn(
|
||||
"px-2.5 py-1 rounded-md text-xs font-medium capitalize transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
||||
sevFilter === s
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bulk actions inline in filter row */}
|
||||
{selected.size > 0 && (
|
||||
<div className="flex items-center gap-3 ml-auto">
|
||||
<span className="text-xs text-muted-foreground">{selected.size} selected</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs text-green-400 border-green-500/30 hover:bg-green-500/10"
|
||||
disabled={bulkActing}
|
||||
onClick={handleBulkResolve}
|
||||
>
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
Resolve selected ({selected.size})
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => setSelected(new Set())}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row count */}
|
||||
{!loading && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{visible.length} alarm{visible.length !== 1 ? "s" : ""} matching filter
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-4 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-12 w-full" />)}
|
||||
</div>
|
||||
) : visible.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-500/50" />
|
||||
<p className="text-sm">No alarms matching this filter</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground uppercase tracking-wide">
|
||||
<th className="px-4 py-3 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={visible.filter((a) => a.state !== "resolved").every((a) => selected.has(a.id))}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium">
|
||||
<button onClick={() => toggleSort("severity")} className="flex items-center gap-1 hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded">
|
||||
Severity <SortIcon col="severity" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Message</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Location</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden xl:table-cell">Sensor</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Category</th>
|
||||
<th className="text-left px-4 py-3 font-medium">
|
||||
<button onClick={() => toggleSort("state")} className="flex items-center gap-1 hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded">
|
||||
State <SortIcon col="state" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium">
|
||||
<button onClick={() => toggleSort("triggered_at")} className="flex items-center gap-1 hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded">
|
||||
Age <SortIcon col="triggered_at" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Escalation</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Assigned</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{paginated.map((alarm) => {
|
||||
const sc = stateConfig[alarm.state] ?? stateConfig.active;
|
||||
const cat = alarmCategory(alarm.sensor_id);
|
||||
return (
|
||||
<tr key={alarm.id} className={cn("hover:bg-muted/30 transition-colors", selected.has(alarm.id) && "bg-muted/20")}>
|
||||
<td className="px-4 py-3 w-8">
|
||||
{alarm.state !== "resolved" && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={selected.has(alarm.id)}
|
||||
onChange={() => toggleSelect(alarm.id)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<SeverityBadge severity={alarm.severity} />
|
||||
</td>
|
||||
<td className="px-4 py-3 max-w-xs">
|
||||
<span className="line-clamp-2">{alarm.message}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs">
|
||||
{(alarm.room_id || alarm.rack_id) ? (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{alarm.room_id && (
|
||||
<button
|
||||
onClick={() => router.push("/environmental")}
|
||||
className="text-muted-foreground hover:text-primary transition-colors underline-offset-2 hover:underline"
|
||||
>
|
||||
{alarm.room_id}
|
||||
</button>
|
||||
)}
|
||||
{alarm.room_id && alarm.rack_id && <span className="text-muted-foreground/40">/</span>}
|
||||
{alarm.rack_id && (
|
||||
<button
|
||||
onClick={() => setSelectedRack(alarm.rack_id)}
|
||||
className="text-muted-foreground hover:text-primary transition-colors underline-offset-2 hover:underline"
|
||||
>
|
||||
{alarm.rack_id}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : <span className="text-muted-foreground">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden xl:table-cell">
|
||||
{alarm.sensor_id ? (
|
||||
<span className="font-mono text-[10px] text-muted-foreground bg-muted/40 px-1.5 py-0.5 rounded">
|
||||
{alarm.sensor_id.split("/").slice(-1)[0]}
|
||||
</span>
|
||||
) : <span className="text-muted-foreground text-xs">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", cat.className)}>
|
||||
{cat.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge className={cn("text-[10px] font-semibold border-0", sc.className)}>
|
||||
{sc.label}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground text-xs whitespace-nowrap tabular-nums">
|
||||
{timeAgo(alarm.triggered_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
{alarm.state !== "resolved" && alarm.severity === "critical" ? (
|
||||
<EscalationTimer triggeredAt={alarm.triggered_at} now={now} />
|
||||
) : (
|
||||
<span className="text-muted-foreground/30 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
<select
|
||||
value={assignments[alarm.id] ?? ""}
|
||||
onChange={(e) => setAssignment(alarm.id, e.target.value)}
|
||||
className="text-[10px] bg-muted/30 border border-border rounded px-1.5 py-0.5 text-foreground/80 focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<option value="">— Unassigned</option>
|
||||
<option value="Alice T.">Alice T.</option>
|
||||
<option value="Bob K.">Bob K.</option>
|
||||
<option value="Charlie L.">Charlie L.</option>
|
||||
<option value="Dave M.">Dave M.</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{alarm.state === "active" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
disabled={acting === alarm.id}
|
||||
onClick={() => handleAcknowledge(alarm.id)}
|
||||
>
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
Ack
|
||||
</Button>
|
||||
)}
|
||||
{(alarm.state === "active" || alarm.state === "acknowledged") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs text-green-400 border-green-500/30 hover:bg-green-500/10"
|
||||
disabled={acting === alarm.id}
|
||||
onClick={() => handleResolve(alarm.id)}
|
||||
>
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pagination bar */}
|
||||
{!loading && visible.length > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
Showing {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, visible.length)} of {visible.length} alarms
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="px-2">{page} / {pageCount}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
disabled={page >= pageCount}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
703
frontend/app/(dashboard)/assets/page.tsx
Normal file
703
frontend/app/(dashboard)/assets/page.tsx
Normal file
|
|
@ -0,0 +1,703 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
fetchAssets, fetchAllDevices, fetchPduReadings,
|
||||
type AssetsData, type RackAsset, type CracAsset, type UpsAsset, type Device, type PduReading,
|
||||
} from "@/lib/api";
|
||||
import { RackDetailSheet } from "@/components/dashboard/rack-detail-sheet";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Thermometer, Zap, Wind, Battery, AlertTriangle,
|
||||
CheckCircle2, HelpCircle, LayoutGrid, List, Download,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
const roomLabels: Record<string, string> = { "hall-a": "Hall A", "hall-b": "Hall B" };
|
||||
|
||||
// ── Status helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
const statusStyles: Record<string, { dot: string; border: string }> = {
|
||||
ok: { dot: "bg-green-500", border: "border-green-500/20" },
|
||||
warning: { dot: "bg-amber-500", border: "border-amber-500/30" },
|
||||
critical: { dot: "bg-destructive", border: "border-destructive/30" },
|
||||
unknown: { dot: "bg-muted", border: "border-border" },
|
||||
};
|
||||
|
||||
const TYPE_STYLES: Record<string, { dot: string; label: string }> = {
|
||||
server: { dot: "bg-blue-400", label: "Server" },
|
||||
switch: { dot: "bg-green-400", label: "Switch" },
|
||||
patch_panel: { dot: "bg-slate-400", label: "Patch Panel" },
|
||||
pdu: { dot: "bg-amber-400", label: "PDU" },
|
||||
storage: { dot: "bg-purple-400", label: "Storage" },
|
||||
firewall: { dot: "bg-red-400", label: "Firewall" },
|
||||
kvm: { dot: "bg-teal-400", label: "KVM" },
|
||||
};
|
||||
|
||||
// ── Compact CRAC row ──────────────────────────────────────────────────────────
|
||||
|
||||
function CracRow({ crac }: { crac: CracAsset }) {
|
||||
const online = crac.state === "online";
|
||||
const fault = crac.state === "fault";
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 text-xs">
|
||||
<Wind className="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<span className="font-semibold font-mono w-20 shrink-0">{crac.crac_id.toUpperCase()}</span>
|
||||
<span className={cn(
|
||||
"flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded-full uppercase tracking-wide shrink-0",
|
||||
fault ? "bg-destructive/10 text-destructive" :
|
||||
online ? "bg-green-500/10 text-green-400" :
|
||||
"bg-muted text-muted-foreground",
|
||||
)}>
|
||||
{fault ? <AlertTriangle className="w-2.5 h-2.5" /> :
|
||||
online ? <CheckCircle2 className="w-2.5 h-2.5" /> :
|
||||
<HelpCircle className="w-2.5 h-2.5" />}
|
||||
{fault ? "Fault" : online ? "Online" : "Unk"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">Supply: <span className="text-foreground font-medium">{crac.supply_temp !== null ? `${crac.supply_temp}°C` : "—"}</span></span>
|
||||
<span className="text-muted-foreground">Return: <span className="text-foreground font-medium">{crac.return_temp !== null ? `${crac.return_temp}°C` : "—"}</span></span>
|
||||
<span className="text-muted-foreground">Fan: <span className="text-foreground font-medium">{crac.fan_pct !== null ? `${crac.fan_pct}%` : "—"}</span></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Compact UPS row ───────────────────────────────────────────────────────────
|
||||
|
||||
function UpsRow({ ups }: { ups: UpsAsset }) {
|
||||
const onBattery = ups.state === "battery";
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 text-xs">
|
||||
<Battery className="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<span className="font-semibold font-mono w-20 shrink-0">{ups.ups_id.toUpperCase()}</span>
|
||||
<span className={cn(
|
||||
"flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded-full uppercase tracking-wide shrink-0",
|
||||
onBattery ? "bg-amber-500/10 text-amber-400" :
|
||||
ups.state === "online" ? "bg-green-500/10 text-green-400" :
|
||||
"bg-muted text-muted-foreground",
|
||||
)}>
|
||||
{onBattery ? <AlertTriangle className="w-2.5 h-2.5" /> : <CheckCircle2 className="w-2.5 h-2.5" />}
|
||||
{onBattery ? "Battery" : ups.state === "online" ? "Mains" : "Unk"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">Charge: <span className="text-foreground font-medium">{ups.charge_pct !== null ? `${ups.charge_pct}%` : "—"}</span></span>
|
||||
<span className="text-muted-foreground">Load: <span className="text-foreground font-medium">{ups.load_pct !== null ? `${ups.load_pct}%` : "—"}</span></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Rack sortable table ───────────────────────────────────────────────────────
|
||||
|
||||
type RackSortCol = "rack_id" | "temp" | "power_kw" | "power_pct" | "alarm_count" | "status";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
function RackTable({
|
||||
racks, roomId, statusFilter, onRackClick,
|
||||
}: {
|
||||
racks: RackAsset[];
|
||||
roomId: string;
|
||||
statusFilter: "all" | "warning" | "critical";
|
||||
onRackClick: (id: string) => void;
|
||||
}) {
|
||||
const [sortCol, setSortCol] = useState<RackSortCol>("rack_id");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
|
||||
function toggleSort(col: RackSortCol) {
|
||||
if (sortCol === col) setSortDir(d => d === "asc" ? "desc" : "asc");
|
||||
else { setSortCol(col); setSortDir("asc"); }
|
||||
}
|
||||
|
||||
function SortIcon({ col }: { col: RackSortCol }) {
|
||||
if (sortCol !== col) return <span className="opacity-30 ml-0.5">↕</span>;
|
||||
return <span className="ml-0.5">{sortDir === "asc" ? "↑" : "↓"}</span>;
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const base = statusFilter === "all" ? racks : racks.filter(r => r.status === statusFilter);
|
||||
return [...base].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortCol === "temp" || sortCol === "power_kw" || sortCol === "alarm_count") {
|
||||
cmp = ((a[sortCol] ?? 0) as number) - ((b[sortCol] ?? 0) as number);
|
||||
} else if (sortCol === "power_pct") {
|
||||
const aP = a.power_kw !== null ? a.power_kw / 10 * 100 : 0;
|
||||
const bP = b.power_kw !== null ? b.power_kw / 10 * 100 : 0;
|
||||
cmp = aP - bP;
|
||||
} else {
|
||||
cmp = String(a[sortCol]).localeCompare(String(b[sortCol]));
|
||||
}
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}, [racks, statusFilter, sortCol, sortDir]);
|
||||
|
||||
type ColDef = { col: RackSortCol; label: string };
|
||||
const cols: ColDef[] = [
|
||||
{ col: "rack_id", label: "Rack ID" },
|
||||
{ col: "temp", label: "Temp (°C)" },
|
||||
{ col: "power_kw", label: "Power (kW)" },
|
||||
{ col: "power_pct", label: "Power%" },
|
||||
{ col: "alarm_count", label: "Alarms" },
|
||||
{ col: "status", label: "Status" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-muted-foreground select-none">
|
||||
{cols.map(({ col, label }) => (
|
||||
<th key={col} className="text-left px-3 py-2">
|
||||
<button
|
||||
onClick={() => toggleSort(col)}
|
||||
className="font-semibold hover:text-foreground transition-colors flex items-center gap-0.5"
|
||||
>
|
||||
{label}<SortIcon col={col} />
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
{/* Room column header */}
|
||||
<th className="text-left px-3 py-2 font-semibold text-muted-foreground">Room</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-8 text-muted-foreground">No racks matching this filter</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map(rack => {
|
||||
const powerPct = rack.power_kw !== null ? (rack.power_kw / 10) * 100 : null;
|
||||
const tempCls = rack.temp !== null
|
||||
? rack.temp >= 30 ? "text-destructive" : rack.temp >= 28 ? "text-amber-400" : ""
|
||||
: "";
|
||||
const pctCls = powerPct !== null
|
||||
? powerPct >= 85 ? "text-destructive" : powerPct >= 75 ? "text-amber-400" : ""
|
||||
: "";
|
||||
const s = statusStyles[rack.status] ?? statusStyles.unknown;
|
||||
return (
|
||||
<tr
|
||||
key={rack.rack_id}
|
||||
onClick={() => onRackClick(rack.rack_id)}
|
||||
className="border-b border-border/40 last:border-0 hover:bg-muted/20 transition-colors cursor-pointer"
|
||||
>
|
||||
<td className="px-3 py-2 font-mono font-semibold">{rack.rack_id.toUpperCase()}</td>
|
||||
<td className={cn("px-3 py-2 tabular-nums font-medium", tempCls)}>
|
||||
{rack.temp !== null ? rack.temp : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 tabular-nums text-muted-foreground">
|
||||
{rack.power_kw !== null ? rack.power_kw : "—"}
|
||||
</td>
|
||||
<td className={cn("px-3 py-2 tabular-nums font-medium", pctCls)}>
|
||||
{powerPct !== null ? `${powerPct.toFixed(0)}%` : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{rack.alarm_count > 0
|
||||
? <span className="font-bold text-destructive">{rack.alarm_count}</span>
|
||||
: <span className="text-muted-foreground">0</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn("w-2 h-2 rounded-full", s.dot)} />
|
||||
<span className="capitalize text-muted-foreground">{rack.status}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{roomLabels[roomId] ?? roomId}</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Inventory table ───────────────────────────────────────────────────────────
|
||||
|
||||
type SortCol = "name" | "type" | "rack_id" | "room_id" | "u_start" | "power_draw_w";
|
||||
|
||||
function InventoryTable({ siteId, onRackClick }: { siteId: string; onRackClick: (rackId: string) => void }) {
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
const [roomFilter, setRoomFilter] = useState<string>("all");
|
||||
const [sortCol, setSortCol] = useState<SortCol>("name");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllDevices(siteId)
|
||||
.then(setDevices)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [siteId]);
|
||||
|
||||
function toggleSort(col: SortCol) {
|
||||
if (sortCol === col) setSortDir(d => d === "asc" ? "desc" : "asc");
|
||||
else { setSortCol(col); setSortDir("asc"); }
|
||||
}
|
||||
|
||||
function SortIcon({ col }: { col: SortCol }) {
|
||||
if (sortCol !== col) return <span className="opacity-30 ml-0.5">↕</span>;
|
||||
return <span className="ml-0.5">{sortDir === "asc" ? "↑" : "↓"}</span>;
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
const base = devices.filter(d => {
|
||||
if (typeFilter !== "all" && d.type !== typeFilter) return false;
|
||||
if (roomFilter !== "all" && d.room_id !== roomFilter) return false;
|
||||
if (q && !d.name.toLowerCase().includes(q) && !d.rack_id.includes(q) && !d.ip.includes(q) && !d.serial.toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
return [...base].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortCol === "power_draw_w" || sortCol === "u_start") {
|
||||
cmp = (a[sortCol] ?? 0) - (b[sortCol] ?? 0);
|
||||
} else {
|
||||
cmp = String(a[sortCol]).localeCompare(String(b[sortCol]));
|
||||
}
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}, [devices, search, typeFilter, roomFilter, sortCol, sortDir]);
|
||||
|
||||
const totalPower = filtered.reduce((s, d) => s + d.power_draw_w, 0);
|
||||
const types = Array.from(new Set(devices.map(d => d.type))).sort();
|
||||
|
||||
function downloadCsv() {
|
||||
const headers = ["Device", "Type", "Rack", "Room", "U Start", "U Height", "IP", "Serial", "Power (W)", "Status"];
|
||||
const rows = filtered.map((d) => [
|
||||
d.name, TYPE_STYLES[d.type]?.label ?? d.type, d.rack_id.toUpperCase(),
|
||||
roomLabels[d.room_id] ?? d.room_id, d.u_start, d.u_height,
|
||||
d.ip !== "-" ? d.ip : "", d.serial, d.power_draw_w, d.status,
|
||||
]);
|
||||
const csv = [headers, ...rows]
|
||||
.map((r) => r.map((v) => `"${String(v ?? "").replace(/"/g, '""')}"`).join(","))
|
||||
.join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = Object.assign(document.createElement("a"), {
|
||||
href: url, download: `bms-inventory-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
});
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Export downloaded");
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-2 mt-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Device type legend */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
{Object.entries(TYPE_STYLES).map(([key, { dot, label }]) => (
|
||||
<div key={key} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className={cn("w-2.5 h-2.5 rounded-full shrink-0", dot)} />
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search name, rack, IP, serial…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="flex-1 min-w-48 h-8 rounded-md border border-border bg-muted/30 px-3 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => setTypeFilter(e.target.value)}
|
||||
className="h-8 rounded-md border border-border bg-muted/30 px-2 text-xs focus:outline-none"
|
||||
>
|
||||
<option value="all">All types</option>
|
||||
{types.map(t => <option key={t} value={t}>{TYPE_STYLES[t]?.label ?? t}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={roomFilter}
|
||||
onChange={e => setRoomFilter(e.target.value)}
|
||||
className="h-8 rounded-md border border-border bg-muted/30 px-2 text-xs focus:outline-none"
|
||||
>
|
||||
<option value="all">All rooms</option>
|
||||
<option value="hall-a">Hall A</option>
|
||||
<option value="hall-b">Hall B</option>
|
||||
</select>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{filtered.length} devices · {(totalPower / 1000).toFixed(1)} kW
|
||||
</span>
|
||||
<button
|
||||
onClick={downloadCsv}
|
||||
className="flex items-center gap-1.5 h-8 px-3 rounded-md border border-border bg-muted/30 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" /> Export CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-muted-foreground select-none">
|
||||
{([
|
||||
{ col: "name" as SortCol, label: "Device", cls: "text-left px-3 py-2" },
|
||||
{ col: "type" as SortCol, label: "Type", cls: "text-left px-3 py-2" },
|
||||
{ col: "rack_id" as SortCol, label: "Rack", cls: "text-left px-3 py-2" },
|
||||
{ col: "room_id" as SortCol, label: "Room", cls: "text-left px-3 py-2 hidden sm:table-cell" },
|
||||
{ col: "u_start" as SortCol, label: "U", cls: "text-left px-3 py-2 hidden md:table-cell" },
|
||||
]).map(({ col, label, cls }) => (
|
||||
<th key={col} className={cls}>
|
||||
<button
|
||||
onClick={() => toggleSort(col)}
|
||||
className="font-semibold hover:text-foreground transition-colors flex items-center gap-0.5"
|
||||
>
|
||||
{label}<SortIcon col={col} />
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
<th className="text-left px-3 py-2 font-semibold hidden md:table-cell">IP</th>
|
||||
<th className="text-right px-3 py-2">
|
||||
<button
|
||||
onClick={() => toggleSort("power_draw_w")}
|
||||
className="font-semibold hover:text-foreground transition-colors flex items-center gap-0.5 ml-auto"
|
||||
>
|
||||
Power<SortIcon col="power_draw_w" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left px-3 py-2 font-semibold">Status</th>
|
||||
<th className="text-left px-3 py-2 font-semibold hidden lg:table-cell">Lifecycle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="text-center py-8 text-muted-foreground">
|
||||
No devices match your filters.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map(d => {
|
||||
const ts = TYPE_STYLES[d.type];
|
||||
return (
|
||||
<tr key={d.device_id} className="border-b border-border/40 last:border-0 hover:bg-muted/20 transition-colors cursor-pointer" onClick={() => onRackClick(d.rack_id)}>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium truncate max-w-[180px]">{d.name}</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono">{d.serial}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full shrink-0", ts?.dot ?? "bg-muted")} />
|
||||
<span className="text-muted-foreground">{ts?.label ?? d.type}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono">{d.rack_id.toUpperCase()}</td>
|
||||
<td className="px-3 py-2 hidden sm:table-cell text-muted-foreground">
|
||||
{roomLabels[d.room_id] ?? d.room_id}
|
||||
</td>
|
||||
<td className="px-3 py-2 hidden md:table-cell text-muted-foreground font-mono">
|
||||
U{d.u_start}{d.u_height > 1 ? `–U${d.u_start + d.u_height - 1}` : ""}
|
||||
</td>
|
||||
<td className="px-3 py-2 hidden md:table-cell font-mono text-muted-foreground">
|
||||
{d.ip !== "-" ? d.ip : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-mono">{d.power_draw_w} W</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-green-500/10 text-green-400">
|
||||
● online
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 hidden lg:table-cell">
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full",
|
||||
d.status === "online" ? "bg-blue-500/10 text-blue-400" :
|
||||
d.status === "offline" ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-muted/50 text-muted-foreground"
|
||||
)}>
|
||||
{d.status === "online" ? "Active" : d.status === "offline" ? "Offline" : "Unknown"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── PDU Monitoring ────────────────────────────────────────────────────────────
|
||||
|
||||
function PduMonitoringSection({ siteId }: { siteId: string }) {
|
||||
const [pdus, setPdus] = useState<PduReading[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPduReadings(siteId)
|
||||
.then(setPdus)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
const id = setInterval(() => fetchPduReadings(siteId).then(setPdus).catch(() => {}), 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [siteId]);
|
||||
|
||||
const critical = pdus.filter(p => p.status === "critical").length;
|
||||
const warning = pdus.filter(p => p.status === "warning").length;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-amber-400" /> PDU Phase Monitoring
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2 text-[10px]">
|
||||
{critical > 0 && <span className="text-destructive font-semibold">{critical} critical</span>}
|
||||
{warning > 0 && <span className="text-amber-400 font-semibold">{warning} warning</span>}
|
||||
{critical === 0 && warning === 0 && !loading && (
|
||||
<span className="text-green-400 font-semibold">All balanced</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-4 space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-8 w-full" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30 text-muted-foreground">
|
||||
<th className="text-left px-4 py-2 font-semibold">Rack</th>
|
||||
<th className="text-left px-4 py-2 font-semibold hidden sm:table-cell">Room</th>
|
||||
<th className="text-right px-4 py-2 font-semibold">Total kW</th>
|
||||
<th className="text-right px-4 py-2 font-semibold hidden md:table-cell">Ph-A kW</th>
|
||||
<th className="text-right px-4 py-2 font-semibold hidden md:table-cell">Ph-B kW</th>
|
||||
<th className="text-right px-4 py-2 font-semibold hidden md:table-cell">Ph-C kW</th>
|
||||
<th className="text-right px-4 py-2 font-semibold hidden lg:table-cell">Ph-A A</th>
|
||||
<th className="text-right px-4 py-2 font-semibold hidden lg:table-cell">Ph-B A</th>
|
||||
<th className="text-right px-4 py-2 font-semibold hidden lg:table-cell">Ph-C A</th>
|
||||
<th className="text-right px-4 py-2 font-semibold">Imbalance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{pdus.map(p => (
|
||||
<tr key={p.rack_id} className={cn(
|
||||
"hover:bg-muted/20 transition-colors",
|
||||
p.status === "critical" && "bg-destructive/5",
|
||||
p.status === "warning" && "bg-amber-500/5",
|
||||
)}>
|
||||
<td className="px-4 py-2 font-mono font-medium">{p.rack_id.toUpperCase().replace("RACK-", "")}</td>
|
||||
<td className="px-4 py-2 hidden sm:table-cell text-muted-foreground">
|
||||
{roomLabels[p.room_id] ?? p.room_id}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums font-medium">
|
||||
{p.total_kw !== null ? p.total_kw.toFixed(2) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums hidden md:table-cell text-muted-foreground">
|
||||
{p.phase_a_kw !== null ? p.phase_a_kw.toFixed(2) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums hidden md:table-cell text-muted-foreground">
|
||||
{p.phase_b_kw !== null ? p.phase_b_kw.toFixed(2) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums hidden md:table-cell text-muted-foreground">
|
||||
{p.phase_c_kw !== null ? p.phase_c_kw.toFixed(2) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums hidden lg:table-cell text-muted-foreground">
|
||||
{p.phase_a_a !== null ? p.phase_a_a.toFixed(1) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums hidden lg:table-cell text-muted-foreground">
|
||||
{p.phase_b_a !== null ? p.phase_b_a.toFixed(1) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums hidden lg:table-cell text-muted-foreground">
|
||||
{p.phase_c_a !== null ? p.phase_c_a.toFixed(1) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums">
|
||||
{p.imbalance_pct !== null ? (
|
||||
<span className={cn(
|
||||
"font-semibold",
|
||||
p.status === "critical" ? "text-destructive" :
|
||||
p.status === "warning" ? "text-amber-400" : "text-green-400",
|
||||
)}>
|
||||
{p.imbalance_pct.toFixed(1)}%
|
||||
</span>
|
||||
) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AssetsPage() {
|
||||
const [data, setData] = useState<AssetsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [selectedRack, setSelectedRack] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "warning" | "critical">("all");
|
||||
const [view, setView] = useState<"grid" | "inventory">("grid");
|
||||
|
||||
async function load() {
|
||||
try { const d = await fetchAssets(SITE_ID); setData(d); setError(false); }
|
||||
catch { setError(true); toast.error("Failed to load asset data"); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="h-20" />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="p-6 flex items-center justify-center h-64 text-sm text-muted-foreground">
|
||||
Unable to load asset data.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultTab = data.rooms[0]?.room_id ?? "";
|
||||
const totalRacks = data.rooms.reduce((s, r) => s + r.racks.length, 0);
|
||||
const critCount = data.rooms.flatMap(r => r.racks).filter(r => r.status === "critical").length;
|
||||
const warnCount = data.rooms.flatMap(r => r.racks).filter(r => r.status === "warning").length;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Asset Registry</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Singapore DC01 · {totalRacks} racks
|
||||
{critCount > 0 && <span className="text-destructive ml-2">· {critCount} critical</span>}
|
||||
{warnCount > 0 && <span className="text-amber-400 ml-2">· {warnCount} warning</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center gap-1 rounded-lg border border-border p-1">
|
||||
<button
|
||||
onClick={() => setView("grid")}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors",
|
||||
view === "grid" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<LayoutGrid className="w-3.5 h-3.5" /> Grid
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("inventory")}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors",
|
||||
view === "inventory" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<List className="w-3.5 h-3.5" /> Inventory
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RackDetailSheet siteId={SITE_ID} rackId={selectedRack} onClose={() => setSelectedRack(null)} />
|
||||
|
||||
{view === "inventory" ? (
|
||||
<InventoryTable siteId={SITE_ID} onRackClick={setSelectedRack} />
|
||||
) : (
|
||||
<>
|
||||
{/* Compact UPS + CRAC rows */}
|
||||
<div className="rounded-lg border border-border divide-y divide-border/50">
|
||||
{data.ups_units.map(ups => <UpsRow key={ups.ups_id} ups={ups} />)}
|
||||
{data.rooms.map(room => <CracRow key={room.crac.crac_id} crac={room.crac} />)}
|
||||
</div>
|
||||
|
||||
{/* PDU phase monitoring */}
|
||||
<PduMonitoringSection siteId={SITE_ID} />
|
||||
|
||||
{/* Per-room rack table */}
|
||||
<Tabs defaultValue={defaultTab}>
|
||||
<TabsList>
|
||||
{data.rooms.map(room => (
|
||||
<TabsTrigger key={room.room_id} value={room.room_id}>
|
||||
{roomLabels[room.room_id] ?? room.room_id}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{data.rooms.map(room => {
|
||||
const rWarn = room.racks.filter(r => r.status === "warning").length;
|
||||
const rCrit = room.racks.filter(r => r.status === "critical").length;
|
||||
|
||||
return (
|
||||
<TabsContent key={room.room_id} value={room.room_id} className="space-y-4 mt-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Racks — {roomLabels[room.room_id] ?? room.room_id}
|
||||
</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
{(["all", "warning", "critical"] as const).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setStatusFilter(f)}
|
||||
className={cn(
|
||||
"px-2.5 py-1 rounded-md text-xs font-medium capitalize transition-colors",
|
||||
statusFilter === f
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{f === "all" ? `All (${room.racks.length})`
|
||||
: f === "warning" ? `Warn (${rWarn})`
|
||||
: `Crit (${rCrit})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RackTable
|
||||
racks={room.racks}
|
||||
roomId={room.room_id}
|
||||
statusFilter={statusFilter}
|
||||
onRackClick={setSelectedRack}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
596
frontend/app/(dashboard)/capacity/page.tsx
Normal file
596
frontend/app/(dashboard)/capacity/page.tsx
Normal file
|
|
@ -0,0 +1,596 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { fetchCapacitySummary, type CapacitySummary, type RoomCapacity, type RackCapacity } from "@/lib/api";
|
||||
import { PageShell } from "@/components/layout/page-shell";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ReferenceLine, ResponsiveContainer, Cell } from "recharts";
|
||||
import { Zap, Wind, Server, RefreshCw, AlertTriangle, TrendingDown, TrendingUp, Clock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
const ROOM_LABELS: Record<string, string> = { "hall-a": "Hall A", "hall-b": "Hall B" };
|
||||
|
||||
// ── Radial gauge ──────────────────────────────────────────────────────────────
|
||||
|
||||
function RadialGauge({ pct, warn, crit, headroom, unit }: { pct: number; warn: number; crit: number; headroom?: number; unit?: string }) {
|
||||
const r = 36;
|
||||
const circumference = 2 * Math.PI * r;
|
||||
const arc = circumference * 0.75; // 270° sweep
|
||||
const filled = Math.min(pct / 100, 1) * arc;
|
||||
|
||||
const color =
|
||||
pct >= crit ? "#ef4444" :
|
||||
pct >= warn ? "#f59e0b" :
|
||||
"#22c55e";
|
||||
const textColor =
|
||||
pct >= crit ? "text-destructive" :
|
||||
pct >= warn ? "text-amber-400" :
|
||||
"text-green-400";
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center py-1">
|
||||
<svg viewBox="0 0 100 100" className="w-28 h-28 -rotate-[135deg]" style={{ overflow: "visible" }}>
|
||||
{/* Track */}
|
||||
<circle
|
||||
cx="50" cy="50" r={r}
|
||||
fill="none"
|
||||
strokeWidth="9"
|
||||
className="stroke-muted"
|
||||
strokeDasharray={`${arc} ${circumference}`}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Fill */}
|
||||
<circle
|
||||
cx="50" cy="50" r={r}
|
||||
fill="none"
|
||||
strokeWidth="9"
|
||||
stroke={color}
|
||||
strokeDasharray={`${filled} ${circumference}`}
|
||||
strokeLinecap="round"
|
||||
style={{ transition: "stroke-dasharray 0.7s ease" }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute text-center pointer-events-none">
|
||||
<span className={cn("text-3xl font-bold tabular-nums leading-none", textColor)}>
|
||||
{pct.toFixed(1)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">%</span>
|
||||
{headroom !== undefined && unit !== undefined && (
|
||||
<p className="text-[9px] text-muted-foreground leading-tight mt-0.5">
|
||||
{headroom.toFixed(1)} {unit}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Capacity gauge card ────────────────────────────────────────────────────────
|
||||
|
||||
function CapacityGauge({
|
||||
label, used, capacity, unit, pct, headroom, icon: Icon, warn = 70, crit = 85,
|
||||
}: {
|
||||
label: string; used: number; capacity: number; unit: string; pct: number;
|
||||
headroom: number; icon: React.ElementType; warn?: number; crit?: number;
|
||||
}) {
|
||||
const textColor = pct >= crit ? "text-destructive" : pct >= warn ? "text-amber-400" : "text-green-400";
|
||||
const status = pct >= crit ? "Critical" : pct >= warn ? "Warning" : "OK";
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-xl border border-border bg-muted/10 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Icon className="w-4 h-4 text-primary" />
|
||||
{label}
|
||||
</div>
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase", textColor,
|
||||
pct >= crit ? "bg-destructive/10" : pct >= warn ? "bg-amber-500/10" : "bg-green-500/10"
|
||||
)}>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<RadialGauge pct={pct} warn={warn} crit={crit} headroom={headroom} unit={unit} />
|
||||
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span><strong className="text-foreground">{used.toFixed(1)}</strong> {unit} used</span>
|
||||
<span><strong className="text-foreground">{capacity.toFixed(0)}</strong> {unit} rated</span>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"rounded-lg px-3 py-2 text-xs",
|
||||
pct >= crit ? "bg-destructive/10 text-destructive" :
|
||||
pct >= warn ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-green-500/10 text-green-400"
|
||||
)}>
|
||||
{headroom.toFixed(1)} {unit} headroom remaining
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Capacity runway component ──────────────────────────────────────
|
||||
// Assumes ~0.5 kW/week average growth rate to forecast when limits are hit
|
||||
|
||||
const GROWTH_KW_WEEK = 0.5;
|
||||
const WARN_PCT = 85;
|
||||
|
||||
function RunwayCard({ rooms }: { rooms: RoomCapacity[] }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||
Capacity Runway
|
||||
<span className="text-[10px] text-muted-foreground font-normal ml-1">
|
||||
(assuming {GROWTH_KW_WEEK} kW/week growth)
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{rooms.map((room) => {
|
||||
const powerHeadroomToWarn = Math.max(0, room.power.capacity_kw * (WARN_PCT / 100) - room.power.used_kw);
|
||||
const coolHeadroomToWarn = Math.max(0, room.cooling.capacity_kw * (WARN_PCT / 100) - room.cooling.load_kw);
|
||||
const powerRunwayWeeks = Math.round(powerHeadroomToWarn / GROWTH_KW_WEEK);
|
||||
const coolRunwayWeeks = Math.round(coolHeadroomToWarn / GROWTH_KW_WEEK);
|
||||
const constrainedBy = powerRunwayWeeks <= coolRunwayWeeks ? "power" : "cooling";
|
||||
const minRunway = Math.min(powerRunwayWeeks, coolRunwayWeeks);
|
||||
|
||||
const runwayColor =
|
||||
minRunway < 4 ? "text-destructive" :
|
||||
minRunway < 12 ? "text-amber-400" :
|
||||
"text-green-400";
|
||||
|
||||
// N+1 cooling: at 1 CRAC per room, losing it means all load hits chillers/other rooms
|
||||
const n1Margin = room.cooling.capacity_kw - room.cooling.load_kw;
|
||||
const n1Ok = n1Margin > room.cooling.capacity_kw * 0.2; // 20% spare = N+1 safe
|
||||
|
||||
return (
|
||||
<div key={room.room_id} className="rounded-xl border border-border bg-muted/10 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold">{ROOM_LABELS[room.room_id] ?? room.room_id}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase",
|
||||
n1Ok ? "bg-green-500/10 text-green-400" : "bg-amber-500/10 text-amber-400",
|
||||
)}>
|
||||
{n1Ok ? "N+1 OK" : "N+1 marginal"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<TrendingUp className={cn("w-8 h-8 shrink-0", runwayColor)} />
|
||||
<div>
|
||||
<p className={cn("text-2xl font-bold tabular-nums leading-none", runwayColor)}>
|
||||
{minRunway}w
|
||||
</p>
|
||||
<p className={cn("text-xs tabular-nums text-muted-foreground leading-none mt-0.5")}>
|
||||
≈{(minRunway / 4.33).toFixed(1)}mo
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
until {WARN_PCT}% {constrainedBy} limit
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-[11px]">
|
||||
<div className={cn(
|
||||
"rounded-lg px-2.5 py-2",
|
||||
powerRunwayWeeks < 4 ? "bg-destructive/10" :
|
||||
powerRunwayWeeks < 12 ? "bg-amber-500/10" : "bg-muted/30",
|
||||
)}>
|
||||
<p className="text-muted-foreground mb-0.5">Power runway</p>
|
||||
<p className={cn(
|
||||
"font-bold",
|
||||
powerRunwayWeeks < 4 ? "text-destructive" :
|
||||
powerRunwayWeeks < 12 ? "text-amber-400" : "text-green-400",
|
||||
)}>
|
||||
{powerRunwayWeeks}w / ≈{(powerRunwayWeeks / 4.33).toFixed(1)}mo
|
||||
</p>
|
||||
<p className="text-muted-foreground">{powerHeadroomToWarn.toFixed(1)} kW free</p>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"rounded-lg px-2.5 py-2",
|
||||
coolRunwayWeeks < 4 ? "bg-destructive/10" :
|
||||
coolRunwayWeeks < 12 ? "bg-amber-500/10" : "bg-muted/30",
|
||||
)}>
|
||||
<p className="text-muted-foreground mb-0.5">Cooling runway</p>
|
||||
<p className={cn(
|
||||
"font-bold",
|
||||
coolRunwayWeeks < 4 ? "text-destructive" :
|
||||
coolRunwayWeeks < 12 ? "text-amber-400" : "text-green-400",
|
||||
)}>
|
||||
{coolRunwayWeeks}w / ≈{(coolRunwayWeeks / 4.33).toFixed(1)}mo
|
||||
</p>
|
||||
<p className="text-muted-foreground">{coolHeadroomToWarn.toFixed(1)} kW free</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Room summary strip ────────────────────────────────────────────────────────
|
||||
|
||||
function RoomSummaryStrip({ rooms }: { rooms: RoomCapacity[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{rooms.map((room) => {
|
||||
const powerPct = room.power.pct;
|
||||
const coolPct = room.cooling.pct;
|
||||
const worstPct = Math.max(powerPct, coolPct);
|
||||
const worstColor =
|
||||
worstPct >= 85 ? "border-destructive/40 bg-destructive/5" :
|
||||
worstPct >= 70 ? "border-amber-500/40 bg-amber-500/5" :
|
||||
"border-border bg-muted/10";
|
||||
|
||||
return (
|
||||
<div key={room.room_id} className={cn("rounded-xl border px-4 py-3 space-y-2", worstColor)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold">{ROOM_LABELS[room.room_id] ?? room.room_id}</span>
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase",
|
||||
worstPct >= 85 ? "bg-destructive/10 text-destructive" :
|
||||
worstPct >= 70 ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-green-500/10 text-green-400"
|
||||
)}>
|
||||
{worstPct >= 85 ? "Critical" : worstPct >= 70 ? "Warning" : "OK"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-xs">
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Power</p>
|
||||
<p className={cn("font-bold text-sm", powerPct >= 85 ? "text-destructive" : powerPct >= 70 ? "text-amber-400" : "text-green-400")}>
|
||||
{powerPct.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">{room.power.used_kw.toFixed(1)} / {room.power.capacity_kw} kW</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Cooling</p>
|
||||
<p className={cn("font-bold text-sm", coolPct >= 80 ? "text-destructive" : coolPct >= 65 ? "text-amber-400" : "text-green-400")}>
|
||||
{coolPct.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">{room.cooling.load_kw.toFixed(1)} / {room.cooling.capacity_kw} kW</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Space</p>
|
||||
<p className="font-bold text-sm text-foreground">{room.space.racks_populated} / {room.space.racks_total}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{room.space.racks_total - room.space.racks_populated} slots free</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Room capacity section ─────────────────────────────────────────────────────
|
||||
|
||||
function RoomCapacityPanel({ room, racks, config }: {
|
||||
room: RoomCapacity;
|
||||
racks: RackCapacity[];
|
||||
config: CapacitySummary["config"];
|
||||
}) {
|
||||
const roomRacks = racks.filter((r) => r.room_id === room.room_id);
|
||||
|
||||
const chartData = roomRacks
|
||||
.map((r) => ({
|
||||
rack: r.rack_id.replace("rack-", "").toUpperCase(),
|
||||
rack_id: r.rack_id,
|
||||
pct: r.power_pct ?? 0,
|
||||
kw: r.power_kw ?? 0,
|
||||
temp: r.temp,
|
||||
}))
|
||||
.sort((a, b) => b.pct - a.pct);
|
||||
|
||||
const forecastPct = Math.min(100, (chartData.reduce((s, d) => s + d.pct, 0) / Math.max(1, chartData.length)) + (GROWTH_KW_WEEK * 13 / config.rack_power_kw * 100));
|
||||
|
||||
const highLoad = roomRacks.filter((r) => (r.power_pct ?? 0) >= 75);
|
||||
const stranded = roomRacks.filter((r) => r.power_kw !== null && (r.power_pct ?? 0) < 20);
|
||||
const strandedKw = stranded.reduce((s, r) => s + ((config.rack_power_kw - (r.power_kw ?? 0))), 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<CapacityGauge
|
||||
label="Power"
|
||||
used={room.power.used_kw}
|
||||
capacity={room.power.capacity_kw}
|
||||
unit="kW"
|
||||
pct={room.power.pct}
|
||||
headroom={room.power.headroom_kw}
|
||||
icon={Zap}
|
||||
/>
|
||||
<CapacityGauge
|
||||
label="Cooling"
|
||||
used={room.cooling.load_kw}
|
||||
capacity={room.cooling.capacity_kw}
|
||||
unit="kW"
|
||||
pct={room.cooling.pct}
|
||||
headroom={room.cooling.headroom_kw}
|
||||
icon={Wind}
|
||||
warn={65}
|
||||
crit={80}
|
||||
/>
|
||||
<div className="space-y-2 rounded-xl border border-border bg-muted/10 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Server className="w-4 h-4 text-primary" /> Space
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<div className="relative w-28 h-28 flex items-center justify-center">
|
||||
<svg viewBox="0 0 100 100" className="w-28 h-28 -rotate-[135deg]" style={{ overflow: "visible" }}>
|
||||
<circle cx="50" cy="50" r="36" fill="none" strokeWidth="9" className="stroke-muted"
|
||||
strokeDasharray={`${2 * Math.PI * 36 * 0.75} ${2 * Math.PI * 36}`} strokeLinecap="round" />
|
||||
<circle cx="50" cy="50" r="36" fill="none" strokeWidth="9" stroke="oklch(0.62 0.17 212)"
|
||||
strokeDasharray={`${(room.space.pct / 100) * 2 * Math.PI * 36 * 0.75} ${2 * Math.PI * 36}`}
|
||||
strokeLinecap="round" style={{ transition: "stroke-dasharray 0.7s ease" }} />
|
||||
</svg>
|
||||
<div className="absolute text-center">
|
||||
<span className="text-2xl font-bold tabular-nums leading-none text-foreground">
|
||||
{room.space.racks_populated}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">/{room.space.racks_total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span><strong className="text-foreground">{room.space.racks_populated}</strong> active</span>
|
||||
<span><strong className="text-foreground">{room.space.racks_total - room.space.racks_populated}</strong> free</span>
|
||||
</div>
|
||||
<div className="rounded-lg px-3 py-2 text-xs bg-muted/40 text-muted-foreground">
|
||||
Each rack rated {config.rack_u_total}U / {config.rack_power_kw} kW max
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-primary" /> Per-rack Power Utilisation
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chartData.length === 0 ? (
|
||||
<div className="h-48 flex items-center justify-center text-sm text-muted-foreground">
|
||||
No rack data available
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 16, left: -10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="rack"
|
||||
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
|
||||
tickLine={false} axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
|
||||
tickLine={false} axisLine={false}
|
||||
domain={[0, 100]} tickFormatter={(v) => `${v}%`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0/9%)", borderRadius: "6px", fontSize: "12px" }}
|
||||
formatter={(v, _name, props) => [
|
||||
`${Number(v).toFixed(1)}% (${props.payload.kw.toFixed(2)} kW)`, "Power load"
|
||||
]}
|
||||
/>
|
||||
<ReferenceLine y={75} stroke="oklch(0.72 0.18 84)" strokeDasharray="4 4" strokeWidth={1}
|
||||
label={{ value: "Warn 75%", fontSize: 9, fill: "oklch(0.72 0.18 84)", position: "insideTopRight" }} />
|
||||
<ReferenceLine y={90} stroke="oklch(0.55 0.22 25)" strokeDasharray="4 4" strokeWidth={1}
|
||||
label={{ value: "Crit 90%", fontSize: 9, fill: "oklch(0.55 0.22 25)", position: "insideTopRight" }} />
|
||||
<ReferenceLine y={forecastPct} stroke="oklch(0.62 0.17 212)" strokeDasharray="6 3" strokeWidth={1.5}
|
||||
label={{ value: "90d forecast", fontSize: 9, fill: "oklch(0.62 0.17 212)", position: "insideTopLeft" }} />
|
||||
<Bar dataKey="pct" radius={[3, 3, 0, 0]}>
|
||||
{chartData.map((d) => (
|
||||
<Cell
|
||||
key={d.rack_id}
|
||||
fill={
|
||||
d.pct >= 90 ? "oklch(0.55 0.22 25)" :
|
||||
d.pct >= 75 ? "oklch(0.65 0.20 45)" :
|
||||
d.pct >= 50 ? "oklch(0.68 0.14 162)" :
|
||||
"oklch(0.62 0.17 212)"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400" /> High Load Racks
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{highLoad.length === 0 ? (
|
||||
<p className="text-sm text-green-400">All racks within normal limits</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{highLoad.sort((a, b) => (b.power_pct ?? 0) - (a.power_pct ?? 0)).map((r) => (
|
||||
<div key={r.rack_id} className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium">{r.rack_id.toUpperCase()}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{r.power_kw?.toFixed(1)} kW</span>
|
||||
<span className={cn(
|
||||
"font-bold",
|
||||
(r.power_pct ?? 0) >= 90 ? "text-destructive" : "text-amber-400"
|
||||
)}>{r.power_pct?.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<TrendingDown className="w-4 h-4 text-muted-foreground" /> Stranded Capacity
|
||||
</CardTitle>
|
||||
{stranded.length > 0 && (
|
||||
<span className="text-xs font-semibold text-amber-400">
|
||||
{strandedKw.toFixed(1)} kW recoverable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stranded.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No underutilised racks detected</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{stranded.sort((a, b) => (a.power_pct ?? 0) - (b.power_pct ?? 0)).map((r) => (
|
||||
<div key={r.rack_id} className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium">{r.rack_id.toUpperCase()}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{r.power_kw?.toFixed(1)} kW</span>
|
||||
<span className="text-muted-foreground">{r.power_pct?.toFixed(1)}% utilised</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-[10px] text-muted-foreground pt-1">
|
||||
{stranded.length} rack{stranded.length > 1 ? "s" : ""} below 20% — consider consolidation
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CapacityPage() {
|
||||
const [data, setData] = useState<CapacitySummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeRoom, setActiveRoom] = useState("hall-a");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try { setData(await fetchCapacitySummary(SITE_ID)); }
|
||||
catch { toast.error("Failed to load capacity data"); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
const sitePower = data?.rooms.reduce((s, r) => s + r.power.used_kw, 0) ?? 0;
|
||||
const siteCapacity = data?.rooms.reduce((s, r) => s + r.power.capacity_kw, 0) ?? 0;
|
||||
|
||||
return (
|
||||
<PageShell className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Capacity Planning</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — power, cooling & space headroom</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={load}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-56" />)}
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
) : !data ? (
|
||||
<div className="flex items-center justify-center h-64 text-sm text-muted-foreground">
|
||||
Unable to load capacity data.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Site summary banner */}
|
||||
<div className="rounded-xl border border-border bg-muted/10 px-5 py-3 flex items-center gap-8 text-sm flex-wrap">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Site IT load</span>
|
||||
{" "}
|
||||
<strong className="text-foreground text-base">{sitePower.toFixed(1)} kW</strong>
|
||||
<span className="text-muted-foreground"> / {siteCapacity.toFixed(0)} kW rated</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Site load</span>
|
||||
{" "}
|
||||
<strong className={cn(
|
||||
"text-base",
|
||||
(sitePower / siteCapacity * 100) >= 85 ? "text-destructive" :
|
||||
(sitePower / siteCapacity * 100) >= 70 ? "text-amber-400" : "text-green-400"
|
||||
)}>
|
||||
{(sitePower / siteCapacity * 100).toFixed(1)}%
|
||||
</strong>
|
||||
</div>
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
Capacity config: {data.config.rack_power_kw} kW/rack ·{" "}
|
||||
{data.config.crac_cooling_kw} kW CRAC ·{" "}
|
||||
{data.config.rack_u_total}U/rack
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Room comparison strip */}
|
||||
<RoomSummaryStrip rooms={data.rooms} />
|
||||
|
||||
{/* Capacity runway + N+1 */}
|
||||
<RunwayCard rooms={data.rooms} />
|
||||
|
||||
{/* Per-room detail tabs */}
|
||||
<div>
|
||||
<Tabs value={activeRoom} onValueChange={setActiveRoom}>
|
||||
<TabsList>
|
||||
{data.rooms.map((r) => (
|
||||
<TabsTrigger key={r.room_id} value={r.room_id}>
|
||||
{ROOM_LABELS[r.room_id] ?? r.room_id}
|
||||
{r.power.pct >= 85 && (
|
||||
<AlertTriangle className="w-3 h-3 ml-1.5 text-destructive" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-6">
|
||||
{data.rooms
|
||||
.filter((r) => r.room_id === activeRoom)
|
||||
.map((room) => (
|
||||
<RoomCapacityPanel
|
||||
key={room.room_id}
|
||||
room={room}
|
||||
racks={data.racks}
|
||||
config={data.config}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
610
frontend/app/(dashboard)/cooling/page.tsx
Normal file
610
frontend/app/(dashboard)/cooling/page.tsx
Normal file
|
|
@ -0,0 +1,610 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { fetchCracStatus, fetchChillerStatus, type CracStatus, type ChillerStatus } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { CracDetailSheet } from "@/components/dashboard/crac-detail-sheet";
|
||||
import {
|
||||
Wind, AlertTriangle, CheckCircle2, Zap, ChevronRight, ArrowRight, Waves, Filter,
|
||||
ChevronUp, ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
const roomLabels: Record<string, string> = { "hall-a": "Hall A", "hall-b": "Hall B" };
|
||||
|
||||
function fmt(v: number | null | undefined, dec = 1, unit = "") {
|
||||
if (v == null) return "—";
|
||||
return `${v.toFixed(dec)}${unit}`;
|
||||
}
|
||||
|
||||
function FillBar({
|
||||
value, max, color, warn, crit, height = "h-2",
|
||||
}: {
|
||||
value: number | null; max: number; color: string;
|
||||
warn?: number; crit?: number; height?: string;
|
||||
}) {
|
||||
const pct = value != null ? Math.min(100, (value / max) * 100) : 0;
|
||||
const barColor =
|
||||
crit && value != null && value >= crit ? "#ef4444" :
|
||||
warn && value != null && value >= warn ? "#f59e0b" :
|
||||
color;
|
||||
return (
|
||||
<div className={cn("rounded-full bg-muted overflow-hidden w-full", height)}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%`, backgroundColor: barColor }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiTile({ label, value, sub, warn }: {
|
||||
label: string; value: string; sub?: string; warn?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-muted/30 rounded-lg px-4 py-3 flex-1 min-w-0">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">{label}</p>
|
||||
<p className={cn("text-xl font-bold tabular-nums", warn && "text-amber-400")}>{value}</p>
|
||||
{sub && <p className="text-[10px] text-muted-foreground mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CracCard({ crac, onOpen }: { crac: CracStatus; onOpen: () => void }) {
|
||||
const [showCompressor, setShowCompressor] = useState(false);
|
||||
const online = crac.state === "online";
|
||||
|
||||
const deltaWarn = (crac.delta ?? 0) > 11;
|
||||
const deltaCrit = (crac.delta ?? 0) > 14;
|
||||
const capWarn = (crac.cooling_capacity_pct ?? 0) > 75;
|
||||
const capCrit = (crac.cooling_capacity_pct ?? 0) > 90;
|
||||
const copWarn = (crac.cop ?? 99) < 1.5;
|
||||
const filterWarn = (crac.filter_dp_pa ?? 0) > 80;
|
||||
const filterCrit = (crac.filter_dp_pa ?? 0) > 120;
|
||||
const compWarn = (crac.compressor_load_pct ?? 0) > 95;
|
||||
const hiPWarn = (crac.high_pressure_bar ?? 0) > 22;
|
||||
const loPWarn = (crac.low_pressure_bar ?? 99) < 3;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"border cursor-pointer hover:border-primary/50 transition-colors",
|
||||
!online && "border-destructive/40",
|
||||
)}
|
||||
onClick={onOpen}
|
||||
>
|
||||
{/* ── Header ───────────────────────────────────────────────── */}
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wind className={cn("w-4 h-4", online ? "text-primary" : "text-destructive")} />
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold leading-none">
|
||||
{crac.crac_id.toUpperCase()}
|
||||
</CardTitle>
|
||||
{crac.room_id && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{roomLabels[crac.room_id] ?? crac.room_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{online && (
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
|
||||
deltaCrit || capCrit ? "bg-destructive/10 text-destructive" :
|
||||
deltaWarn || capWarn || filterWarn || copWarn ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-green-500/10 text-green-400",
|
||||
)}>
|
||||
{deltaCrit || capCrit ? "Critical" : deltaWarn || capWarn || filterWarn || copWarn ? "Warning" : "Normal"}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
"flex items-center gap-1 text-[10px] font-semibold px-2.5 py-1 rounded-full uppercase tracking-wide",
|
||||
online ? "bg-green-500/10 text-green-400" : "bg-destructive/10 text-destructive",
|
||||
)}>
|
||||
{online
|
||||
? <><CheckCircle2 className="w-3 h-3" /> Online</>
|
||||
: <><AlertTriangle className="w-3 h-3" /> Fault</>}
|
||||
</span>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{!online ? (
|
||||
<div className="rounded-md px-3 py-2 text-xs bg-destructive/10 text-destructive">
|
||||
Unit offline — cooling capacity in this room is degraded.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* ── Thermal hero ─────────────────────────────────────── */}
|
||||
<div className="rounded-lg bg-muted/20 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Supply</p>
|
||||
<p className="text-3xl font-bold tabular-nums text-blue-400">
|
||||
{fmt(crac.supply_temp, 1)}°C
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center gap-1 px-4">
|
||||
<p className={cn(
|
||||
"text-base font-bold tabular-nums",
|
||||
deltaCrit ? "text-destructive" : deltaWarn ? "text-amber-400" : "text-muted-foreground",
|
||||
)}>
|
||||
ΔT {fmt(crac.delta, 1)}°C
|
||||
</p>
|
||||
<div className="flex items-center gap-1 w-full">
|
||||
<div className="flex-1 h-px bg-muted-foreground/30" />
|
||||
<ArrowRight className="w-3 h-3 text-muted-foreground/50 shrink-0" />
|
||||
<div className="flex-1 h-px bg-muted-foreground/30" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Return</p>
|
||||
<p className={cn(
|
||||
"text-3xl font-bold tabular-nums",
|
||||
deltaCrit ? "text-destructive" : deltaWarn ? "text-amber-400" : "text-orange-400",
|
||||
)}>
|
||||
{fmt(crac.return_temp, 1)}°C
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Cooling capacity ─────────────────────────────────── */}
|
||||
<div>
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">Cooling Capacity</span>
|
||||
<span className="text-xs font-mono">
|
||||
<span className={cn(capCrit ? "text-destructive" : capWarn ? "text-amber-400" : "text-foreground")}>
|
||||
{fmt(crac.cooling_capacity_kw, 1)} / {crac.rated_capacity_kw} kW
|
||||
</span>
|
||||
<span className="text-muted-foreground mx-1.5">·</span>
|
||||
<span className={cn(copWarn ? "text-amber-400" : "text-foreground")}>
|
||||
COP {fmt(crac.cop, 2)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={crac.cooling_capacity_pct} max={100} color="#34d399" warn={75} crit={90} />
|
||||
<p className={cn(
|
||||
"text-[10px] mt-1 text-right",
|
||||
capCrit ? "text-destructive" : capWarn ? "text-amber-400" : "text-muted-foreground",
|
||||
)}>
|
||||
{fmt(crac.cooling_capacity_pct, 1)}% utilised
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Fan + Filter ─────────────────────────────────────── */}
|
||||
<div className="space-y-2.5">
|
||||
<div>
|
||||
<div className="flex justify-between text-[10px] mb-1">
|
||||
<span className="text-muted-foreground">Fan</span>
|
||||
<span className="font-mono text-foreground">
|
||||
{fmt(crac.fan_pct, 1)}%
|
||||
{crac.fan_rpm != null ? ` · ${Math.round(crac.fan_rpm).toLocaleString()} rpm` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={crac.fan_pct} max={100} color="#60a5fa" height="h-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-[10px] mb-1">
|
||||
<span className="text-muted-foreground">Filter ΔP</span>
|
||||
<span className={cn(
|
||||
"font-mono",
|
||||
filterCrit ? "text-destructive" : filterWarn ? "text-amber-400" : "text-foreground",
|
||||
)}>
|
||||
{fmt(crac.filter_dp_pa, 0)} Pa
|
||||
{!filterWarn && <span className="text-green-400 ml-1.5">✓</span>}
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={crac.filter_dp_pa} max={150} color="#94a3b8" warn={80} crit={120} height="h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Compressor (collapsible) ─────────────────────────── */}
|
||||
<div className="rounded-md bg-muted/20 px-3 py-2.5 space-y-2">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowCompressor(!showCompressor); }}
|
||||
className="flex items-center justify-between w-full text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span className="uppercase tracking-wide font-semibold">Compressor</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-mono">{fmt(crac.compressor_load_pct, 1)}% · {fmt(crac.compressor_power_kw, 2)} kW</span>
|
||||
{showCompressor ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
</span>
|
||||
</button>
|
||||
{showCompressor && (
|
||||
<div className="pt-2 space-y-2">
|
||||
<FillBar value={crac.compressor_load_pct} max={100} color="#e879f9" warn={80} crit={95} height="h-1.5" />
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground pt-0.5">
|
||||
<span className={cn(hiPWarn ? "text-destructive" : "")}>
|
||||
Hi {fmt(crac.high_pressure_bar, 1)} bar
|
||||
</span>
|
||||
<span className={cn(loPWarn ? "text-destructive" : "")}>
|
||||
Lo {fmt(crac.low_pressure_bar, 2)} bar
|
||||
</span>
|
||||
<span>SH {fmt(crac.discharge_superheat_c, 1)}°C</span>
|
||||
<span>SC {fmt(crac.liquid_subcooling_c, 1)}°C</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Electrical (one line) ────────────────────────────── */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground border-t border-border/30 pt-3">
|
||||
<Zap className="w-3 h-3 shrink-0" />
|
||||
<span className="font-mono font-medium text-foreground">{fmt(crac.total_unit_power_kw, 2)} kW</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono">{fmt(crac.input_voltage_v, 0)} V</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono">{fmt(crac.input_current_a, 1)} A</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono">PF {fmt(crac.power_factor, 3)}</span>
|
||||
</div>
|
||||
|
||||
{/* ── Status banner ────────────────────────────────────── */}
|
||||
<div className={cn(
|
||||
"rounded-md px-3 py-2 text-xs",
|
||||
deltaCrit || capCrit
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: deltaWarn || capWarn || filterWarn || copWarn
|
||||
? "bg-amber-500/10 text-amber-400"
|
||||
: "bg-green-500/10 text-green-400",
|
||||
)}>
|
||||
{deltaCrit || capCrit
|
||||
? "Heat load is high — check airflow or redistribute rack density."
|
||||
: deltaWarn || capWarn
|
||||
? "Heat load is elevated — monitor for further rises."
|
||||
: filterWarn
|
||||
? "Filter requires attention — airflow may be restricted."
|
||||
: copWarn
|
||||
? "Running inefficiently — check refrigerant charge."
|
||||
: "Operating efficiently within normal parameters."}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Filter replacement estimate ────────────────────────────────────
|
||||
// Assumes ~1.2 Pa/day rate of rise — replace at 120 Pa threshold
|
||||
|
||||
const FILTER_REPLACE_PA = 120;
|
||||
const FILTER_RATE_PA_DAY = 1.2;
|
||||
|
||||
function FilterEstimate({ cracs }: { cracs: CracStatus[] }) {
|
||||
const units = cracs
|
||||
.filter((c) => c.state === "online" && c.filter_dp_pa != null)
|
||||
.map((c) => {
|
||||
const dp = c.filter_dp_pa!;
|
||||
const days = Math.max(0, Math.round((FILTER_REPLACE_PA - dp) / FILTER_RATE_PA_DAY));
|
||||
const urgent = dp >= 120;
|
||||
const warn = dp >= 80;
|
||||
return { crac_id: c.crac_id, dp, days, urgent, warn };
|
||||
})
|
||||
.sort((a, b) => a.days - b.days);
|
||||
|
||||
if (units.length === 0) return null;
|
||||
|
||||
const anyUrgent = units.some((u) => u.urgent);
|
||||
const anyWarn = units.some((u) => u.warn);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-muted-foreground" />
|
||||
Predictive Filter Replacement
|
||||
</CardTitle>
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase",
|
||||
anyUrgent ? "bg-destructive/10 text-destructive" :
|
||||
anyWarn ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-green-500/10 text-green-400",
|
||||
)}>
|
||||
{anyUrgent ? "Overdue" : anyWarn ? "Attention needed" : "All filters OK"}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{units.map((u) => (
|
||||
<div key={u.crac_id}>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="font-medium">{u.crac_id.toUpperCase()}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={cn(
|
||||
"font-mono",
|
||||
u.urgent ? "text-destructive" : u.warn ? "text-amber-400" : "text-muted-foreground",
|
||||
)}>
|
||||
{u.dp} Pa
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-[10px] px-2 py-0.5 rounded-full font-semibold",
|
||||
u.urgent ? "bg-destructive/10 text-destructive" :
|
||||
u.warn ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-green-500/10 text-green-400",
|
||||
)}>
|
||||
{u.urgent ? "Replace now" : `~${u.days}d`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full bg-muted overflow-hidden h-1.5">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.min(100, (u.dp / FILTER_REPLACE_PA) * 100)}%`,
|
||||
backgroundColor: u.urgent ? "#ef4444" : u.warn ? "#f59e0b" : "#94a3b8",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-[10px] text-muted-foreground pt-1">
|
||||
Estimated at {FILTER_RATE_PA_DAY} Pa/day increase · replace at {FILTER_REPLACE_PA} Pa threshold
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Chiller card ──────────────────────────────────────────────────
|
||||
|
||||
function ChillerCard({ chiller }: { chiller: ChillerStatus }) {
|
||||
const online = chiller.state === "online";
|
||||
const loadWarn = (chiller.cooling_load_pct ?? 0) > 80;
|
||||
|
||||
return (
|
||||
<Card className={cn("border", !online && "border-destructive/40")}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Waves className="w-4 h-4 text-blue-400" />
|
||||
{chiller.chiller_id.toUpperCase()} — Chiller Plant
|
||||
</CardTitle>
|
||||
<span className={cn("text-[10px] font-semibold px-2.5 py-1 rounded-full uppercase tracking-wide",
|
||||
online ? "bg-green-500/10 text-green-400" : "bg-destructive/10 text-destructive",
|
||||
)}>
|
||||
{online ? <><CheckCircle2 className="w-3 h-3 inline mr-0.5" /> Online</> : <><AlertTriangle className="w-3 h-3 inline mr-0.5" /> Fault</>}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!online ? (
|
||||
<div className="rounded-md px-3 py-2 text-xs bg-destructive/10 text-destructive">
|
||||
Chiller fault — CHW supply lost. CRAC/CRAH units relying on local refrigerant circuits only.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* CHW temps */}
|
||||
<div className="rounded-lg bg-muted/20 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">CHW Supply</p>
|
||||
<p className="text-2xl font-bold tabular-nums text-blue-400">{fmt(chiller.chw_supply_c, 1)}°C</p>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center gap-1 px-4">
|
||||
<p className="text-sm font-bold tabular-nums text-muted-foreground">ΔT {fmt(chiller.chw_delta_c, 1)}°C</p>
|
||||
<div className="flex items-center gap-1 w-full">
|
||||
<div className="flex-1 h-px bg-muted-foreground/30" />
|
||||
<ArrowRight className="w-3 h-3 text-muted-foreground/50 shrink-0" />
|
||||
<div className="flex-1 h-px bg-muted-foreground/30" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">CHW Return</p>
|
||||
<p className="text-2xl font-bold tabular-nums text-orange-400">{fmt(chiller.chw_return_c, 1)}°C</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Load */}
|
||||
<div>
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">Cooling Load</span>
|
||||
<span className="text-xs font-mono">
|
||||
<span className={cn(loadWarn ? "text-amber-400" : "")}>{fmt(chiller.cooling_load_kw, 1)} kW</span>
|
||||
<span className="text-muted-foreground mx-1.5">·</span>
|
||||
<span>COP {fmt(chiller.cop, 2)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={chiller.cooling_load_pct} max={100} color="#34d399" warn={80} crit={95} />
|
||||
<p className="text-[10px] mt-1 text-right text-muted-foreground">{fmt(chiller.cooling_load_pct, 1)}% load</p>
|
||||
</div>
|
||||
{/* Details */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px]">
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Flow rate</span><span className="font-mono">{fmt(chiller.flow_gpm, 0)} GPM</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Comp load</span><span className="font-mono">{fmt(chiller.compressor_load_pct, 1)}%</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Cond press</span><span className="font-mono">{fmt(chiller.condenser_pressure_bar, 2)} bar</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Evap press</span><span className="font-mono">{fmt(chiller.evaporator_pressure_bar, 2)} bar</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">CW supply</span><span className="font-mono">{fmt(chiller.cw_supply_c, 1)}°C</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">CW return</span><span className="font-mono">{fmt(chiller.cw_return_c, 1)}°C</span></div>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground border-t border-border/30 pt-2">
|
||||
Run hours: <strong className="text-foreground">{chiller.run_hours != null ? chiller.run_hours.toFixed(0) : "—"} h</strong>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CoolingPage() {
|
||||
const [cracs, setCracs] = useState<CracStatus[]>([]);
|
||||
const [chillers, setChillers] = useState<ChillerStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedCrac, setSelected] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [c, ch] = await Promise.all([
|
||||
fetchCracStatus(SITE_ID),
|
||||
fetchChillerStatus(SITE_ID).catch(() => []),
|
||||
]);
|
||||
setCracs(c);
|
||||
setChillers(ch);
|
||||
}
|
||||
catch { toast.error("Failed to load cooling data"); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
const online = cracs.filter(c => c.state === "online");
|
||||
const anyFaulted = cracs.some(c => c.state === "fault");
|
||||
const totalCoolingKw = online.reduce((s, c) => s + (c.cooling_capacity_kw ?? 0), 0);
|
||||
const totalRatedKw = cracs.reduce((s, c) => s + (c.rated_capacity_kw ?? 0), 0);
|
||||
const copUnits = online.filter(c => c.cop != null);
|
||||
const avgCop = copUnits.length > 0
|
||||
? copUnits.reduce((s, c) => s + (c.cop ?? 0), 0) / copUnits.length
|
||||
: null;
|
||||
const totalUnitPower = online.reduce((s, c) => s + (c.total_unit_power_kw ?? 0), 0);
|
||||
const totalAirflowCfm = online.reduce((s, c) => s + (c.airflow_cfm ?? 0), 0);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* ── Page header ───────────────────────────────────────────── */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Cooling Systems</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Singapore DC01 · click a unit to drill down · refreshes every 30s
|
||||
</p>
|
||||
</div>
|
||||
{!loading && (
|
||||
<span className={cn(
|
||||
"flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-full",
|
||||
anyFaulted ? "bg-destructive/10 text-destructive" : "bg-green-500/10 text-green-400",
|
||||
)}>
|
||||
{anyFaulted
|
||||
? <><AlertTriangle className="w-3.5 h-3.5" /> Cooling fault detected</>
|
||||
: <><CheckCircle2 className="w-3.5 h-3.5" /> All {cracs.length} units operational</>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Filter alert banner ───────────────────────────────────── */}
|
||||
{!loading && (() => {
|
||||
const urgent = cracs
|
||||
.filter(c => c.state === "online" && c.filter_dp_pa != null)
|
||||
.map(c => ({ id: c.crac_id, days: Math.max(0, Math.round((120 - c.filter_dp_pa!) / 1.2)) }))
|
||||
.filter(c => c.days < 14)
|
||||
.sort((a, b) => a.days - b.days);
|
||||
if (urgent.length === 0) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm">
|
||||
<Filter className="w-4 h-4 text-amber-400 shrink-0" />
|
||||
<span className="text-amber-400">
|
||||
<strong>Filter replacement due:</strong>{" "}
|
||||
{urgent.map(u => `${u.id.toUpperCase()} in ${u.days === 0 ? "now" : `~${u.days}d`}`).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* ── Fleet summary KPI cards ───────────────────────────────── */}
|
||||
{loading && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-20" />)}
|
||||
</div>
|
||||
)}
|
||||
{!loading && cracs.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Cooling Load</p>
|
||||
<p className="text-2xl font-bold tabular-nums">{totalCoolingKw.toFixed(1)} kW</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">of {totalRatedKw} kW rated</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Avg COP</p>
|
||||
<p className={cn("text-2xl font-bold tabular-nums", avgCop != null && avgCop < 1.5 && "text-amber-400")}>
|
||||
{avgCop != null ? avgCop.toFixed(2) : "—"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Unit Power Draw</p>
|
||||
<p className="text-2xl font-bold tabular-nums">{totalUnitPower.toFixed(1)} kW</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">total electrical input</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Units Online</p>
|
||||
<p className={cn("text-2xl font-bold tabular-nums", anyFaulted && "text-amber-400")}>
|
||||
{online.length} / {cracs.length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{totalAirflowCfm > 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Total Airflow</p>
|
||||
<p className="text-2xl font-bold tabular-nums">{Math.round(totalAirflowCfm).toLocaleString()}</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">CFM combined output</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Chiller plant ─────────────────────────────────────────── */}
|
||||
{(loading || chillers.length > 0) && (
|
||||
<>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">Chiller Plant</h2>
|
||||
{loading ? (
|
||||
<Skeleton className="h-56" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{chillers.map(ch => <ChillerCard key={ch.chiller_id} chiller={ch} />)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Filter health (moved before CRAC cards) ───────────────── */}
|
||||
{!loading && <FilterEstimate cracs={cracs} />}
|
||||
|
||||
{/* ── CRAC cards ────────────────────────────────────────────── */}
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">CRAC / CRAH Units</h2>
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Skeleton className="h-72" />
|
||||
<Skeleton className="h-72" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{cracs.map(crac => (
|
||||
<CracCard key={crac.crac_id} crac={crac} onOpen={() => setSelected(crac.crac_id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CracDetailSheet
|
||||
siteId={SITE_ID}
|
||||
cracId={selectedCrac}
|
||||
onClose={() => setSelected(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
246
frontend/app/(dashboard)/dashboard/page.tsx
Normal file
246
frontend/app/(dashboard)/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Zap, Thermometer, Wind, AlertTriangle, Wifi, WifiOff, Fuel, Droplets } from "lucide-react";
|
||||
import { KpiCard } from "@/components/dashboard/kpi-card";
|
||||
import { PowerTrendChart } from "@/components/dashboard/power-trend-chart";
|
||||
import { TemperatureTrendChart } from "@/components/dashboard/temperature-trend-chart";
|
||||
import { AlarmFeed } from "@/components/dashboard/alarm-feed";
|
||||
import { MiniFloorMap } from "@/components/dashboard/mini-floor-map";
|
||||
import { RackDetailSheet } from "@/components/dashboard/rack-detail-sheet";
|
||||
import {
|
||||
fetchKpis, fetchPowerHistory, fetchTempHistory,
|
||||
fetchAlarms, fetchGeneratorStatus, fetchLeakStatus,
|
||||
fetchCapacitySummary, fetchFloorLayout,
|
||||
type KpiData, type PowerBucket, type TempBucket,
|
||||
type Alarm, type GeneratorStatus, type LeakSensorStatus,
|
||||
type RackCapacity,
|
||||
} from "@/lib/api";
|
||||
import { TimeRangePicker } from "@/components/ui/time-range-picker";
|
||||
import Link from "next/link";
|
||||
import { PageShell } from "@/components/layout/page-shell";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
const KPI_INTERVAL = 15_000;
|
||||
const CHART_INTERVAL = 30_000;
|
||||
|
||||
// Fallback static data shown when the API is unreachable
|
||||
const FALLBACK_KPIS: KpiData = {
|
||||
total_power_kw: 0, pue: 0, avg_temperature: 0, active_alarms: 0,
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [kpis, setKpis] = useState<KpiData>(FALLBACK_KPIS);
|
||||
const [prevKpis, setPrevKpis] = useState<KpiData | null>(null);
|
||||
const [powerHistory, setPowerHistory] = useState<PowerBucket[]>([]);
|
||||
const [tempHistory, setTempHistory] = useState<TempBucket[]>([]);
|
||||
const [alarms, setAlarms] = useState<Alarm[]>([]);
|
||||
const [generators, setGenerators] = useState<GeneratorStatus[]>([]);
|
||||
const [leakSensors, setLeakSensors] = useState<LeakSensorStatus[]>([]);
|
||||
const [mapRacks, setMapRacks] = useState<RackCapacity[]>([]);
|
||||
const [mapLayout, setMapLayout] = useState<Record<string, { label: string; crac_id: string; rows: { label: string; racks: string[] }[] }> | null>(null);
|
||||
const [chartHours, setChartHours] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [liveError, setLiveError] = useState(false);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [selectedRack, setSelectedRack] = useState<string | null>(null);
|
||||
|
||||
const refreshKpis = useCallback(async () => {
|
||||
try {
|
||||
const [k, a, g, l, cap] = await Promise.all([
|
||||
fetchKpis(SITE_ID),
|
||||
fetchAlarms(SITE_ID),
|
||||
fetchGeneratorStatus(SITE_ID).catch(() => []),
|
||||
fetchLeakStatus(SITE_ID).catch(() => []),
|
||||
fetchCapacitySummary(SITE_ID).catch(() => null),
|
||||
]);
|
||||
setKpis((current) => {
|
||||
if (current !== FALLBACK_KPIS) setPrevKpis(current);
|
||||
return k;
|
||||
});
|
||||
setAlarms(a);
|
||||
setGenerators(g);
|
||||
setLeakSensors(l);
|
||||
if (cap) setMapRacks(cap.racks);
|
||||
setLiveError(false);
|
||||
setLastUpdated(new Date());
|
||||
} catch {
|
||||
setLiveError(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshCharts = useCallback(async () => {
|
||||
try {
|
||||
const [p, t] = await Promise.all([
|
||||
fetchPowerHistory(SITE_ID, chartHours),
|
||||
fetchTempHistory(SITE_ID, chartHours),
|
||||
]);
|
||||
setPowerHistory(p);
|
||||
setTempHistory(t);
|
||||
} catch {
|
||||
// keep previous chart data on failure
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
Promise.all([refreshKpis(), refreshCharts()]).finally(() => setLoading(false));
|
||||
fetchFloorLayout(SITE_ID)
|
||||
.then(l => setMapLayout(l as typeof mapLayout))
|
||||
.catch(() => {});
|
||||
}, [refreshKpis, refreshCharts]);
|
||||
|
||||
// Re-fetch charts when time range changes
|
||||
useEffect(() => { refreshCharts(); }, [chartHours, refreshCharts]);
|
||||
|
||||
// Polling
|
||||
useEffect(() => {
|
||||
const kpiTimer = setInterval(refreshKpis, KPI_INTERVAL);
|
||||
const chartTimer = setInterval(refreshCharts, CHART_INTERVAL);
|
||||
return () => { clearInterval(kpiTimer); clearInterval(chartTimer); };
|
||||
}, [refreshKpis, refreshCharts]);
|
||||
|
||||
function handleAlarmClick(alarm: Alarm) {
|
||||
if (alarm.rack_id) {
|
||||
setSelectedRack(alarm.rack_id);
|
||||
} else if (alarm.room_id) {
|
||||
router.push("/environmental");
|
||||
} else {
|
||||
router.push("/alarms");
|
||||
}
|
||||
}
|
||||
|
||||
// Derived KPI display values
|
||||
const alarmStatus = kpis.active_alarms === 0 ? "ok"
|
||||
: kpis.active_alarms <= 2 ? "warning" : "critical";
|
||||
|
||||
const tempStatus = kpis.avg_temperature === 0 ? "ok"
|
||||
: kpis.avg_temperature >= 28 ? "critical"
|
||||
: kpis.avg_temperature >= 25 ? "warning" : "ok";
|
||||
|
||||
// Trends vs previous poll
|
||||
const powerTrend = prevKpis ? Math.round((kpis.total_power_kw - prevKpis.total_power_kw) * 10) / 10 : null;
|
||||
const tempTrend = prevKpis ? Math.round((kpis.avg_temperature - prevKpis.avg_temperature) * 10) / 10 : null;
|
||||
const alarmTrend = prevKpis ? kpis.active_alarms - prevKpis.active_alarms : null;
|
||||
|
||||
// Generator derived
|
||||
const gen = generators[0] ?? null;
|
||||
const genFuel = gen?.fuel_pct ?? null;
|
||||
const genState = gen?.state ?? "unknown";
|
||||
const genStatus: "ok" | "warning" | "critical" =
|
||||
genState === "fault" ? "critical" :
|
||||
genState === "running" ? "warning" :
|
||||
genFuel !== null && genFuel < 25 ? "warning" : "ok";
|
||||
|
||||
// Leak derived
|
||||
const activeLeaks = leakSensors.filter(s => s.state === "detected").length;
|
||||
const leakStatus: "ok" | "warning" | "critical" = activeLeaks > 0 ? "critical" : "ok";
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<RackDetailSheet siteId="sg-01" rackId={selectedRack} onClose={() => setSelectedRack(null)} />
|
||||
{/* Live status bar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{liveError ? (
|
||||
<><WifiOff className="w-3 h-3 text-destructive" /> Live data unavailable</>
|
||||
) : (
|
||||
<><Wifi className="w-3 h-3 text-green-400" /> Live · updates every 15s</>
|
||||
)}
|
||||
</div>
|
||||
{lastUpdated && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Last updated {lastUpdated.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unified KPI grid — 3×2 on desktop */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-3">
|
||||
<KpiCard
|
||||
title="Total Power"
|
||||
value={loading ? "—" : `${kpis.total_power_kw} kW`}
|
||||
icon={Zap}
|
||||
iconColor="text-amber-400"
|
||||
status="ok"
|
||||
loading={loading}
|
||||
trend={powerTrend}
|
||||
trendLabel={powerTrend !== null ? `${powerTrend > 0 ? "+" : ""}${powerTrend} kW` : undefined}
|
||||
href="/power"
|
||||
/>
|
||||
<KpiCard
|
||||
title="PUE"
|
||||
value={loading ? "—" : kpis.pue.toFixed(2)}
|
||||
hint="Lower is better"
|
||||
icon={Wind}
|
||||
iconColor="text-primary"
|
||||
status="ok"
|
||||
loading={loading}
|
||||
href="/capacity"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Avg Temperature"
|
||||
value={loading ? "—" : `${kpis.avg_temperature}°C`}
|
||||
icon={Thermometer}
|
||||
iconColor="text-green-400"
|
||||
status={loading ? "ok" : tempStatus}
|
||||
loading={loading}
|
||||
trend={tempTrend}
|
||||
trendLabel={tempTrend !== null ? `${tempTrend > 0 ? "+" : ""}${tempTrend}°C` : undefined}
|
||||
trendInvert
|
||||
href="/environmental"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Active Alarms"
|
||||
value={loading ? "—" : String(kpis.active_alarms)}
|
||||
icon={AlertTriangle}
|
||||
iconColor="text-destructive"
|
||||
status={loading ? "ok" : alarmStatus}
|
||||
loading={loading}
|
||||
trend={alarmTrend}
|
||||
trendLabel={alarmTrend !== null ? `${alarmTrend > 0 ? "+" : ""}${alarmTrend}` : undefined}
|
||||
trendInvert
|
||||
href="/alarms"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Generator"
|
||||
value={loading ? "—" : genFuel !== null ? `${genFuel.toFixed(1)}% fuel` : "—"}
|
||||
hint={genState === "standby" ? "Standby — ready" : genState === "running" ? "Running under load" : genState === "test" ? "Test run" : genState === "fault" ? "FAULT — check generator" : "—"}
|
||||
icon={Fuel}
|
||||
iconColor={genStatus === "critical" ? "text-destructive" : genStatus === "warning" ? "text-amber-400" : "text-green-400"}
|
||||
status={loading ? "ok" : genStatus}
|
||||
loading={loading}
|
||||
href="/power"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Leak Detection"
|
||||
value={loading ? "—" : activeLeaks > 0 ? `${activeLeaks} active` : "All clear"}
|
||||
hint={activeLeaks > 0 ? "Water detected — investigate immediately" : `${leakSensors.length} sensors monitoring`}
|
||||
icon={Droplets}
|
||||
iconColor={leakStatus === "critical" ? "text-destructive" : "text-blue-400"}
|
||||
status={loading ? "ok" : leakStatus}
|
||||
loading={loading}
|
||||
href="/environmental"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Trends</p>
|
||||
<TimeRangePicker value={chartHours} onChange={setChartHours} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<PowerTrendChart data={powerHistory} loading={loading} />
|
||||
<TemperatureTrendChart data={tempHistory} loading={loading} />
|
||||
</div>
|
||||
|
||||
{/* Bottom row — 50/50 */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<MiniFloorMap layout={mapLayout} racks={mapRacks} loading={loading} />
|
||||
<AlarmFeed alarms={alarms} loading={loading} onAcknowledge={refreshKpis} onAlarmClick={handleAlarmClick} />
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
330
frontend/app/(dashboard)/energy/page.tsx
Normal file
330
frontend/app/(dashboard)/energy/page.tsx
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
fetchEnergyReport, fetchUtilityPower,
|
||||
type EnergyReport, type UtilityPower,
|
||||
} from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
AreaChart, Area, LineChart, Line,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
|
||||
} from "recharts";
|
||||
import { Zap, Leaf, RefreshCw, TrendingDown, DollarSign, Activity } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
// Singapore grid emission factor (kgCO2e/kWh) — Energy Market Authority 2023
|
||||
const GRID_EF_KG_CO2_KWH = 0.4168;
|
||||
// Approximate WUE for air-cooled DC in Singapore climate
|
||||
const WUE_EST = 1.4;
|
||||
|
||||
function KpiTile({
|
||||
label, value, sub, icon: Icon, iconClass, warn,
|
||||
}: {
|
||||
label: string; value: string; sub?: string;
|
||||
icon?: React.ElementType; iconClass?: string; warn?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-muted/10 px-4 py-4 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className={cn("w-4 h-4 shrink-0", iconClass ?? "text-primary")} />}
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">{label}</p>
|
||||
</div>
|
||||
<p className={cn("text-2xl font-bold tabular-nums leading-none", warn && "text-amber-400")}>{value}</p>
|
||||
{sub && <p className="text-[10px] text-muted-foreground">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{children}</h2>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EnergyPage() {
|
||||
const [energy, setEnergy] = useState<EnergyReport | null>(null);
|
||||
const [utility, setUtility] = useState<UtilityPower | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [e, u] = await Promise.all([
|
||||
fetchEnergyReport(SITE_ID, 30),
|
||||
fetchUtilityPower(SITE_ID).catch(() => null),
|
||||
]);
|
||||
setEnergy(e);
|
||||
setUtility(u);
|
||||
} catch { toast.error("Failed to load energy data"); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
const co2e_kg = energy ? Math.round(energy.kwh_total * GRID_EF_KG_CO2_KWH) : null;
|
||||
const co2e_t = co2e_kg ? (co2e_kg / 1000).toFixed(2) : null;
|
||||
const wue_water = energy ? (energy.kwh_total * (WUE_EST - 1)).toFixed(0) : null;
|
||||
|
||||
const itKwChart = (energy?.pue_trend ?? []).map((d) => ({
|
||||
day: new Date(d.day).toLocaleDateString("en-GB", { month: "short", day: "numeric" }),
|
||||
kw: d.avg_it_kw,
|
||||
pue: d.pue_est,
|
||||
}));
|
||||
|
||||
const avgPue30 = energy?.pue_estimated ?? null;
|
||||
const pueWarn = avgPue30 != null && avgPue30 > 1.5;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Energy & Sustainability</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — 30-day energy analysis · refreshes every 60s</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!loading && (
|
||||
<div className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full bg-green-500/10 text-green-400 font-semibold">
|
||||
<Leaf className="w-3.5 h-3.5" /> {co2e_t ? `${co2e_t} tCO₂e this month` : "—"}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={load}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Site energy banner */}
|
||||
{!loading && utility && (
|
||||
<div className="rounded-xl border border-border bg-muted/10 px-5 py-3 flex items-center gap-8 text-sm flex-wrap">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Current IT load: </span>
|
||||
<strong>{utility.total_kw.toFixed(1)} kW</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Tariff: </span>
|
||||
<strong>SGD {utility.tariff_sgd_kwh.toFixed(3)}/kWh</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Month-to-date: </span>
|
||||
<strong>{utility.kwh_month_to_date.toFixed(0)} kWh</strong>
|
||||
<span className="text-muted-foreground ml-1">
|
||||
(SGD {utility.cost_sgd_mtd.toFixed(0)})
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
Singapore · SP Group grid
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 30-day KPIs */}
|
||||
<SectionHeader>30-Day Energy Summary</SectionHeader>
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<KpiTile
|
||||
label="Total Consumption"
|
||||
value={energy ? `${energy.kwh_total.toFixed(0)} kWh` : "—"}
|
||||
sub="last 30 days"
|
||||
icon={Zap}
|
||||
iconClass="text-amber-400"
|
||||
/>
|
||||
<KpiTile
|
||||
label="Energy Cost"
|
||||
value={energy ? `SGD ${energy.cost_sgd.toFixed(0)}` : "—"}
|
||||
sub={`@ SGD ${energy?.tariff_sgd_kwh.toFixed(3) ?? "—"}/kWh`}
|
||||
icon={DollarSign}
|
||||
iconClass="text-green-400"
|
||||
/>
|
||||
<KpiTile
|
||||
label="Avg PUE"
|
||||
value={avgPue30 != null ? avgPue30.toFixed(3) : "—"}
|
||||
sub={avgPue30 != null && avgPue30 < 1.4 ? "Excellent" : avgPue30 != null && avgPue30 < 1.6 ? "Good" : "Room to improve"}
|
||||
icon={Activity}
|
||||
iconClass={pueWarn ? "text-amber-400" : "text-primary"}
|
||||
warn={pueWarn}
|
||||
/>
|
||||
<KpiTile
|
||||
label="Annual Estimate"
|
||||
value={utility ? `SGD ${(utility.cost_sgd_annual_est).toFixed(0)}` : "—"}
|
||||
sub={utility ? `${utility.kwh_annual_est.toFixed(0)} kWh/yr` : undefined}
|
||||
icon={TrendingDown}
|
||||
iconClass="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* IT Load trend */}
|
||||
{(loading || itKwChart.length > 0) && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-amber-400" />
|
||||
Daily IT Load — 30 Days
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-48" />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={itKwChart} margin={{ top: 4, right: 16, left: -10, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="itKwGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="oklch(0.78 0.17 84)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="oklch(0.78 0.17 84)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
|
||||
tickLine={false} axisLine={false}
|
||||
interval={4}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
|
||||
tickLine={false} axisLine={false}
|
||||
tickFormatter={(v) => `${v} kW`}
|
||||
domain={["auto", "auto"]}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0/9%)", borderRadius: "6px", fontSize: "12px" }}
|
||||
formatter={(v) => [`${Number(v).toFixed(1)} kW`, "IT Load"]}
|
||||
/>
|
||||
<Area type="monotone" dataKey="kw" stroke="oklch(0.78 0.17 84)" fill="url(#itKwGrad)" strokeWidth={2} dot={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* PUE trend */}
|
||||
{(loading || itKwChart.length > 0) && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-primary" />
|
||||
PUE Trend — 30 Days
|
||||
<span className="text-[10px] font-normal text-muted-foreground ml-1">(target: < 1.4)</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-48" />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={itKwChart} margin={{ top: 4, right: 16, left: -10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
|
||||
tickLine={false} axisLine={false}
|
||||
interval={4}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
|
||||
tickLine={false} axisLine={false}
|
||||
domain={[1.0, "auto"]}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0/9%)", borderRadius: "6px", fontSize: "12px" }}
|
||||
formatter={(v) => [Number(v).toFixed(3), "PUE"]}
|
||||
/>
|
||||
<ReferenceLine y={1.4} stroke="oklch(0.68 0.14 162)" strokeDasharray="4 4" strokeWidth={1}
|
||||
label={{ value: "Target 1.4", fontSize: 9, fill: "oklch(0.68 0.14 162)", position: "insideTopRight" }} />
|
||||
<ReferenceLine y={1.6} stroke="oklch(0.65 0.20 45)" strokeDasharray="4 4" strokeWidth={1}
|
||||
label={{ value: "Warn 1.6", fontSize: 9, fill: "oklch(0.65 0.20 45)", position: "insideTopRight" }} />
|
||||
<Line type="monotone" dataKey="pue" stroke="oklch(0.62 0.17 212)" strokeWidth={2} dot={false} activeDot={{ r: 3 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Sustainability */}
|
||||
<SectionHeader>Sustainability Metrics</SectionHeader>
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-24" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="rounded-xl border border-green-500/20 bg-green-500/5 px-4 py-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Leaf className="w-4 h-4 text-green-400" />
|
||||
<p className="text-xs font-semibold text-green-400 uppercase tracking-wider">Carbon Footprint</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums">{co2e_t ?? "—"} tCO₂e</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
30-day estimate · {energy?.kwh_total.toFixed(0) ?? "—"} kWh × {GRID_EF_KG_CO2_KWH} kgCO₂e/kWh
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Singapore grid emission factor (EMA 2023)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-blue-500/20 bg-blue-500/5 px-4 py-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-blue-400" />
|
||||
<p className="text-xs font-semibold text-blue-400 uppercase tracking-wider">Water Usage (WUE)</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums">{WUE_EST.toFixed(1)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Estimated WUE (L/kWh) · air-cooled DC
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Est. {wue_water ? `${Number(wue_water).toLocaleString()} L` : "—"} consumed (30d)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-primary/20 bg-primary/5 px-4 py-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingDown className="w-4 h-4 text-primary" />
|
||||
<p className="text-xs font-semibold text-primary uppercase tracking-wider">Efficiency</p>
|
||||
</div>
|
||||
<p className={cn("text-2xl font-bold tabular-nums", pueWarn ? "text-amber-400" : "text-green-400")}>
|
||||
{avgPue30?.toFixed(3) ?? "—"}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Avg PUE · {avgPue30 != null && avgPue30 < 1.4 ? "Excellent — Tier IV class" :
|
||||
avgPue30 != null && avgPue30 < 1.6 ? "Good — industry average" :
|
||||
"Above average — optimise cooling"}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
IT energy efficiency: {avgPue30 != null ? `${(1 / avgPue30 * 100).toFixed(1)}%` : "—"} of total power to IT
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reference info */}
|
||||
<div className="rounded-xl border border-border bg-muted/5 px-5 py-4 text-xs text-muted-foreground space-y-1.5">
|
||||
<p className="font-semibold text-foreground/80">Singapore Energy Context</p>
|
||||
<p>Grid emission factor: {GRID_EF_KG_CO2_KWH} kgCO₂e/kWh (EMA 2023, predominantly natural gas + growing solar)</p>
|
||||
<p>Electricity tariff: SGD {utility?.tariff_sgd_kwh.toFixed(3) ?? "0.298"}/kWh (SP Group commercial rate)</p>
|
||||
<p>BCA Green Mark: Targeting GoldPLUS certification · PUE target < 1.4</p>
|
||||
<p className="text-muted-foreground/50 text-[10px] pt-1">
|
||||
CO₂e and WUE estimates are indicative. Actual values depend on metered chilled water and cooling tower data.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
846
frontend/app/(dashboard)/environmental/page.tsx
Normal file
846
frontend/app/(dashboard)/environmental/page.tsx
Normal file
|
|
@ -0,0 +1,846 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
fetchRackEnvReadings, fetchHumidityHistory, fetchTempHistory as fetchRoomTempHistory,
|
||||
fetchCracStatus, fetchLeakStatus, fetchFireStatus, fetchParticleStatus,
|
||||
type RoomEnvReadings, type HumidityBucket, type TempBucket, type CracStatus,
|
||||
type LeakSensorStatus, type FireZoneStatus, type ParticleStatus,
|
||||
} from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useThresholds } from "@/lib/threshold-context";
|
||||
import { TimeRangePicker } from "@/components/ui/time-range-picker";
|
||||
import {
|
||||
ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, ReferenceLine, ReferenceArea,
|
||||
} from "recharts";
|
||||
import { Thermometer, Droplets, WifiOff, CheckCircle2, AlertTriangle, Flame, Wind } from "lucide-react";
|
||||
import { RackDetailSheet } from "@/components/dashboard/rack-detail-sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
const ROOM_COLORS: Record<string, { temp: string; hum: string }> = {
|
||||
"hall-a": { temp: "oklch(0.62 0.17 212)", hum: "oklch(0.55 0.18 270)" },
|
||||
"hall-b": { temp: "oklch(0.7 0.15 162)", hum: "oklch(0.60 0.15 145)" },
|
||||
};
|
||||
const roomLabels: Record<string, string> = { "hall-a": "Hall A", "hall-b": "Hall B" };
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
// ── Utility functions ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Magnus formula dew point (°C) from temperature (°C) and relative humidity (%) */
|
||||
function dewPoint(temp: number, rh: number): number {
|
||||
const gamma = Math.log(rh / 100) + (17.625 * temp) / (243.04 + temp);
|
||||
return Math.round((243.04 * gamma / (17.625 - gamma)) * 10) / 10;
|
||||
}
|
||||
|
||||
function humidityColor(hum: number | null): string {
|
||||
if (hum === null) return "oklch(0.25 0.02 265)";
|
||||
if (hum > 80) return "oklch(0.55 0.22 25)"; // critical high
|
||||
if (hum > 65) return "oklch(0.65 0.20 45)"; // warning high
|
||||
if (hum > 50) return "oklch(0.72 0.18 84)"; // elevated
|
||||
if (hum >= 30) return "oklch(0.68 0.14 162)"; // optimal
|
||||
return "oklch(0.62 0.17 212)"; // low (static risk)
|
||||
}
|
||||
|
||||
// ── Temperature heatmap ───────────────────────────────────────────────────────
|
||||
|
||||
function tempColor(temp: number | null, warn = 26, crit = 28): string {
|
||||
if (temp === null) return "oklch(0.25 0.02 265)";
|
||||
if (temp >= crit + 4) return "oklch(0.55 0.22 25)";
|
||||
if (temp >= crit) return "oklch(0.65 0.20 45)";
|
||||
if (temp >= warn) return "oklch(0.72 0.18 84)";
|
||||
if (temp >= warn - 2) return "oklch(0.78 0.14 140)";
|
||||
if (temp >= warn - 4) return "oklch(0.68 0.14 162)";
|
||||
return "oklch(0.60 0.15 212)";
|
||||
}
|
||||
|
||||
type HeatmapOverlay = "temp" | "humidity";
|
||||
|
||||
function TempHeatmap({
|
||||
rooms, onRackClick, activeRoom, tempWarn = 26, tempCrit = 28, humWarn = 65, humCrit = 80,
|
||||
}: {
|
||||
rooms: RoomEnvReadings[];
|
||||
onRackClick: (rackId: string) => void;
|
||||
activeRoom: string;
|
||||
tempWarn?: number;
|
||||
tempCrit?: number;
|
||||
humWarn?: number;
|
||||
humCrit?: number;
|
||||
}) {
|
||||
const [overlay, setOverlay] = useState<HeatmapOverlay>("temp");
|
||||
const room = rooms.find((r) => r.room_id === activeRoom);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
{overlay === "temp"
|
||||
? <Thermometer className="w-4 h-4 text-primary" />
|
||||
: <Droplets className="w-4 h-4 text-blue-400" />}
|
||||
{overlay === "temp" ? "Temperature" : "Humidity"} Heatmap
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Overlay toggle */}
|
||||
<div className="flex items-center gap-0.5 rounded-md border border-border p-0.5">
|
||||
<button
|
||||
onClick={() => setOverlay("temp")}
|
||||
aria-label="Temperature overlay"
|
||||
aria-pressed={overlay === "temp"}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
||||
overlay === "temp" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Thermometer className="w-3 h-3" /> Temp
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOverlay("humidity")}
|
||||
aria-label="Humidity overlay"
|
||||
aria-pressed={overlay === "humidity"}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
||||
overlay === "humidity" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Droplets className="w-3 h-3" /> Humidity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Callout — hottest or most humid */}
|
||||
{(() => {
|
||||
if (overlay === "temp") {
|
||||
const hottest = room?.racks.reduce((a, b) =>
|
||||
(a.temperature ?? 0) > (b.temperature ?? 0) ? a : b
|
||||
);
|
||||
if (!hottest || hottest.temperature === null) return null;
|
||||
const isHot = hottest.temperature >= tempWarn;
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-3 py-2 text-xs",
|
||||
hottest.temperature >= tempCrit ? "bg-destructive/10 text-destructive" :
|
||||
isHot ? "bg-amber-500/10 text-amber-400" : "bg-muted/40 text-muted-foreground"
|
||||
)}>
|
||||
<Thermometer className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>
|
||||
Hottest: <strong>{hottest.rack_id.toUpperCase()}</strong> at <strong>{hottest.temperature}°C</strong>
|
||||
{hottest.temperature >= tempCrit ? " — above critical threshold" : isHot ? " — above warning threshold" : " — within normal range"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const humid = room?.racks.reduce((a, b) =>
|
||||
(a.humidity ?? 0) > (b.humidity ?? 0) ? a : b
|
||||
);
|
||||
if (!humid || humid.humidity === null) return null;
|
||||
const isHigh = humid.humidity > humWarn;
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-3 py-2 text-xs",
|
||||
humid.humidity > humCrit ? "bg-destructive/10 text-destructive" :
|
||||
isHigh ? "bg-amber-500/10 text-amber-400" : "bg-muted/40 text-muted-foreground"
|
||||
)}>
|
||||
<Droplets className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>
|
||||
Highest humidity: <strong>{humid.rack_id.toUpperCase()}</strong> at <strong>{humid.humidity}%</strong>
|
||||
{humid.humidity > humCrit ? " — above critical threshold" : isHigh ? " — above warning threshold" : " — within normal range"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
|
||||
{/* Rack grid */}
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{room?.racks.map((rack) => {
|
||||
const offline = rack.temperature === null && rack.humidity === null;
|
||||
const bg = overlay === "temp" ? tempColor(rack.temperature, tempWarn, tempCrit) : humidityColor(rack.humidity);
|
||||
const mainVal = overlay === "temp"
|
||||
? (rack.temperature !== null ? `${rack.temperature}°` : null)
|
||||
: (rack.humidity !== null ? `${rack.humidity}%` : null);
|
||||
const subVal = overlay === "temp"
|
||||
? (rack.humidity !== null && rack.temperature !== null
|
||||
? `DP ${dewPoint(rack.temperature, rack.humidity)}°` : null)
|
||||
: (rack.temperature !== null ? `${rack.temperature}°C` : null);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={rack.rack_id}
|
||||
onClick={() => onRackClick(rack.rack_id)}
|
||||
className={cn(
|
||||
"relative rounded-lg p-3 flex flex-col items-center justify-center gap-0.5 min-h-[72px] transition-all cursor-pointer hover:ring-2 hover:ring-white/20",
|
||||
offline ? "hover:opacity-70" : "hover:opacity-80"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: offline ? "oklch(0.22 0.02 265)" : bg,
|
||||
backgroundImage: offline
|
||||
? "repeating-linear-gradient(45deg, transparent, transparent 4px, oklch(1 0 0 / 4%) 4px, oklch(1 0 0 / 4%) 8px)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px] font-semibold text-white/70">
|
||||
{rack.rack_id.replace("rack-", "").toUpperCase()}
|
||||
</span>
|
||||
{offline ? (
|
||||
<WifiOff className="w-3.5 h-3.5 text-white/40" />
|
||||
) : (
|
||||
<span className="text-base font-bold text-white">{mainVal ?? "—"}</span>
|
||||
)}
|
||||
{subVal && (
|
||||
<span className="text-[10px] text-white/60">{subVal}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
{overlay === "temp" ? (
|
||||
<>
|
||||
<span>Cool</span>
|
||||
{(["oklch(0.60 0.15 212)", "oklch(0.68 0.14 162)", "oklch(0.78 0.14 140)", "oklch(0.72 0.18 84)", "oklch(0.65 0.20 45)", "oklch(0.55 0.22 25)"] as string[]).map((c, i) => (
|
||||
<span key={i} className="w-6 h-3 rounded-sm inline-block" style={{ backgroundColor: c }} />
|
||||
))}
|
||||
<span>Hot</span>
|
||||
<span className="ml-auto">Warn: {tempWarn}°C | Crit: {tempCrit}°C · Tiles show dew point (DP)</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Dry</span>
|
||||
{(["oklch(0.62 0.17 212)", "oklch(0.68 0.14 162)", "oklch(0.72 0.18 84)", "oklch(0.65 0.20 45)", "oklch(0.55 0.22 25)"] as string[]).map((c, i) => (
|
||||
<span key={i} className="w-6 h-3 rounded-sm inline-block" style={{ backgroundColor: c }} />
|
||||
))}
|
||||
<span>Humid</span>
|
||||
<span className="ml-auto">Optimal: 30–65% | ASHRAE A1 max: 80%</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Dual-axis trend chart ─────────────────────────────────────────────────────
|
||||
|
||||
function EnvTrendChart({
|
||||
tempData, humData, hours, activeRoom, tempWarn = 26, tempCrit = 28, humWarn = 65,
|
||||
}: {
|
||||
tempData: TempBucket[];
|
||||
humData: HumidityBucket[];
|
||||
hours: number;
|
||||
activeRoom: string;
|
||||
tempWarn?: number;
|
||||
tempCrit?: number;
|
||||
humWarn?: number;
|
||||
}) {
|
||||
const roomIds = [...new Set(tempData.map((d) => d.room_id))].sort();
|
||||
|
||||
// Build combined rows for the active room
|
||||
type ComboRow = { time: string; temp: number | null; hum: number | null };
|
||||
const buckets = new Map<string, ComboRow>();
|
||||
|
||||
for (const d of tempData.filter((d) => d.room_id === activeRoom)) {
|
||||
const time = formatTime(d.bucket);
|
||||
if (!buckets.has(time)) buckets.set(time, { time, temp: null, hum: null });
|
||||
buckets.get(time)!.temp = d.avg_temp;
|
||||
}
|
||||
for (const d of humData.filter((d) => d.room_id === activeRoom)) {
|
||||
const time = formatTime(d.bucket);
|
||||
if (!buckets.has(time)) buckets.set(time, { time, temp: null, hum: null });
|
||||
buckets.get(time)!.hum = d.avg_humidity;
|
||||
}
|
||||
const chartData = Array.from(buckets.values());
|
||||
const colors = ROOM_COLORS[activeRoom] ?? ROOM_COLORS["hall-a"];
|
||||
|
||||
const labelSuffix = hours <= 1 ? "1h" : hours <= 6 ? "6h" : hours <= 24 ? "24h" : "7d";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Thermometer className="w-4 h-4 text-primary" />
|
||||
<Droplets className="w-4 h-4 text-blue-400" />
|
||||
Temp & Humidity — last {labelSuffix}
|
||||
</CardTitle>
|
||||
</div>
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-4 text-[10px] text-muted-foreground mt-1 flex-wrap">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-4 h-0.5 inline-block rounded" style={{ backgroundColor: colors.temp }} />
|
||||
Temp (°C, left axis)
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-4 h-0.5 inline-block rounded border-t-2 border-dashed" style={{ borderColor: colors.hum }} />
|
||||
Humidity (%, right axis)
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 ml-auto">
|
||||
<span className="w-4 h-3 inline-block rounded opacity-40" style={{ backgroundColor: "#22c55e" }} />
|
||||
ASHRAE A1 safe zone
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chartData.length === 0 ? (
|
||||
<div className="h-[240px] flex items-center justify-center text-sm text-muted-foreground">
|
||||
Waiting for data...
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<ComposedChart data={chartData} margin={{ top: 4, right: 30, left: -16, bottom: 0 }}>
|
||||
{/* ASHRAE A1 safe zones */}
|
||||
<ReferenceArea yAxisId="temp" y1={18} y2={27} fill="#22c55e" fillOpacity={0.07} />
|
||||
<ReferenceArea yAxisId="hum" y1={20} y2={80} fill="#3b82f6" fillOpacity={0.05} />
|
||||
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
{/* Left axis — temperature */}
|
||||
<YAxis
|
||||
yAxisId="temp"
|
||||
domain={[16, 36]}
|
||||
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) => `${v}°`}
|
||||
/>
|
||||
{/* Right axis — humidity */}
|
||||
<YAxis
|
||||
yAxisId="hum"
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "12px" }}
|
||||
formatter={(v, name) =>
|
||||
name === "temp" ? [`${Number(v).toFixed(1)}°C`, "Temperature"] :
|
||||
[`${Number(v).toFixed(0)}%`, "Humidity"]
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Temp reference lines */}
|
||||
<ReferenceLine yAxisId="temp" y={tempWarn} stroke="oklch(0.72 0.18 84)" strokeDasharray="4 4" strokeWidth={1}
|
||||
label={{ value: `Warn ${tempWarn}°`, fontSize: 9, fill: "oklch(0.72 0.18 84)", position: "right" }} />
|
||||
<ReferenceLine yAxisId="temp" y={tempCrit} stroke="oklch(0.55 0.22 25)" strokeDasharray="4 4" strokeWidth={1}
|
||||
label={{ value: `Crit ${tempCrit}°`, fontSize: 9, fill: "oklch(0.55 0.22 25)", position: "right" }} />
|
||||
|
||||
{/* Humidity reference line */}
|
||||
<ReferenceLine yAxisId="hum" y={humWarn} stroke="oklch(0.62 0.17 212)" strokeDasharray="4 4" strokeWidth={1} />
|
||||
|
||||
{/* Lines */}
|
||||
<Line
|
||||
yAxisId="temp"
|
||||
type="monotone"
|
||||
dataKey="temp"
|
||||
stroke={colors.temp}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls
|
||||
/>
|
||||
<Line
|
||||
yAxisId="hum"
|
||||
type="monotone"
|
||||
dataKey="hum"
|
||||
stroke={colors.hum}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="6 3"
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground mt-2">
|
||||
Green shaded band = ASHRAE A1 thermal envelope (18–27°C / 20–80% RH)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ASHRAE A1 Compliance Table ────────────────────────────────────────────────
|
||||
|
||||
// ASHRAE A1: 15–32°C, 20–80% RH
|
||||
function AshraeTable({ rooms }: { rooms: RoomEnvReadings[] }) {
|
||||
const allRacks = rooms.flatMap(r =>
|
||||
r.racks.map(rack => ({ ...rack, room_id: r.room_id }))
|
||||
).filter(r => r.temperature !== null || r.humidity !== null);
|
||||
|
||||
type Issue = { type: string; detail: string };
|
||||
const rows = allRacks.map(rack => {
|
||||
const issues: Issue[] = [];
|
||||
if (rack.temperature !== null) {
|
||||
if (rack.temperature < 15) issues.push({ type: "Temp", detail: `${rack.temperature}°C — below 15°C min` });
|
||||
if (rack.temperature > 32) issues.push({ type: "Temp", detail: `${rack.temperature}°C — above 32°C max` });
|
||||
}
|
||||
if (rack.humidity !== null) {
|
||||
if (rack.humidity < 20) issues.push({ type: "RH", detail: `${rack.humidity}% — below 20% min` });
|
||||
if (rack.humidity > 80) issues.push({ type: "RH", detail: `${rack.humidity}% — above 80% max` });
|
||||
}
|
||||
const dp = rack.temperature !== null && rack.humidity !== null
|
||||
? dewPoint(rack.temperature, rack.humidity) : null;
|
||||
return { rack, issues, dp };
|
||||
});
|
||||
|
||||
const violations = rows.filter(r => r.issues.length > 0);
|
||||
const compliant = rows.filter(r => r.issues.length === 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" /> ASHRAE A1 Compliance
|
||||
</CardTitle>
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-2 py-0.5 rounded-full",
|
||||
violations.length > 0 ? "bg-destructive/10 text-destructive" : "bg-green-500/10 text-green-400"
|
||||
)}>
|
||||
{violations.length === 0 ? `All ${compliant.length} racks compliant` : `${violations.length} violation${violations.length > 1 ? "s" : ""}`}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{violations.length === 0 ? (
|
||||
<p className="text-sm text-green-400 flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
All racks within ASHRAE A1 envelope (15–32°C, 20–80% RH)
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{violations.map(({ rack, issues, dp }) => (
|
||||
<div key={rack.rack_id} className="flex items-start gap-3 rounded-lg bg-destructive/5 border border-destructive/20 px-3 py-2 text-xs">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-destructive mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<span className="font-semibold">{rack.rack_id.toUpperCase()}</span>
|
||||
<span className="text-muted-foreground ml-2">{roomLabels[rack.room_id] ?? rack.room_id}</span>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-0.5 mt-0.5 text-destructive">
|
||||
{issues.map((iss, i) => <span key={i}>{iss.type}: {iss.detail}</span>)}
|
||||
{dp !== null && <span className="text-muted-foreground">DP: {dp}°C</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-[10px] text-muted-foreground pt-1">
|
||||
ASHRAE A1 envelope: 15–32°C dry bulb, 20–80% relative humidity
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Dew Point Panel ───────────────────────────────────────────────────────────
|
||||
|
||||
function DewPointPanel({
|
||||
rooms, cracs, activeRoom,
|
||||
}: {
|
||||
rooms: RoomEnvReadings[];
|
||||
cracs: CracStatus[];
|
||||
activeRoom: string;
|
||||
}) {
|
||||
const room = rooms.find(r => r.room_id === activeRoom);
|
||||
const crac = cracs.find(c => c.room_id === activeRoom);
|
||||
const supplyTemp = crac?.supply_temp ?? null;
|
||||
|
||||
const rackDps = (room?.racks ?? [])
|
||||
.filter(r => r.temperature !== null && r.humidity !== null)
|
||||
.map(r => ({
|
||||
rack_id: r.rack_id,
|
||||
dp: dewPoint(r.temperature!, r.humidity!),
|
||||
temp: r.temperature!,
|
||||
hum: r.humidity!,
|
||||
}))
|
||||
.sort((a, b) => b.dp - a.dp);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Droplets className="w-4 h-4 text-blue-400" /> Dew Point by Rack
|
||||
</CardTitle>
|
||||
{supplyTemp !== null && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
CRAC supply: <strong className="text-blue-400">{supplyTemp}°C</strong>
|
||||
{rackDps.some(r => r.dp >= supplyTemp - 1) && (
|
||||
<span className="text-destructive ml-1">— condensation risk!</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{rackDps.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No data available</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{rackDps.map(({ rack_id, dp, temp, hum }) => {
|
||||
const nearCondensation = supplyTemp !== null && dp >= supplyTemp - 1;
|
||||
const dpColor = nearCondensation ? "text-destructive"
|
||||
: dp > 15 ? "text-amber-400" : "text-foreground";
|
||||
return (
|
||||
<div key={rack_id} className="flex items-center gap-3 text-xs">
|
||||
<span className="font-mono w-16 shrink-0 text-muted-foreground">
|
||||
{rack_id.replace("rack-", "").toUpperCase()}
|
||||
</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all", nearCondensation ? "bg-destructive" : dp > 15 ? "bg-amber-500" : "bg-blue-500")}
|
||||
style={{ width: `${Math.min(100, Math.max(0, (dp / 30) * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={cn("font-mono font-semibold w-16 text-right", dpColor)}>
|
||||
{dp}°C DP
|
||||
</span>
|
||||
<span className="text-muted-foreground w-20 text-right hidden sm:block">
|
||||
{temp}° / {hum}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-[10px] text-muted-foreground pt-1">
|
||||
Dew point approaching CRAC supply temp = condensation risk on cold surfaces
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Leak sensor panel ─────────────────────────────────────────────────────────
|
||||
|
||||
function LeakPanel({ sensors }: { sensors: LeakSensorStatus[] }) {
|
||||
const detected = sensors.filter(s => s.state === "detected");
|
||||
const anyDetected = detected.length > 0;
|
||||
|
||||
return (
|
||||
<Card className={cn("border", anyDetected && "border-destructive/50")}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Droplets className="w-4 h-4 text-blue-400" /> Water / Leak Detection
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/leak" className="text-[10px] text-primary hover:underline">View full page →</Link>
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
|
||||
anyDetected ? "bg-destructive/10 text-destructive animate-pulse" : "bg-green-500/10 text-green-400",
|
||||
)}>
|
||||
{anyDetected ? `${detected.length} leak detected` : "All clear"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{sensors.map(s => {
|
||||
const detected = s.state === "detected";
|
||||
return (
|
||||
<div key={s.sensor_id} className={cn(
|
||||
"flex items-start justify-between rounded-lg px-3 py-2 text-xs",
|
||||
detected ? "bg-destructive/10" : "bg-muted/30",
|
||||
)}>
|
||||
<div>
|
||||
<p className={cn("font-semibold", detected ? "text-destructive" : "text-foreground")}>
|
||||
{detected ? <AlertTriangle className="w-3 h-3 inline mr-1" /> : <CheckCircle2 className="w-3 h-3 inline mr-1 text-green-400" />}
|
||||
{s.sensor_id}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-0.5">
|
||||
Zone: {s.floor_zone}
|
||||
{s.under_floor ? " · under-floor" : ""}
|
||||
{s.near_crac ? " · near CRAC" : ""}
|
||||
{s.room_id ? ` · ${s.room_id}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<span className={cn("font-semibold shrink-0 ml-2", detected ? "text-destructive" : "text-green-400")}>
|
||||
{s.state === "detected" ? "DETECTED" : s.state === "clear" ? "Clear" : "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sensors.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">No sensors configured</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── VESDA / Fire panel ────────────────────────────────────────────────────────
|
||||
|
||||
const VESDA_LEVEL_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
||||
normal: { label: "Normal", color: "text-green-400", bg: "bg-green-500/10" },
|
||||
alert: { label: "Alert", color: "text-amber-400", bg: "bg-amber-500/10" },
|
||||
action: { label: "Action", color: "text-orange-400", bg: "bg-orange-500/10" },
|
||||
fire: { label: "FIRE", color: "text-destructive", bg: "bg-destructive/10" },
|
||||
};
|
||||
|
||||
function FirePanel({ zones }: { zones: FireZoneStatus[] }) {
|
||||
const elevated = zones.filter(z => z.level !== "normal");
|
||||
|
||||
return (
|
||||
<Card className={cn("border", elevated.length > 0 && elevated.some(z => z.level === "fire") && "border-destructive/50")}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Flame className="w-4 h-4 text-orange-400" /> VESDA / Smoke Detection
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/fire" className="text-[10px] text-primary hover:underline">View full page →</Link>
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
|
||||
elevated.length === 0 ? "bg-green-500/10 text-green-400" :
|
||||
zones.some(z => z.level === "fire") ? "bg-destructive/10 text-destructive animate-pulse" :
|
||||
"bg-amber-500/10 text-amber-400",
|
||||
)}>
|
||||
{elevated.length === 0 ? "All normal" : `${elevated.length} zone${elevated.length !== 1 ? "s" : ""} elevated`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{zones.map(zone => {
|
||||
const cfg = VESDA_LEVEL_CONFIG[zone.level] ?? VESDA_LEVEL_CONFIG.normal;
|
||||
return (
|
||||
<div key={zone.zone_id} className={cn("rounded-lg px-3 py-2 text-xs", cfg.bg)}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div>
|
||||
<p className="font-semibold">{zone.zone_id}</p>
|
||||
{zone.room_id && <p className="text-muted-foreground">{zone.room_id}</p>}
|
||||
</div>
|
||||
<span className={cn("font-bold text-sm uppercase", cfg.color)}>{cfg.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
Obscuration: <strong className={cn(zone.level !== "normal" ? cfg.color : "")}>{zone.obscuration_pct_m != null ? `${zone.obscuration_pct_m.toFixed(3)} %/m` : "—"}</strong>
|
||||
</span>
|
||||
<div className="flex gap-2 text-[10px]">
|
||||
{!zone.detector_1_ok && <span className="text-destructive">Det1 fault</span>}
|
||||
{!zone.detector_2_ok && <span className="text-destructive">Det2 fault</span>}
|
||||
{!zone.power_ok && <span className="text-destructive">Power fault</span>}
|
||||
{!zone.flow_ok && <span className="text-destructive">Flow fault</span>}
|
||||
{zone.detector_1_ok && zone.detector_2_ok && zone.power_ok && zone.flow_ok && (
|
||||
<span className="text-green-400">Systems OK</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{zones.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">No VESDA zones configured</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Particle count panel (ISO 14644) ──────────────────────────────────────────
|
||||
|
||||
const ISO_LABELS: Record<number, { label: string; color: string }> = {
|
||||
5: { label: "ISO 5", color: "text-green-400" },
|
||||
6: { label: "ISO 6", color: "text-green-400" },
|
||||
7: { label: "ISO 7", color: "text-green-400" },
|
||||
8: { label: "ISO 8", color: "text-amber-400" },
|
||||
9: { label: "ISO 9", color: "text-destructive" },
|
||||
};
|
||||
|
||||
const ISO8_0_5UM = 3_520_000;
|
||||
const ISO8_5UM = 29_300;
|
||||
|
||||
function ParticlePanel({ rooms }: { rooms: ParticleStatus[] }) {
|
||||
if (rooms.length === 0) return null;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Wind className="w-4 h-4 text-primary" />
|
||||
Air Quality — ISO 14644
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{rooms.map(r => {
|
||||
const cls = r.iso_class ? ISO_LABELS[r.iso_class] : null;
|
||||
const p05pct = r.particles_0_5um !== null ? Math.min(100, (r.particles_0_5um / ISO8_0_5UM) * 100) : null;
|
||||
const p5pct = r.particles_5um !== null ? Math.min(100, (r.particles_5um / ISO8_5UM) * 100) : null;
|
||||
return (
|
||||
<div key={r.room_id} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{r.room_id === "hall-a" ? "Hall A" : r.room_id === "hall-b" ? "Hall B" : r.room_id}</span>
|
||||
{cls ? (
|
||||
<span className={cn("text-xs font-semibold px-2 py-0.5 rounded-full bg-muted/40", cls.color)}>
|
||||
{cls.label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">No data</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-28 shrink-0">≥0.5 µm</span>
|
||||
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
|
||||
{p05pct !== null && (
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all", p05pct >= 100 ? "bg-destructive" : p05pct >= 70 ? "bg-amber-500" : "bg-green-500")}
|
||||
style={{ width: `${p05pct}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="w-32 text-right font-mono">
|
||||
{r.particles_0_5um !== null ? r.particles_0_5um.toLocaleString() : "—"} /m³
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-28 shrink-0">≥5 µm</span>
|
||||
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
|
||||
{p5pct !== null && (
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all", p5pct >= 100 ? "bg-destructive" : p5pct >= 70 ? "bg-amber-500" : "bg-green-500")}
|
||||
style={{ width: `${p5pct}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="w-32 text-right font-mono">
|
||||
{r.particles_5um !== null ? r.particles_5um.toLocaleString() : "—"} /m³
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-[10px] text-muted-foreground pt-1">
|
||||
DC target: ISO 8 (≤3,520,000 particles ≥0.5 µm/m³ · ≤29,300 ≥5 µm/m³)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function EnvironmentalPage() {
|
||||
const { thresholds } = useThresholds();
|
||||
const [rooms, setRooms] = useState<RoomEnvReadings[]>([]);
|
||||
const [tempHist, setTempHist] = useState<TempBucket[]>([]);
|
||||
const [humHist, setHumHist] = useState<HumidityBucket[]>([]);
|
||||
const [cracs, setCracs] = useState<CracStatus[]>([]);
|
||||
const [leakSensors, setLeak] = useState<LeakSensorStatus[]>([]);
|
||||
const [fireZones, setFire] = useState<FireZoneStatus[]>([]);
|
||||
const [particles, setParticles] = useState<ParticleStatus[]>([]);
|
||||
const [hours, setHours] = useState(6);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedRack, setSelectedRack] = useState<string | null>(null);
|
||||
const [activeRoom, setActiveRoom] = useState("hall-a");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [r, t, h, c, l, f, p] = await Promise.all([
|
||||
fetchRackEnvReadings(SITE_ID),
|
||||
fetchRoomTempHistory(SITE_ID, hours),
|
||||
fetchHumidityHistory(SITE_ID, hours),
|
||||
fetchCracStatus(SITE_ID),
|
||||
fetchLeakStatus(SITE_ID).catch(() => []),
|
||||
fetchFireStatus(SITE_ID).catch(() => []),
|
||||
fetchParticleStatus(SITE_ID).catch(() => []),
|
||||
]);
|
||||
setRooms(r);
|
||||
setTempHist(t);
|
||||
setHumHist(h);
|
||||
setCracs(c);
|
||||
setLeak(l);
|
||||
setFire(f);
|
||||
setParticles(p);
|
||||
} catch {
|
||||
toast.error("Failed to load environmental data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [hours]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Environmental Monitoring</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — refreshes every 30s</p>
|
||||
</div>
|
||||
<TimeRangePicker value={hours} onChange={setHours} />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<RackDetailSheet siteId={SITE_ID} rackId={selectedRack} onClose={() => setSelectedRack(null)} />
|
||||
|
||||
{/* Page-level room tab selector */}
|
||||
{rooms.length > 0 && (
|
||||
<Tabs value={activeRoom} onValueChange={setActiveRoom}>
|
||||
<TabsList>
|
||||
{rooms.map(r => (
|
||||
<TabsTrigger key={r.room_id} value={r.room_id} className="px-6">
|
||||
{roomLabels[r.room_id] ?? r.room_id}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{rooms.length > 0 && (
|
||||
<TempHeatmap rooms={rooms} onRackClick={setSelectedRack} activeRoom={activeRoom}
|
||||
tempWarn={thresholds.temp.warn} tempCrit={thresholds.temp.critical}
|
||||
humWarn={thresholds.humidity.warn} humCrit={thresholds.humidity.critical} />
|
||||
)}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{rooms.length > 0 && <AshraeTable rooms={rooms} />}
|
||||
{rooms.length > 0 && (
|
||||
<DewPointPanel rooms={rooms} cracs={cracs} activeRoom={activeRoom} />
|
||||
)}
|
||||
</div>
|
||||
{/* Leak + VESDA panels */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<LeakPanel sensors={leakSensors} />
|
||||
<FirePanel zones={fireZones} />
|
||||
</div>
|
||||
<EnvTrendChart tempData={tempHist} humData={humHist} hours={hours} activeRoom={activeRoom}
|
||||
tempWarn={thresholds.temp.warn} tempCrit={thresholds.temp.critical}
|
||||
humWarn={thresholds.humidity.warn} />
|
||||
<ParticlePanel rooms={particles} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
285
frontend/app/(dashboard)/fire/page.tsx
Normal file
285
frontend/app/(dashboard)/fire/page.tsx
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { fetchFireStatus, type FireZoneStatus } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Flame, RefreshCw, CheckCircle2, AlertTriangle, Zap, Wind, Activity } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
const LEVEL_CONFIG: Record<string, {
|
||||
label: string; bg: string; border: string; text: string; icon: React.ElementType; pulsing: boolean;
|
||||
}> = {
|
||||
normal: {
|
||||
label: "Normal",
|
||||
bg: "bg-green-500/10", border: "border-green-500/20", text: "text-green-400",
|
||||
icon: CheckCircle2, pulsing: false,
|
||||
},
|
||||
alert: {
|
||||
label: "Alert",
|
||||
bg: "bg-amber-500/10", border: "border-amber-500/40", text: "text-amber-400",
|
||||
icon: AlertTriangle, pulsing: false,
|
||||
},
|
||||
action: {
|
||||
label: "Action",
|
||||
bg: "bg-orange-500/10", border: "border-orange-500/40", text: "text-orange-400",
|
||||
icon: AlertTriangle, pulsing: true,
|
||||
},
|
||||
fire: {
|
||||
label: "FIRE",
|
||||
bg: "bg-destructive/10", border: "border-destructive/60", text: "text-destructive",
|
||||
icon: Flame, pulsing: true,
|
||||
},
|
||||
};
|
||||
|
||||
function ObscurationBar({ value }: { value: number | null }) {
|
||||
if (value == null) return null;
|
||||
const pct = Math.min(100, value * 20); // 0–5 %/m mapped to 0–100%
|
||||
const color = value > 3 ? "#ef4444" : value > 1.5 ? "#f59e0b" : "#94a3b8";
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between text-[10px] mb-1">
|
||||
<span className="text-muted-foreground">Obscuration</span>
|
||||
<span className="font-mono font-semibold text-xs" style={{ color }}>{value.toFixed(2)} %/m</span>
|
||||
</div>
|
||||
<div className="rounded-full bg-muted overflow-hidden h-1.5">
|
||||
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${pct}%`, backgroundColor: color }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIndicator({ label, ok, icon: Icon }: {
|
||||
label: string; ok: boolean; icon: React.ElementType;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"rounded-lg px-2.5 py-2 flex items-center gap-2 text-xs",
|
||||
ok ? "bg-green-500/10" : "bg-destructive/10",
|
||||
)}>
|
||||
<Icon className={cn("w-3.5 h-3.5 shrink-0", ok ? "text-green-400" : "text-destructive")} />
|
||||
<div>
|
||||
<p className="text-muted-foreground text-[10px]">{label}</p>
|
||||
<p className={cn("font-semibold", ok ? "text-green-400" : "text-destructive")}>
|
||||
{ok ? "OK" : "Fault"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VesdaCard({ zone }: { zone: FireZoneStatus }) {
|
||||
const level = zone.level;
|
||||
const cfg = LEVEL_CONFIG[level] ?? LEVEL_CONFIG.normal;
|
||||
const Icon = cfg.icon;
|
||||
const isAlarm = level !== "normal";
|
||||
|
||||
return (
|
||||
<Card className={cn(isAlarm ? "border-2" : "border", cfg.border, isAlarm && cfg.bg, level === "fire" && "bg-red-950/30")}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Icon className={cn("w-4 h-4", cfg.text, cfg.pulsing && "animate-pulse")} />
|
||||
{zone.zone_id.toUpperCase()}
|
||||
</CardTitle>
|
||||
<span className={cn(
|
||||
"flex items-center gap-1 text-[10px] font-bold px-2.5 py-0.5 rounded-full uppercase tracking-wide border",
|
||||
cfg.bg, cfg.border, cfg.text,
|
||||
)}>
|
||||
<Icon className={cn("w-3 h-3", cfg.pulsing && "animate-pulse")} />
|
||||
{cfg.label}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{level === "fire" && (
|
||||
<div className="rounded-lg border border-destructive/60 bg-destructive/15 px-3 py-3 text-xs text-destructive font-semibold animate-pulse">
|
||||
FIRE ALARM — Initiate evacuation and contact emergency services immediately
|
||||
</div>
|
||||
)}
|
||||
{level === "action" && (
|
||||
<div className="rounded-lg border border-orange-500/40 bg-orange-500/10 px-3 py-2.5 text-xs text-orange-400 font-medium">
|
||||
Action threshold reached — investigate smoke source immediately
|
||||
</div>
|
||||
)}
|
||||
{level === "alert" && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 text-xs text-amber-400">
|
||||
Alert level — elevated smoke particles detected, monitor closely
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ObscurationBar value={zone.obscuration_pct_m} />
|
||||
|
||||
{/* Detector status */}
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ label: "Detector 1", ok: zone.detector_1_ok },
|
||||
{ label: "Detector 2", ok: zone.detector_2_ok },
|
||||
].map(({ label, ok }) => (
|
||||
<div key={label} className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
{ok ? <CheckCircle2 className="w-3.5 h-3.5 text-green-400" /> : <AlertTriangle className="w-3.5 h-3.5 text-destructive" />}
|
||||
{label}
|
||||
</span>
|
||||
<span className={cn("font-semibold", ok ? "text-green-400" : "text-destructive")}>
|
||||
{ok ? "Online" : "Fault"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* System status */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<StatusIndicator label="Power supply" ok={zone.power_ok} icon={Zap} />
|
||||
<StatusIndicator label="Airflow" ok={zone.flow_ok} icon={Wind} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FireSafetyPage() {
|
||||
const [zones, setZones] = useState<FireZoneStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setZones(await fetchFireStatus(SITE_ID));
|
||||
} catch { toast.error("Failed to load fire safety data"); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 10_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
const fireZones = zones.filter((z) => z.level === "fire");
|
||||
const actionZones = zones.filter((z) => z.level === "action");
|
||||
const alertZones = zones.filter((z) => z.level === "alert");
|
||||
const normalZones = zones.filter((z) => z.level === "normal");
|
||||
const anyAlarm = fireZones.length + actionZones.length + alertZones.length > 0;
|
||||
|
||||
const worstLevel =
|
||||
fireZones.length > 0 ? "fire" :
|
||||
actionZones.length > 0 ? "action" :
|
||||
alertZones.length > 0 ? "alert" : "normal";
|
||||
const worstCfg = LEVEL_CONFIG[worstLevel];
|
||||
const WIcon = worstCfg.icon;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Fire & Life Safety</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — VESDA aspirating detector network · refreshes every 10s</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!loading && (
|
||||
<span className={cn(
|
||||
"flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-full border",
|
||||
worstCfg.bg, worstCfg.border, worstCfg.text,
|
||||
anyAlarm && "animate-pulse",
|
||||
)}>
|
||||
<WIcon className="w-3.5 h-3.5" />
|
||||
{anyAlarm
|
||||
? `${fireZones.length + actionZones.length + alertZones.length} zone${fireZones.length + actionZones.length + alertZones.length > 1 ? "s" : ""} in alarm`
|
||||
: `All ${zones.length} zones normal`}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={load}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System summary bar */}
|
||||
{!loading && (
|
||||
<div className="rounded-xl border border-border bg-muted/10 px-5 py-3 flex items-center gap-8 text-sm flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-primary" />
|
||||
<span className="text-muted-foreground">VESDA zones monitored:</span>
|
||||
<strong>{zones.length}</strong>
|
||||
</div>
|
||||
{[
|
||||
{ label: "Fire", count: fireZones.length, cls: "text-destructive" },
|
||||
{ label: "Action", count: actionZones.length, cls: "text-orange-400" },
|
||||
{ label: "Alert", count: alertZones.length, cls: "text-amber-400" },
|
||||
{ label: "Normal", count: normalZones.length, cls: "text-green-400" },
|
||||
].map(({ label, count, cls }) => (
|
||||
<div key={label} className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">{label}:</span>
|
||||
<strong className={cls}>{count}</strong>
|
||||
</div>
|
||||
))}
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
All detectors use VESDA aspirating smoke detection technology
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fire alarm banner */}
|
||||
{!loading && fireZones.length > 0 && (
|
||||
<div className="rounded-xl border-2 border-destructive bg-destructive/10 px-5 py-4 animate-pulse">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Flame className="w-6 h-6 text-destructive shrink-0" />
|
||||
<p className="text-base font-bold text-destructive">
|
||||
FIRE ALARM ACTIVE — {fireZones.length} zone{fireZones.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-destructive/80">
|
||||
Initiate building evacuation. Contact SCDF (995). Do not re-enter until cleared by fire services.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zone cards — alarms first */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-64" />)}
|
||||
</div>
|
||||
) : zones.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No VESDA zone data available</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{[...fireZones, ...actionZones, ...alertZones, ...normalZones].map((zone) => (
|
||||
<VesdaCard key={zone.zone_id} zone={zone} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="rounded-xl border border-border bg-muted/5 px-5 py-4">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-widest mb-3">VESDA Alert Levels</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
|
||||
{Object.entries(LEVEL_CONFIG).map(([key, cfg]) => {
|
||||
const Icon = cfg.icon;
|
||||
return (
|
||||
<div key={key} className={cn("rounded-lg border px-3 py-2.5", cfg.bg, cfg.border)}>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Icon className={cn("w-3.5 h-3.5", cfg.text)} />
|
||||
<span className={cn("font-bold uppercase", cfg.text)}>{cfg.label}</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
{key === "normal" ? "No smoke detected, system clear" :
|
||||
key === "alert" ? "Trace smoke particles, monitor" :
|
||||
key === "action" ? "Significant smoke, investigate now" :
|
||||
"Confirmed fire, evacuate immediately"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
963
frontend/app/(dashboard)/floor-map/page.tsx
Normal file
963
frontend/app/(dashboard)/floor-map/page.tsx
Normal file
|
|
@ -0,0 +1,963 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
|
||||
import {
|
||||
fetchCapacitySummary, fetchCracStatus, fetchAlarms, fetchLeakStatus,
|
||||
fetchFloorLayout, saveFloorLayout,
|
||||
type CapacitySummary, type CracStatus, type Alarm, type LeakSensorStatus,
|
||||
} from "@/lib/api";
|
||||
import { RackDetailSheet } from "@/components/dashboard/rack-detail-sheet";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Wind, WifiOff, Thermometer, Zap, CheckCircle2, AlertTriangle,
|
||||
Droplets, Cable, Flame, Snowflake, Settings2, Plus, Trash2,
|
||||
GripVertical, ChevronDown, ChevronUp,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useThresholds } from "@/lib/threshold-context";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
// ── Layout type ───────────────────────────────────────────────────────────────
|
||||
|
||||
type RowLayout = { label: string; racks: string[] };
|
||||
type RoomLayout = { label: string; crac_id: string; rows: RowLayout[] };
|
||||
type FloorLayout = Record<string, RoomLayout>;
|
||||
|
||||
const DEFAULT_LAYOUT: FloorLayout = {
|
||||
"hall-a": {
|
||||
label: "Hall A",
|
||||
crac_id: "crac-01",
|
||||
rows: [
|
||||
{ label: "Row 1", racks: Array.from({ length: 20 }, (_, i) => `SG1A01.${String(i + 1).padStart(2, "0")}`) },
|
||||
{ label: "Row 2", racks: Array.from({ length: 20 }, (_, i) => `SG1A02.${String(i + 1).padStart(2, "0")}`) },
|
||||
],
|
||||
},
|
||||
"hall-b": {
|
||||
label: "Hall B",
|
||||
crac_id: "crac-02",
|
||||
rows: [
|
||||
{ label: "Row 1", racks: Array.from({ length: 20 }, (_, i) => `SG1B01.${String(i + 1).padStart(2, "0")}`) },
|
||||
{ label: "Row 2", racks: Array.from({ length: 20 }, (_, i) => `SG1B02.${String(i + 1).padStart(2, "0")}`) },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Derive feed from row index: even index → "A", odd → "B"
|
||||
function getFeed(layout: FloorLayout, rackId: string): "A" | "B" | undefined {
|
||||
for (const room of Object.values(layout)) {
|
||||
for (let i = 0; i < room.rows.length; i++) {
|
||||
if (room.rows[i].racks.includes(rackId)) return i % 2 === 0 ? "A" : "B";
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── Colour helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function tempBg(temp: number | null, warn = 26, crit = 28) {
|
||||
if (temp === null) return "oklch(0.22 0.02 265)";
|
||||
if (temp >= crit + 4) return "oklch(0.55 0.22 25)";
|
||||
if (temp >= crit) return "oklch(0.65 0.20 45)";
|
||||
if (temp >= warn) return "oklch(0.72 0.18 84)";
|
||||
if (temp >= warn - 2) return "oklch(0.78 0.14 140)";
|
||||
if (temp >= warn - 4) return "oklch(0.68 0.14 162)";
|
||||
return "oklch(0.60 0.15 212)";
|
||||
}
|
||||
|
||||
function powerBg(pct: number | null) {
|
||||
if (pct === null) return "oklch(0.22 0.02 265)";
|
||||
if (pct >= 90) return "oklch(0.55 0.22 25)";
|
||||
if (pct >= 75) return "oklch(0.65 0.20 45)";
|
||||
if (pct >= 55) return "oklch(0.72 0.18 84)";
|
||||
if (pct >= 35) return "oklch(0.68 0.14 162)";
|
||||
return "oklch(0.60 0.15 212)";
|
||||
}
|
||||
|
||||
type Overlay = "temp" | "power" | "alarms" | "feed" | "crac";
|
||||
|
||||
function alarmBg(count: number): string {
|
||||
if (count === 0) return "oklch(0.22 0.02 265)";
|
||||
if (count >= 3) return "oklch(0.55 0.22 25)";
|
||||
if (count >= 1) return "oklch(0.65 0.20 45)";
|
||||
return "oklch(0.68 0.14 162)";
|
||||
}
|
||||
|
||||
function feedBg(feed: "A" | "B" | undefined): string {
|
||||
if (feed === "A") return "oklch(0.55 0.18 255)";
|
||||
if (feed === "B") return "oklch(0.60 0.18 40)";
|
||||
return "oklch(0.22 0.02 265)";
|
||||
}
|
||||
|
||||
const CRAC_ZONE_COLORS = [
|
||||
"oklch(0.55 0.18 255)", // blue — zone 1
|
||||
"oklch(0.60 0.18 40)", // amber — zone 2
|
||||
"oklch(0.60 0.16 145)", // teal — zone 3
|
||||
"oklch(0.58 0.18 310)", // purple — zone 4
|
||||
];
|
||||
|
||||
// ── Rack tile ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function RackTile({
|
||||
rackId, temp, powerPct, alarmCount, overlay, feed, cracColor, onClick, tempWarn = 26, tempCrit = 28,
|
||||
}: {
|
||||
rackId: string; temp: number | null; powerPct: number | null;
|
||||
alarmCount: number; overlay: Overlay; feed?: "A" | "B"; cracColor?: string; onClick: () => void;
|
||||
tempWarn?: number; tempCrit?: number;
|
||||
}) {
|
||||
const offline = temp === null && powerPct === null;
|
||||
const bg = offline ? "oklch(0.22 0.02 265)"
|
||||
: overlay === "temp" ? tempBg(temp, tempWarn, tempCrit)
|
||||
: overlay === "power" ? powerBg(powerPct)
|
||||
: overlay === "feed" ? feedBg(feed)
|
||||
: overlay === "crac" ? (cracColor ?? "oklch(0.22 0.02 265)")
|
||||
: alarmBg(alarmCount);
|
||||
|
||||
const shortId = rackId.replace("rack-", "").toUpperCase();
|
||||
const mainVal = overlay === "temp" ? (temp !== null ? `${temp}°` : null)
|
||||
: overlay === "power" ? (powerPct !== null ? `${Math.round(powerPct)}%` : null)
|
||||
: overlay === "feed" ? (feed ?? null)
|
||||
: (alarmCount > 0 ? String(alarmCount) : null);
|
||||
const subVal = overlay === "temp" ? (powerPct !== null ? `${Math.round(powerPct)}%` : null)
|
||||
: overlay === "power" ? (temp !== null ? `${temp}°C` : null)
|
||||
: overlay === "feed" ? (temp !== null ? `${temp}°C` : null)
|
||||
: (temp !== null ? `${temp}°C` : null);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={`${rackId} — ${temp ?? "—"}°C · ${powerPct != null ? Math.round(powerPct) : "—"}% load · ${alarmCount} alarm${alarmCount !== 1 ? "s" : ""}`}
|
||||
aria-label={`${rackId} — ${temp ?? "—"}°C · ${powerPct != null ? Math.round(powerPct) : "—"}% load · ${alarmCount} alarm${alarmCount !== 1 ? "s" : ""}`}
|
||||
className="group relative flex flex-col items-center justify-center gap-0.5 rounded-lg cursor-pointer select-none transition-all duration-200 hover:ring-2 hover:ring-white/40 hover:scale-105 active:scale-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
style={{
|
||||
backgroundColor: bg,
|
||||
width: 76, height: 92,
|
||||
backgroundImage: offline
|
||||
? "repeating-linear-gradient(45deg,transparent,transparent 5px,oklch(1 0 0/5%) 5px,oklch(1 0 0/5%) 10px)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<span className="text-[9px] font-bold text-white/60 tracking-widest">{shortId}</span>
|
||||
{offline ? (
|
||||
<WifiOff className="w-4 h-4 text-white/30" />
|
||||
) : overlay === "alarms" && alarmCount === 0 ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-white/40" />
|
||||
) : (
|
||||
<span className="text-[17px] font-bold text-white leading-none">{mainVal ?? "—"}</span>
|
||||
)}
|
||||
{subVal && (
|
||||
<span className="text-[9px] text-white/55 opacity-0 group-hover:opacity-100 transition-opacity">{subVal}</span>
|
||||
)}
|
||||
{overlay === "temp" && powerPct !== null && (
|
||||
<div className="absolute bottom-1.5 left-2 right-2 h-[3px] rounded-full bg-white/15 overflow-hidden">
|
||||
<div className="h-full rounded-full bg-white/50" style={{ width: `${powerPct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{overlay !== "alarms" && alarmCount > 0 && (
|
||||
<div className="absolute top-1 right-1 w-3.5 h-3.5 rounded-full bg-destructive flex items-center justify-center">
|
||||
<span className="text-[8px] font-bold text-white leading-none">{alarmCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── CRAC strip ────────────────────────────────────────────────────────────────
|
||||
|
||||
function CracStrip({ crac }: { crac: CracStatus | undefined }) {
|
||||
const online = crac?.state === "online";
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-center gap-4 rounded-lg px-4 py-2.5 border text-sm",
|
||||
online ? "bg-primary/5 border-primary/20" : "bg-destructive/5 border-destructive/20"
|
||||
)}>
|
||||
<Wind className={cn("w-4 h-4 shrink-0", online ? "text-primary" : "text-destructive")} />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-xs">{crac?.crac_id.toUpperCase() ?? "CRAC"}</span>
|
||||
<span className={cn(
|
||||
"flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase",
|
||||
online ? "bg-green-500/10 text-green-400" : "bg-destructive/10 text-destructive"
|
||||
)}>
|
||||
{online ? <CheckCircle2 className="w-3 h-3" /> : <AlertTriangle className="w-3 h-3" />}
|
||||
{online ? "Online" : "Fault"}
|
||||
</span>
|
||||
</div>
|
||||
{crac && online && (
|
||||
<div className="flex items-center gap-4 ml-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
<Thermometer className="w-3 h-3 inline mr-0.5 text-primary" />
|
||||
Supply <strong className="text-foreground">{crac.supply_temp ?? "—"}°C</strong>
|
||||
</span>
|
||||
<span>
|
||||
<Thermometer className="w-3 h-3 inline mr-0.5 text-orange-400" />
|
||||
Return <strong className="text-foreground">{crac.return_temp ?? "—"}°C</strong>
|
||||
</span>
|
||||
{crac.delta !== null && (
|
||||
<span>ΔT <strong className={cn(
|
||||
crac.delta > 14 ? "text-destructive" : crac.delta > 11 ? "text-amber-400" : "text-green-400"
|
||||
)}>+{crac.delta}°C</strong></span>
|
||||
)}
|
||||
{crac.fan_pct !== null && (
|
||||
<span>Fan <strong className="text-foreground">{crac.fan_pct}%</strong></span>
|
||||
)}
|
||||
{crac.cooling_capacity_pct !== null && (
|
||||
<span>Cap <strong className={cn(
|
||||
(crac.cooling_capacity_pct ?? 0) >= 90 ? "text-destructive" :
|
||||
(crac.cooling_capacity_pct ?? 0) >= 75 ? "text-amber-400" : "text-foreground"
|
||||
)}>{crac.cooling_capacity_pct?.toFixed(0)}%</strong></span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Room plan ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function RoomPlan({
|
||||
roomId, layout, data, cracs, overlay, alarmsByRack, onRackClick, tempWarn = 26, tempCrit = 28,
|
||||
}: {
|
||||
roomId: string;
|
||||
layout: FloorLayout;
|
||||
data: CapacitySummary;
|
||||
cracs: CracStatus[];
|
||||
overlay: Overlay;
|
||||
alarmsByRack: Map<string, number>;
|
||||
onRackClick: (id: string) => void;
|
||||
tempWarn?: number;
|
||||
tempCrit?: number;
|
||||
}) {
|
||||
const roomLayout = layout[roomId];
|
||||
if (!roomLayout) return null;
|
||||
|
||||
const rackMap = new Map(data.racks.map((r) => [r.rack_id, r]));
|
||||
const crac = cracs.find((c) => c.crac_id === roomLayout.crac_id);
|
||||
const roomRacks = data.racks.filter((r) => r.room_id === roomId);
|
||||
const offlineCount = roomRacks.filter((r) => r.temp === null && r.power_kw === null).length;
|
||||
|
||||
const avgTemp = (() => {
|
||||
const temps = roomRacks.map((r) => r.temp).filter((t): t is number => t !== null);
|
||||
return temps.length ? Math.round((temps.reduce((a, b) => a + b, 0) / temps.length) * 10) / 10 : null;
|
||||
})();
|
||||
const totalPower = (() => {
|
||||
const powers = roomRacks.map((r) => r.power_kw).filter((p): p is number => p !== null);
|
||||
return powers.length ? Math.round(powers.reduce((a, b) => a + b, 0) * 10) / 10 : null;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-6 text-xs text-muted-foreground">
|
||||
<span>{roomRacks.length} racks</span>
|
||||
{avgTemp !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Thermometer className="w-3 h-3" />
|
||||
Avg <strong className="text-foreground">{avgTemp}°C</strong>
|
||||
</span>
|
||||
)}
|
||||
{totalPower !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" />
|
||||
<strong className="text-foreground">{totalPower} kW</strong> IT load
|
||||
</span>
|
||||
)}
|
||||
{offlineCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-muted-foreground/60">
|
||||
<WifiOff className="w-3 h-3" />
|
||||
{offlineCount} offline
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TransformWrapper initialScale={1} minScale={0.4} maxScale={3} limitToBounds={false} wheel={{ disabled: true }}>
|
||||
{({ zoomIn, zoomOut, resetTransform }) => (
|
||||
<>
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<button
|
||||
onClick={() => zoomIn()}
|
||||
className="px-2 py-1 rounded border border-border text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||
title="Zoom in"
|
||||
>+</button>
|
||||
<button
|
||||
onClick={() => resetTransform()}
|
||||
className="px-2 py-1 rounded border border-border text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||
title="Reset zoom"
|
||||
>⊙</button>
|
||||
<button
|
||||
onClick={() => zoomOut()}
|
||||
className="px-2 py-1 rounded border border-border text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||
title="Zoom out"
|
||||
>−</button>
|
||||
<span className="text-[10px] text-muted-foreground/50 ml-1">Drag to pan</span>
|
||||
</div>
|
||||
<TransformComponent wrapperStyle={{ width: "100%", overflow: "hidden", borderRadius: "0.75rem" }}>
|
||||
<div className="rounded-xl border border-border bg-muted/10 p-5 space-y-3" style={{ minWidth: "100%" }}>
|
||||
<CracStrip crac={crac} />
|
||||
|
||||
<div
|
||||
className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-widest px-2 rounded-md py-2"
|
||||
style={{ backgroundColor: "oklch(0.55 0.22 25 / 6%)", color: "oklch(0.65 0.20 45)" }}
|
||||
>
|
||||
<div className="flex-1 h-px" style={{ backgroundColor: "oklch(0.65 0.20 45 / 30%)" }} />
|
||||
<Flame className="w-3 h-3 shrink-0" style={{ color: "oklch(0.65 0.20 45)" }} />
|
||||
HOT AISLE
|
||||
<Flame className="w-3 h-3 shrink-0" style={{ color: "oklch(0.65 0.20 45)" }} />
|
||||
<div className="flex-1 h-px" style={{ backgroundColor: "oklch(0.65 0.20 45 / 30%)" }} />
|
||||
</div>
|
||||
|
||||
{roomLayout.rows.map((row, rowIdx) => {
|
||||
const rowCracColor = CRAC_ZONE_COLORS[rowIdx % CRAC_ZONE_COLORS.length];
|
||||
return (
|
||||
<div key={rowIdx}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[9px] text-muted-foreground/40 uppercase tracking-widest w-10 shrink-0 text-right">
|
||||
{row.label}
|
||||
</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{row.racks.map((rackId) => {
|
||||
const rack = rackMap.get(rackId);
|
||||
return (
|
||||
<RackTile
|
||||
key={rackId}
|
||||
rackId={rackId}
|
||||
temp={rack?.temp ?? null}
|
||||
powerPct={rack?.power_pct ?? null}
|
||||
alarmCount={alarmsByRack.get(rackId) ?? 0}
|
||||
overlay={overlay}
|
||||
feed={getFeed(layout, rackId)}
|
||||
cracColor={rowCracColor}
|
||||
onClick={() => onRackClick(rackId)}
|
||||
tempWarn={tempWarn}
|
||||
tempCrit={tempCrit}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{rowIdx < roomLayout.rows.length - 1 && (
|
||||
<div
|
||||
className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-widest px-2 mt-2 mb-1 rounded-md py-2"
|
||||
style={{ backgroundColor: "oklch(0.62 0.17 212 / 7%)", color: "oklch(0.62 0.17 212)" }}
|
||||
>
|
||||
<div className="flex-1 h-px border-t border-dashed" style={{ borderColor: "oklch(0.62 0.17 212 / 30%)" }} />
|
||||
<Snowflake className="w-3 h-3 shrink-0" style={{ color: "oklch(0.62 0.17 212)" }} />
|
||||
COLD AISLE
|
||||
<Snowflake className="w-3 h-3 shrink-0" style={{ color: "oklch(0.62 0.17 212)" }} />
|
||||
<div className="flex-1 h-px border-t border-dashed" style={{ borderColor: "oklch(0.62 0.17 212 / 30%)" }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div
|
||||
className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-widest px-2 rounded-md py-2"
|
||||
style={{ backgroundColor: "oklch(0.55 0.22 25 / 6%)", color: "oklch(0.65 0.20 45)" }}
|
||||
>
|
||||
<div className="flex-1 h-px" style={{ backgroundColor: "oklch(0.65 0.20 45 / 30%)" }} />
|
||||
<Flame className="w-3 h-3 shrink-0" style={{ color: "oklch(0.65 0.20 45)" }} />
|
||||
HOT AISLE
|
||||
<Flame className="w-3 h-3 shrink-0" style={{ color: "oklch(0.65 0.20 45)" }} />
|
||||
<div className="flex-1 h-px" style={{ backgroundColor: "oklch(0.65 0.20 45 / 30%)" }} />
|
||||
</div>
|
||||
</div>
|
||||
</TransformComponent>
|
||||
</>
|
||||
)}
|
||||
</TransformWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Leak sensor panel ─────────────────────────────────────────────────────────
|
||||
|
||||
function LeakSensorPanel({ sensors }: { sensors: LeakSensorStatus[] }) {
|
||||
if (sensors.length === 0) return null;
|
||||
const active = sensors.filter((s) => s.state === "detected");
|
||||
const byZone = sensors.reduce<Record<string, LeakSensorStatus[]>>((acc, s) => {
|
||||
const zone = s.floor_zone ?? "unknown";
|
||||
(acc[zone] ??= []).push(s);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<Card className={cn("border", active.length > 0 && "border-destructive/50")}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Droplets className={cn("w-4 h-4", active.length > 0 ? "text-destructive" : "text-blue-400")} />
|
||||
Leak Sensor Status
|
||||
</CardTitle>
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-2.5 py-0.5 rounded-full uppercase",
|
||||
active.length > 0 ? "bg-destructive/10 text-destructive" : "bg-green-500/10 text-green-400",
|
||||
)}>
|
||||
{active.length > 0 ? `${active.length} leak${active.length > 1 ? "s" : ""} detected` : "All clear"}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{Object.entries(byZone).map(([zone, zoneSensors]) => (
|
||||
<div key={zone} className="rounded-lg border border-border/50 bg-muted/10 p-3 space-y-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{zone}</p>
|
||||
{zoneSensors.map((s) => {
|
||||
const detected = s.state === "detected";
|
||||
return (
|
||||
<div key={s.sensor_id} className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={cn(
|
||||
"w-2 h-2 rounded-full shrink-0",
|
||||
detected ? "bg-destructive animate-pulse" :
|
||||
s.state === "unknown" ? "bg-muted-foreground/30" : "bg-green-500",
|
||||
)} />
|
||||
<span className="text-xs font-medium truncate">{s.sensor_id}</span>
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground/60 mt-0.5 truncate">
|
||||
{[
|
||||
s.under_floor ? "Under floor" : "Surface mount",
|
||||
s.near_crac ? "near CRAC" : null,
|
||||
s.room_id ?? null,
|
||||
].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold shrink-0",
|
||||
detected ? "text-destructive" :
|
||||
s.state === "unknown" ? "text-muted-foreground/50" : "text-green-400",
|
||||
)}>
|
||||
{detected ? "LEAK" : s.state === "unknown" ? "unknown" : "clear"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Layout editor ─────────────────────────────────────────────────────────────
|
||||
|
||||
function LayoutEditor({
|
||||
layout, onSave, saving,
|
||||
}: {
|
||||
layout: FloorLayout;
|
||||
onSave: (l: FloorLayout) => void;
|
||||
saving?: boolean;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<FloorLayout>(() => JSON.parse(JSON.stringify(layout)));
|
||||
const [newRoomId, setNewRoomId] = useState("");
|
||||
const [newRoomLabel, setNewRoomLabel] = useState("");
|
||||
const [newRoomCrac, setNewRoomCrac] = useState("");
|
||||
const [expandedRoom, setExpandedRoom] = useState<string | null>(Object.keys(draft)[0] ?? null);
|
||||
|
||||
function updateRoom(roomId: string, patch: Partial<RoomLayout>) {
|
||||
setDraft(d => ({ ...d, [roomId]: { ...d[roomId], ...patch } }));
|
||||
}
|
||||
|
||||
function deleteRoom(roomId: string) {
|
||||
setDraft(d => { const n = { ...d }; delete n[roomId]; return n; });
|
||||
if (expandedRoom === roomId) setExpandedRoom(null);
|
||||
}
|
||||
|
||||
function addRoom() {
|
||||
const id = newRoomId.trim().toLowerCase().replace(/\s+/g, "-");
|
||||
if (!id || !newRoomLabel.trim() || draft[id]) return;
|
||||
setDraft(d => ({
|
||||
...d,
|
||||
[id]: { label: newRoomLabel.trim(), crac_id: newRoomCrac.trim(), rows: [] },
|
||||
}));
|
||||
setNewRoomId(""); setNewRoomLabel(""); setNewRoomCrac("");
|
||||
setExpandedRoom(id);
|
||||
}
|
||||
|
||||
function addRow(roomId: string) {
|
||||
const room = draft[roomId];
|
||||
const label = `Row ${room.rows.length + 1}`;
|
||||
updateRoom(roomId, { rows: [...room.rows, { label, racks: [] }] });
|
||||
}
|
||||
|
||||
function deleteRow(roomId: string, rowIdx: number) {
|
||||
const rows = draft[roomId].rows.filter((_, i) => i !== rowIdx);
|
||||
updateRoom(roomId, { rows });
|
||||
}
|
||||
|
||||
function updateRowLabel(roomId: string, rowIdx: number, label: string) {
|
||||
const rows = draft[roomId].rows.map((r, i) => i === rowIdx ? { ...r, label } : r);
|
||||
updateRoom(roomId, { rows });
|
||||
}
|
||||
|
||||
function addRack(roomId: string, rowIdx: number, rackId: string) {
|
||||
const id = rackId.trim();
|
||||
if (!id) return;
|
||||
const rows = draft[roomId].rows.map((r, i) =>
|
||||
i === rowIdx ? { ...r, racks: [...r.racks, id] } : r
|
||||
);
|
||||
updateRoom(roomId, { rows });
|
||||
}
|
||||
|
||||
function removeRack(roomId: string, rowIdx: number, rackIdx: number) {
|
||||
const rows = draft[roomId].rows.map((r, i) =>
|
||||
i === rowIdx ? { ...r, racks: r.racks.filter((_, j) => j !== rackIdx) } : r
|
||||
);
|
||||
updateRoom(roomId, { rows });
|
||||
}
|
||||
|
||||
function moveRow(roomId: string, rowIdx: number, dir: -1 | 1) {
|
||||
const rows = [...draft[roomId].rows];
|
||||
const target = rowIdx + dir;
|
||||
if (target < 0 || target >= rows.length) return;
|
||||
[rows[rowIdx], rows[target]] = [rows[target], rows[rowIdx]];
|
||||
updateRoom(roomId, { rows });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-6 pt-6 pb-4 border-b border-border shrink-0">
|
||||
<h2 className="text-base font-semibold flex items-center gap-2">
|
||||
<Settings2 className="w-4 h-4 text-primary" /> Floor Layout Editor
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Configure rooms, rows, and rack positions. Changes are saved for all users.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{Object.entries(draft).map(([roomId, room]) => (
|
||||
<div key={roomId} className="rounded-xl border border-border bg-muted/10 overflow-hidden">
|
||||
{/* Room header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 bg-muted/20 border-b border-border/60">
|
||||
<button
|
||||
onClick={() => setExpandedRoom(expandedRoom === roomId ? null : roomId)}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left focus-visible:outline-none"
|
||||
>
|
||||
{expandedRoom === roomId
|
||||
? <ChevronUp className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
: <ChevronDown className="w-3.5 h-3.5 text-muted-foreground shrink-0" />}
|
||||
<span className="text-sm font-semibold truncate">{room.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">{roomId}</span>
|
||||
<span className="text-[10px] text-muted-foreground ml-1">
|
||||
· {room.rows.reduce((s, r) => s + r.racks.length, 0)} racks
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteRoom(roomId)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors shrink-0 p-1"
|
||||
aria-label={`Delete ${room.label}`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expandedRoom === roomId && (
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Room fields */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] text-muted-foreground font-medium">Room Label</label>
|
||||
<input
|
||||
value={room.label}
|
||||
onChange={e => updateRoom(roomId, { label: e.target.value })}
|
||||
className="w-full h-7 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] text-muted-foreground font-medium">CRAC ID</label>
|
||||
<input
|
||||
value={room.crac_id}
|
||||
onChange={e => updateRoom(roomId, { crac_id: e.target.value })}
|
||||
placeholder="e.g. crac-01"
|
||||
className="w-full h-7 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rows</p>
|
||||
<button
|
||||
onClick={() => addRow(roomId)}
|
||||
className="flex items-center gap-1 text-[10px] text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" /> Add Row
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{room.rows.length === 0 && (
|
||||
<p className="text-[10px] text-muted-foreground/50 py-2 text-center">No rows — click Add Row</p>
|
||||
)}
|
||||
|
||||
{room.rows.map((row, rowIdx) => (
|
||||
<div key={rowIdx} className="rounded-lg border border-border/60 bg-background/50 p-2.5 space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<GripVertical className="w-3 h-3 text-muted-foreground/30 shrink-0" />
|
||||
<input
|
||||
value={row.label}
|
||||
onChange={e => updateRowLabel(roomId, rowIdx, e.target.value)}
|
||||
className="flex-1 h-6 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<button onClick={() => moveRow(roomId, rowIdx, -1)} disabled={rowIdx === 0}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-20 transition-colors">
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
</button>
|
||||
<button onClick={() => moveRow(roomId, rowIdx, 1)} disabled={rowIdx === room.rows.length - 1}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-20 transition-colors">
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
<button onClick={() => deleteRow(roomId, rowIdx)}
|
||||
className="p-0.5 text-muted-foreground hover:text-destructive transition-colors">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Racks in this row */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.racks.map((rackId, rackIdx) => (
|
||||
<span key={rackIdx} className="inline-flex items-center gap-0.5 bg-muted rounded px-1.5 py-0.5 text-[10px] font-mono">
|
||||
{rackId}
|
||||
<button
|
||||
onClick={() => removeRack(roomId, rowIdx, rackIdx)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors ml-0.5"
|
||||
aria-label={`Remove ${rackId}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<RackAdder onAdd={(id) => addRack(roomId, rowIdx, id)} />
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground/40">
|
||||
Feed: {rowIdx % 2 === 0 ? "A (even rows)" : "B (odd rows)"} · {row.racks.length} rack{row.racks.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add new room */}
|
||||
<div className="rounded-xl border border-dashed border-border p-3 space-y-2">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Add New Room</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<input
|
||||
value={newRoomId}
|
||||
onChange={e => setNewRoomId(e.target.value)}
|
||||
placeholder="room-id (e.g. hall-c)"
|
||||
className="h-7 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<input
|
||||
value={newRoomLabel}
|
||||
onChange={e => setNewRoomLabel(e.target.value)}
|
||||
placeholder="Label (e.g. Hall C)"
|
||||
className="h-7 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<input
|
||||
value={newRoomCrac}
|
||||
onChange={e => setNewRoomCrac(e.target.value)}
|
||||
placeholder="CRAC ID (e.g. crac-03)"
|
||||
className="h-7 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={addRoom}
|
||||
disabled={!newRoomId.trim() || !newRoomLabel.trim()}
|
||||
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Add Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="px-6 py-4 border-t border-border shrink-0 flex items-center justify-between gap-3">
|
||||
<button
|
||||
onClick={() => { setDraft(JSON.parse(JSON.stringify(DEFAULT_LAYOUT))); setExpandedRoom(Object.keys(DEFAULT_LAYOUT)[0]); }}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
<Button size="sm" onClick={() => onSave(draft)} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save Layout"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Small inline input to add a rack ID to a row
|
||||
function RackAdder({ onAdd }: { onAdd: (id: string) => void }) {
|
||||
const [val, setVal] = useState("");
|
||||
function submit() {
|
||||
if (val.trim()) { onAdd(val.trim()); setVal(""); }
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<input
|
||||
value={val}
|
||||
onChange={e => setVal(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && submit()}
|
||||
placeholder="rack-id"
|
||||
className="h-5 w-20 rounded border border-dashed border-border bg-background px-1.5 text-[10px] font-mono focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<button
|
||||
onClick={submit}
|
||||
className="text-primary hover:text-primary/70 transition-colors"
|
||||
aria-label="Add rack"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function FloorMapPage() {
|
||||
const { thresholds } = useThresholds();
|
||||
const [layout, setLayout] = useState<FloorLayout>(DEFAULT_LAYOUT);
|
||||
const [data, setData] = useState<CapacitySummary | null>(null);
|
||||
const [cracs, setCracs] = useState<CracStatus[]>([]);
|
||||
const [alarms, setAlarms] = useState<Alarm[]>([]);
|
||||
const [leakSensors, setLeakSensors] = useState<LeakSensorStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [overlay, setOverlay] = useState<Overlay>("temp");
|
||||
const [activeRoom, setActiveRoom] = useState<string>("");
|
||||
const [selectedRack, setSelectedRack] = useState<string | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [layoutSaving, setLayoutSaving] = useState(false);
|
||||
|
||||
// Load layout from backend on mount
|
||||
useEffect(() => {
|
||||
fetchFloorLayout(SITE_ID)
|
||||
.then((remote) => {
|
||||
const parsed = remote as FloorLayout;
|
||||
setLayout(parsed);
|
||||
setActiveRoom(Object.keys(parsed)[0] ?? "");
|
||||
})
|
||||
.catch(() => {
|
||||
// No saved layout yet — use default
|
||||
setActiveRoom(Object.keys(DEFAULT_LAYOUT)[0] ?? "");
|
||||
});
|
||||
}, []);
|
||||
|
||||
const alarmsByRack = new Map<string, number>();
|
||||
for (const a of alarms) {
|
||||
if (a.rack_id) alarmsByRack.set(a.rack_id, (alarmsByRack.get(a.rack_id) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [d, c, a, ls] = await Promise.all([
|
||||
fetchCapacitySummary(SITE_ID),
|
||||
fetchCracStatus(SITE_ID),
|
||||
fetchAlarms(SITE_ID, "active", 200),
|
||||
fetchLeakStatus(SITE_ID).catch(() => [] as LeakSensorStatus[]),
|
||||
]);
|
||||
setData(d);
|
||||
setCracs(c);
|
||||
setAlarms(a);
|
||||
setLeakSensors(ls);
|
||||
} catch { /* keep stale */ }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
async function handleSaveLayout(newLayout: FloorLayout) {
|
||||
setLayoutSaving(true);
|
||||
try {
|
||||
await saveFloorLayout(SITE_ID, newLayout as unknown as Record<string, unknown>);
|
||||
setLayout(newLayout);
|
||||
if (!newLayout[activeRoom]) setActiveRoom(Object.keys(newLayout)[0] ?? "");
|
||||
setEditorOpen(false);
|
||||
} catch {
|
||||
// save failed — keep editor open so user can retry
|
||||
} finally {
|
||||
setLayoutSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const roomIds = Object.keys(layout);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Floor Map</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — live rack layout · refreshes every 30s</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Overlay selector */}
|
||||
<div className="flex items-center gap-0.5 rounded-lg bg-muted p-1">
|
||||
{([
|
||||
{ val: "temp" as Overlay, icon: Thermometer, label: "Temperature" },
|
||||
{ val: "power" as Overlay, icon: Zap, label: "Power %" },
|
||||
{ val: "alarms" as Overlay, icon: AlertTriangle, label: "Alarms" },
|
||||
{ val: "feed" as Overlay, icon: Cable, label: "Power Feed" },
|
||||
{ val: "crac" as Overlay, icon: Wind, label: "CRAC Coverage" },
|
||||
]).map(({ val, icon: Icon, label }) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setOverlay(val)}
|
||||
aria-label={label}
|
||||
aria-pressed={overlay === val}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
||||
overlay === val ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" /> {label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Layout editor trigger */}
|
||||
<Sheet open={editorOpen} onOpenChange={setEditorOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="flex items-center gap-1.5">
|
||||
<Settings2 className="w-3.5 h-3.5" /> Edit Layout
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="p-0 w-[420px] sm:w-[480px] flex flex-col">
|
||||
<LayoutEditor layout={layout} onSave={handleSaveLayout} saving={layoutSaving} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton className="h-96 w-full" />
|
||||
) : !data ? (
|
||||
<div className="flex items-center justify-center h-64 text-sm text-muted-foreground">
|
||||
Unable to load floor map data.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<RackDetailSheet siteId={SITE_ID} rackId={selectedRack} onClose={() => setSelectedRack(null)} />
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Room View</CardTitle>
|
||||
{roomIds.length > 0 && (
|
||||
<Tabs value={activeRoom} onValueChange={setActiveRoom}>
|
||||
<TabsList className="h-7">
|
||||
{roomIds.map((id) => (
|
||||
<TabsTrigger key={id} value={id} className="text-xs px-3 py-0.5">
|
||||
{layout[id].label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activeRoom && layout[activeRoom] ? (
|
||||
<RoomPlan
|
||||
roomId={activeRoom}
|
||||
layout={layout}
|
||||
data={data}
|
||||
cracs={cracs}
|
||||
overlay={overlay}
|
||||
alarmsByRack={alarmsByRack}
|
||||
onRackClick={setSelectedRack}
|
||||
tempWarn={thresholds.temp.warn}
|
||||
tempCrit={thresholds.temp.critical}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3 text-muted-foreground">
|
||||
<Settings2 className="w-8 h-8 opacity-30" />
|
||||
<p className="text-sm">No rooms configured</p>
|
||||
<p className="text-xs">Use Edit Layout to add rooms and racks</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<LeakSensorPanel sensors={leakSensors} />
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground flex-wrap">
|
||||
{overlay === "alarms" ? (
|
||||
<>
|
||||
<span className="font-medium">Alarm count:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{([
|
||||
{ c: "oklch(0.22 0.02 265)", l: "0" },
|
||||
{ c: "oklch(0.65 0.20 45)", l: "1–2" },
|
||||
{ c: "oklch(0.55 0.22 25)", l: "3+" },
|
||||
]).map(({ c, l }) => (
|
||||
<span key={l} className="flex items-center gap-0.5">
|
||||
<span className="w-6 h-4 rounded-sm inline-block" style={{ backgroundColor: c }} />
|
||||
<span>{l}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : overlay === "feed" ? (
|
||||
<>
|
||||
<span className="font-medium">Power feed:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-6 h-4 rounded-sm inline-block" style={{ backgroundColor: "oklch(0.55 0.18 255)" }} />
|
||||
<span>Feed A (even rows)</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-6 h-4 rounded-sm inline-block" style={{ backgroundColor: "oklch(0.60 0.18 40)" }} />
|
||||
<span>Feed B (odd rows)</span>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : overlay === "crac" ? (
|
||||
<>
|
||||
<span className="font-medium">CRAC thermal zones:</span>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{CRAC_ZONE_COLORS.slice(0, layout[activeRoom]?.rows.length ?? 2).map((c, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<span className="w-6 h-4 rounded-sm inline-block" style={{ backgroundColor: c }} />
|
||||
<span>Zone {i + 1}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium">{overlay === "temp" ? "Temperature:" : "Power utilisation:"}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{overlay === "temp"
|
||||
? (["oklch(0.60 0.15 212)","oklch(0.68 0.14 162)","oklch(0.78 0.14 140)","oklch(0.72 0.18 84)","oklch(0.65 0.20 45)","oklch(0.55 0.22 25)"] as string[]).map((c, i) => (
|
||||
<span key={i} className="w-7 h-4 rounded-sm inline-block" style={{ backgroundColor: c }} />
|
||||
))
|
||||
: (["oklch(0.60 0.15 212)","oklch(0.68 0.14 162)","oklch(0.72 0.18 84)","oklch(0.65 0.20 45)","oklch(0.55 0.22 25)"] as string[]).map((c, i) => (
|
||||
<span key={i} className="w-7 h-4 rounded-sm inline-block" style={{ backgroundColor: c }} />
|
||||
))}
|
||||
<span className="ml-1">{overlay === "temp" ? "Cool → Hot" : "Low → High"}</span>
|
||||
</div>
|
||||
{overlay === "temp" && <span className="ml-auto">Warn: {thresholds.temp.warn}°C | Critical: {thresholds.temp.critical}°C</span>}
|
||||
{overlay === "power" && <span className="ml-auto">Warn: 75% | Critical: 90%</span>}
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground/50">Click any rack to drill down</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
412
frontend/app/(dashboard)/generator/page.tsx
Normal file
412
frontend/app/(dashboard)/generator/page.tsx
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
fetchGeneratorStatus, fetchAtsStatus, fetchPhaseBreakdown,
|
||||
type GeneratorStatus, type AtsStatus, type RoomPhase,
|
||||
} from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Fuel, Zap, Activity, RefreshCw, CheckCircle2, AlertTriangle,
|
||||
ArrowLeftRight, Gauge, Thermometer, Battery,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { GeneratorDetailSheet } from "@/components/dashboard/generator-detail-sheet";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
const STATE_COLOR: Record<string, string> = {
|
||||
running: "bg-green-500/10 text-green-400",
|
||||
standby: "bg-blue-500/10 text-blue-400",
|
||||
test: "bg-amber-500/10 text-amber-400",
|
||||
fault: "bg-destructive/10 text-destructive",
|
||||
unknown: "bg-muted/30 text-muted-foreground",
|
||||
};
|
||||
|
||||
const ATS_FEED_COLOR: Record<string, string> = {
|
||||
"utility-a": "bg-blue-500/10 text-blue-400",
|
||||
"utility-b": "bg-sky-500/10 text-sky-400",
|
||||
"generator": "bg-amber-500/10 text-amber-400",
|
||||
};
|
||||
|
||||
function FillBar({
|
||||
value, max, color = "#22c55e", warn, crit,
|
||||
}: {
|
||||
value: number | null; max: number; color?: string; warn?: number; crit?: number;
|
||||
}) {
|
||||
const pct = value != null ? Math.min(100, (value / max) * 100) : 0;
|
||||
const bg = crit && value != null && value >= crit ? "#ef4444"
|
||||
: warn && value != null && value >= warn ? "#f59e0b"
|
||||
: color;
|
||||
return (
|
||||
<div className="rounded-full bg-muted overflow-hidden h-2 w-full">
|
||||
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${pct}%`, backgroundColor: bg }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatRow({ label, value, warn }: { label: string; value: string; warn?: boolean }) {
|
||||
return (
|
||||
<div className="flex justify-between items-baseline text-xs">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className={cn("font-mono font-medium", warn && "text-amber-400")}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneratorCard({ gen, onClick }: { gen: GeneratorStatus; onClick: () => void }) {
|
||||
const fuelLow = (gen.fuel_pct ?? 100) < 25;
|
||||
const fuelCrit = (gen.fuel_pct ?? 100) < 10;
|
||||
const isFault = gen.state === "fault";
|
||||
const isRun = gen.state === "running" || gen.state === "test";
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn("border cursor-pointer hover:border-primary/40 transition-colors", isFault && "border-destructive/50")}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Activity className={cn("w-4 h-4", isRun ? "text-green-400" : "text-muted-foreground")} />
|
||||
{gen.gen_id.toUpperCase()}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-2.5 py-0.5 rounded-full uppercase tracking-wide",
|
||||
STATE_COLOR[gen.state] ?? STATE_COLOR.unknown,
|
||||
)}>
|
||||
{gen.state}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Fuel level */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="flex items-center gap-1.5 text-[10px] text-muted-foreground uppercase tracking-wide">
|
||||
<Fuel className="w-3 h-3" /> Fuel Level
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-sm font-bold tabular-nums",
|
||||
fuelCrit ? "text-destructive" : fuelLow ? "text-amber-400" : "text-green-400",
|
||||
)}>
|
||||
{gen.fuel_pct != null ? `${gen.fuel_pct.toFixed(1)}%` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<FillBar
|
||||
value={gen.fuel_pct}
|
||||
max={100}
|
||||
color="#22c55e"
|
||||
warn={25}
|
||||
crit={10}
|
||||
/>
|
||||
{gen.fuel_litres != null && (
|
||||
<p className="text-[10px] text-muted-foreground text-right mt-1">
|
||||
{gen.fuel_litres.toFixed(0)} L remaining
|
||||
</p>
|
||||
)}
|
||||
{gen.fuel_litres != null && gen.load_kw != null && gen.load_kw > 0 && (() => {
|
||||
const runtimeH = gen.fuel_litres / (gen.load_kw * 0.27);
|
||||
const hours = Math.floor(runtimeH);
|
||||
const mins = Math.round((runtimeH - hours) * 60);
|
||||
const cls = runtimeH < 4 ? "text-destructive" : runtimeH < 12 ? "text-amber-400" : "text-green-400";
|
||||
return (
|
||||
<p className={cn("text-[10px] text-right mt-0.5", cls)}>
|
||||
Est. runtime: <strong>{hours}h {mins}m</strong>
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Load */}
|
||||
{gen.load_kw != null && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="flex items-center gap-1.5 text-[10px] text-muted-foreground uppercase tracking-wide">
|
||||
<Zap className="w-3 h-3" /> Load
|
||||
</span>
|
||||
<span className="text-sm font-bold tabular-nums text-foreground">
|
||||
{gen.load_kw.toFixed(1)} kW
|
||||
{gen.load_pct != null && (
|
||||
<span className="text-muted-foreground font-normal ml-1">({gen.load_pct.toFixed(0)}%)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={gen.load_pct} max={100} color="#60a5fa" warn={75} crit={90} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Engine stats */}
|
||||
<div className="rounded-lg bg-muted/20 px-3 py-3 space-y-2">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold mb-1">Engine</p>
|
||||
{gen.voltage_v != null && <StatRow label="Output voltage" value={`${gen.voltage_v.toFixed(0)} V`} />}
|
||||
{gen.frequency_hz != null && <StatRow label="Frequency" value={`${gen.frequency_hz.toFixed(1)} Hz`} warn={Math.abs(gen.frequency_hz - 50) > 0.5} />}
|
||||
{gen.run_hours != null && <StatRow label="Run hours" value={`${gen.run_hours.toFixed(0)} h`} />}
|
||||
{gen.oil_pressure_bar != null && <StatRow label="Oil pressure" value={`${gen.oil_pressure_bar.toFixed(1)} bar`} warn={gen.oil_pressure_bar < 2.0} />}
|
||||
{gen.coolant_temp_c != null && (
|
||||
<div className="flex justify-between items-baseline text-xs">
|
||||
<span className="text-muted-foreground flex items-center gap-1">
|
||||
<Thermometer className="w-3 h-3 inline" /> Coolant temp
|
||||
</span>
|
||||
<span className={cn("font-mono font-medium", gen.coolant_temp_c > 95 ? "text-destructive" : gen.coolant_temp_c > 85 ? "text-amber-400" : "")}>
|
||||
{gen.coolant_temp_c.toFixed(1)}°C
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{gen.battery_v != null && (
|
||||
<div className="flex justify-between items-baseline text-xs">
|
||||
<span className="text-muted-foreground flex items-center gap-1">
|
||||
<Battery className="w-3 h-3 inline" /> Battery
|
||||
</span>
|
||||
<span className={cn("font-mono font-medium", gen.battery_v < 11.5 ? "text-destructive" : gen.battery_v < 12.0 ? "text-amber-400" : "text-green-400")}>
|
||||
{gen.battery_v.toFixed(1)} V
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AtsCard({ ats }: { ats: AtsStatus }) {
|
||||
const feedColor = ATS_FEED_COLOR[ats.active_feed] ?? "bg-muted/30 text-muted-foreground";
|
||||
const isGen = ats.active_feed === "generator";
|
||||
|
||||
return (
|
||||
<Card className={cn("border", isGen && "border-amber-500/40")}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<ArrowLeftRight className="w-4 h-4 text-primary" />
|
||||
{ats.ats_id.toUpperCase()} — ATS Transfer Switch
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">Active feed</span>
|
||||
<span className={cn("text-xs font-bold px-2.5 py-1 rounded-full uppercase tracking-wide", feedColor)}>
|
||||
{ats.active_feed}
|
||||
</span>
|
||||
{isGen && <span className="text-[10px] text-amber-400">Running on generator power</span>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-xs">
|
||||
{[
|
||||
{ label: "Utility A", v: ats.utility_a_v },
|
||||
{ label: "Utility B", v: ats.utility_b_v },
|
||||
{ label: "Generator", v: ats.generator_v },
|
||||
].map(({ label, v }) => (
|
||||
<div key={label} className="rounded-lg bg-muted/20 px-2 py-2 text-center">
|
||||
<p className="text-[10px] text-muted-foreground mb-0.5">{label}</p>
|
||||
<p className="font-bold text-foreground tabular-nums">{v != null ? `${v.toFixed(0)} V` : "—"}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
{ats.transfer_count != null && (
|
||||
<div className="rounded-lg bg-muted/20 px-3 py-2">
|
||||
<p className="text-[10px] text-muted-foreground mb-0.5">Transfers (total)</p>
|
||||
<p className="font-bold">{ats.transfer_count}</p>
|
||||
</div>
|
||||
)}
|
||||
{ats.last_transfer_ms != null && (
|
||||
<div className="rounded-lg bg-muted/20 px-3 py-2">
|
||||
<p className="text-[10px] text-muted-foreground mb-0.5">Last transfer time</p>
|
||||
<p className="font-bold">{ats.last_transfer_ms} ms</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PhaseImbalancePanel({ rooms }: { rooms: RoomPhase[] }) {
|
||||
const allRacks = rooms.flatMap((r) => r.racks);
|
||||
const flagged = allRacks
|
||||
.filter((r) => (r.imbalance_pct ?? 0) >= 5)
|
||||
.sort((a, b) => (b.imbalance_pct ?? 0) - (a.imbalance_pct ?? 0));
|
||||
|
||||
if (flagged.length === 0) return (
|
||||
<div className="rounded-lg border border-border bg-muted/10 px-4 py-3 flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-400" />
|
||||
<span className="text-green-400">No PDU phase imbalance detected across all racks</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400" />
|
||||
PDU Phase Imbalance
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{flagged.map((rack) => {
|
||||
const crit = (rack.imbalance_pct ?? 0) >= 15;
|
||||
return (
|
||||
<div key={rack.rack_id} className={cn(
|
||||
"rounded-lg px-3 py-2 grid grid-cols-5 gap-2 text-xs items-center",
|
||||
crit ? "bg-destructive/10" : "bg-amber-500/10",
|
||||
)}>
|
||||
<span className="font-medium col-span-1">{rack.rack_id.toUpperCase()}</span>
|
||||
<span className={cn("text-center", crit ? "text-destructive" : "text-amber-400")}>
|
||||
{rack.imbalance_pct?.toFixed(1)}% imbalance
|
||||
</span>
|
||||
<span className="text-muted-foreground text-center">A: {rack.phase_a_kw?.toFixed(2) ?? "—"} kW</span>
|
||||
<span className="text-muted-foreground text-center">B: {rack.phase_b_kw?.toFixed(2) ?? "—"} kW</span>
|
||||
<span className="text-muted-foreground text-center">C: {rack.phase_c_kw?.toFixed(2) ?? "—"} kW</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GeneratorPage() {
|
||||
const [generators, setGenerators] = useState<GeneratorStatus[]>([]);
|
||||
const [atsUnits, setAtsUnits] = useState<AtsStatus[]>([]);
|
||||
const [phases, setPhases] = useState<RoomPhase[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedGen, setSelectedGen] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [g, a, p] = await Promise.all([
|
||||
fetchGeneratorStatus(SITE_ID),
|
||||
fetchAtsStatus(SITE_ID).catch(() => [] as AtsStatus[]),
|
||||
fetchPhaseBreakdown(SITE_ID).catch(() => [] as RoomPhase[]),
|
||||
]);
|
||||
setGenerators(g);
|
||||
setAtsUnits(a);
|
||||
setPhases(p);
|
||||
} catch { toast.error("Failed to load generator data"); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 15_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
const anyFault = generators.some((g) => g.state === "fault");
|
||||
const anyRun = generators.some((g) => g.state === "running" || g.state === "test");
|
||||
const onGen = atsUnits.some((a) => a.active_feed === "generator");
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Generator & Power Path</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — backup power systems · refreshes every 15s</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!loading && (
|
||||
<span className={cn(
|
||||
"flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-full",
|
||||
anyFault ? "bg-destructive/10 text-destructive" :
|
||||
onGen ? "bg-amber-500/10 text-amber-400" :
|
||||
anyRun ? "bg-green-500/10 text-green-400" :
|
||||
"bg-blue-500/10 text-blue-400",
|
||||
)}>
|
||||
{anyFault ? <><AlertTriangle className="w-3.5 h-3.5" /> Generator fault</> :
|
||||
onGen ? <><AlertTriangle className="w-3.5 h-3.5" /> Running on generator</> :
|
||||
anyRun ? <><CheckCircle2 className="w-3.5 h-3.5" /> Generator running (test)</> :
|
||||
<><CheckCircle2 className="w-3.5 h-3.5" /> Utility power — all standby</>}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={load}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Site power status bar */}
|
||||
{!loading && atsUnits.length > 0 && (
|
||||
<div className={cn(
|
||||
"rounded-xl border px-5 py-3 flex items-center gap-6 text-sm flex-wrap",
|
||||
onGen ? "border-amber-500/30 bg-amber-500/5" : "border-border bg-muted/10",
|
||||
)}>
|
||||
<Gauge className={cn("w-5 h-5 shrink-0", onGen ? "text-amber-400" : "text-primary")} />
|
||||
<div>
|
||||
<span className="text-muted-foreground">Power path: </span>
|
||||
<strong className="text-foreground capitalize">{onGen ? "Generator (utility lost)" : "Utility mains"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Generators: </span>
|
||||
<strong>{generators.length} total</strong>
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({generators.filter((g) => g.state === "standby").length} standby,{" "}
|
||||
{generators.filter((g) => g.state === "running").length} running,{" "}
|
||||
{generators.filter((g) => g.state === "fault").length} fault)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generators */}
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Diesel Generators
|
||||
</h2>
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Skeleton className="h-80" />
|
||||
<Skeleton className="h-80" />
|
||||
</div>
|
||||
) : generators.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No generator data available</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{generators.map((g) => (
|
||||
<GeneratorCard key={g.gen_id} gen={g} onClick={() => setSelectedGen(g.gen_id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ATS */}
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Automatic Transfer Switches
|
||||
</h2>
|
||||
{loading ? (
|
||||
<Skeleton className="h-40" />
|
||||
) : atsUnits.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No ATS data available</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{atsUnits.map((a) => <AtsCard key={a.ats_id} ats={a} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<GeneratorDetailSheet
|
||||
siteId={SITE_ID}
|
||||
genId={selectedGen}
|
||||
onClose={() => setSelectedGen(null)}
|
||||
/>
|
||||
|
||||
{/* Phase imbalance */}
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
PDU Phase Balance
|
||||
</h2>
|
||||
{loading ? (
|
||||
<Skeleton className="h-32" />
|
||||
) : (
|
||||
<PhaseImbalancePanel rooms={phases} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
frontend/app/(dashboard)/layout.tsx
Normal file
33
frontend/app/(dashboard)/layout.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { Topbar } from "@/components/layout/topbar";
|
||||
import { AlarmProvider } from "@/lib/alarm-context";
|
||||
import { ThresholdProvider } from "@/lib/threshold-context";
|
||||
import { ErrorBoundary } from "@/components/error-boundary";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ThresholdProvider>
|
||||
<AlarmProvider>
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<div className="hidden md:flex">
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
|
||||
<Topbar />
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<ErrorBoundary>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
<Toaster position="bottom-right" theme="dark" richColors />
|
||||
</div>
|
||||
</AlarmProvider>
|
||||
</ThresholdProvider>
|
||||
);
|
||||
}
|
||||
244
frontend/app/(dashboard)/leak/page.tsx
Normal file
244
frontend/app/(dashboard)/leak/page.tsx
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { fetchLeakStatus, type LeakSensorStatus } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Droplets, RefreshCw, CheckCircle2, AlertTriangle, MapPin, Wind, Clock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
function SensorBadge({ state }: { state: string }) {
|
||||
const cfg = {
|
||||
detected: { cls: "bg-destructive/10 text-destructive border-destructive/30", label: "LEAK DETECTED" },
|
||||
clear: { cls: "bg-green-500/10 text-green-400 border-green-500/20", label: "Clear" },
|
||||
unknown: { cls: "bg-muted/30 text-muted-foreground border-border", label: "Unknown" },
|
||||
}[state] ?? { cls: "bg-muted/30 text-muted-foreground border-border", label: state };
|
||||
return (
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide border",
|
||||
cfg.cls,
|
||||
)}>
|
||||
{cfg.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SensorCard({ sensor }: { sensor: LeakSensorStatus }) {
|
||||
const detected = sensor.state === "detected";
|
||||
const sensorAny = sensor as LeakSensorStatus & { last_triggered_at?: string | null; trigger_count_30d?: number };
|
||||
const triggerCount30d = sensorAny.trigger_count_30d ?? 0;
|
||||
const lastTriggeredAt = sensorAny.last_triggered_at ?? null;
|
||||
|
||||
let lastTriggeredText: string;
|
||||
if (lastTriggeredAt) {
|
||||
const daysAgo = Math.floor((Date.now() - new Date(lastTriggeredAt).getTime()) / (1000 * 60 * 60 * 24));
|
||||
lastTriggeredText = daysAgo === 0 ? "Today" : `${daysAgo}d ago`;
|
||||
} else if (detected) {
|
||||
lastTriggeredText = "Currently active";
|
||||
} else {
|
||||
lastTriggeredText = "No recent events";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"rounded-xl border p-4 space-y-3 transition-colors",
|
||||
detected ? "border-destructive/50 bg-destructive/5" : "border-border bg-muted/5",
|
||||
)}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"w-2.5 h-2.5 rounded-full shrink-0 mt-0.5",
|
||||
detected ? "bg-destructive animate-pulse" :
|
||||
sensor.state === "unknown" ? "bg-muted-foreground/30" : "bg-green-500",
|
||||
)} />
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-none">{sensor.sensor_id}</p>
|
||||
{sensor.floor_zone && (
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5 flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" /> {sensor.floor_zone}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground">{triggerCount30d} events (30d)</span>
|
||||
<SensorBadge state={sensor.state} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{sensor.room_id && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<span>Room:</span>
|
||||
<span className="font-medium text-foreground capitalize">{sensor.room_id}</span>
|
||||
</div>
|
||||
)}
|
||||
{sensor.near_crac && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Wind className="w-3 h-3 shrink-0" />
|
||||
<span className="font-medium text-foreground">Near CRAC</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-span-2 flex items-center gap-1.5 text-muted-foreground">
|
||||
<span>{sensor.under_floor ? "Under raised floor" : "Above floor level"}</span>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center gap-1.5 text-[10px] text-muted-foreground mt-0.5">
|
||||
<Clock className="w-3 h-3 shrink-0" />
|
||||
<span>{lastTriggeredText}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detected && (
|
||||
<div className="rounded-lg bg-destructive/10 border border-destructive/30 px-3 py-2 text-xs text-destructive font-medium">
|
||||
Water detected — inspect immediately and isolate if necessary
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LeakDetectionPage() {
|
||||
const [sensors, setSensors] = useState<LeakSensorStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setSensors(await fetchLeakStatus(SITE_ID));
|
||||
} catch { toast.error("Failed to load leak sensor data"); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 15_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
const active = sensors.filter((s) => s.state === "detected");
|
||||
const offline = sensors.filter((s) => s.state === "unknown");
|
||||
const dry = sensors.filter((s) => s.state === "clear");
|
||||
|
||||
// Group by floor_zone
|
||||
const byZone = sensors.reduce<Record<string, LeakSensorStatus[]>>((acc, s) => {
|
||||
const zone = s.floor_zone ?? "Unassigned";
|
||||
(acc[zone] ??= []).push(s);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const zoneEntries = Object.entries(byZone).sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Leak Detection</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — water sensor site map · refreshes every 15s</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!loading && (
|
||||
<span className={cn(
|
||||
"flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-full",
|
||||
active.length > 0
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-green-500/10 text-green-400",
|
||||
)}>
|
||||
{active.length > 0
|
||||
? <><AlertTriangle className="w-3.5 h-3.5" /> {active.length} leak{active.length > 1 ? "s" : ""} detected</>
|
||||
: <><CheckCircle2 className="w-3.5 h-3.5" /> No leaks detected</>}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={load}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI bar */}
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{
|
||||
label: "Active Leaks",
|
||||
value: active.length,
|
||||
sub: "require immediate action",
|
||||
cls: active.length > 0 ? "border-destructive/40 bg-destructive/5 text-destructive" : "border-border bg-muted/10 text-green-400",
|
||||
},
|
||||
{
|
||||
label: "Sensors Clear",
|
||||
value: dry.length,
|
||||
sub: `of ${sensors.length} total sensors`,
|
||||
cls: "border-border bg-muted/10 text-foreground",
|
||||
},
|
||||
{
|
||||
label: "Offline",
|
||||
value: offline.length,
|
||||
sub: "no signal",
|
||||
cls: offline.length > 0 ? "border-amber-500/30 bg-amber-500/5 text-amber-400" : "border-border bg-muted/10 text-muted-foreground",
|
||||
},
|
||||
].map(({ label, value, sub, cls }) => (
|
||||
<div key={label} className={cn("rounded-xl border px-4 py-3", cls)}>
|
||||
<p className="text-[10px] uppercase tracking-wider mb-1 opacity-70">{label}</p>
|
||||
<p className="text-2xl font-bold tabular-nums leading-none">{value}</p>
|
||||
<p className="text-[10px] opacity-60 mt-1">{sub}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active leak alert */}
|
||||
{!loading && active.length > 0 && (
|
||||
<div className="rounded-xl border border-destructive/50 bg-destructive/10 px-5 py-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Droplets className="w-5 h-5 text-destructive shrink-0" />
|
||||
<p className="text-sm font-semibold text-destructive">
|
||||
{active.length} water leak{active.length > 1 ? "s" : ""} detected — immediate action required
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{active.map((s) => (
|
||||
<p key={s.sensor_id} className="text-xs text-destructive/80">
|
||||
• <strong>{s.sensor_id}</strong>
|
||||
{s.floor_zone ? ` — ${s.floor_zone}` : ""}
|
||||
{s.near_crac ? ` (near ${s.near_crac})` : ""}
|
||||
{s.under_floor ? " — under raised floor" : ""}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zone panels */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-48" />)}
|
||||
</div>
|
||||
) : (
|
||||
zoneEntries.map(([zone, zoneSensors]) => {
|
||||
const zoneActive = zoneSensors.filter((s) => s.state === "detected");
|
||||
return (
|
||||
<div key={zone}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{zone}</h2>
|
||||
{zoneActive.length > 0 && (
|
||||
<span className="text-[10px] font-semibold text-destructive bg-destructive/10 px-2 py-0.5 rounded-full">
|
||||
{zoneActive.length} LEAK
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{zoneSensors.map((s) => <SensorCard key={s.sensor_id} sensor={s} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
440
frontend/app/(dashboard)/maintenance/page.tsx
Normal file
440
frontend/app/(dashboard)/maintenance/page.tsx
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
fetchMaintenanceWindows, createMaintenanceWindow, deleteMaintenanceWindow,
|
||||
type MaintenanceWindow,
|
||||
} from "@/lib/api";
|
||||
import { PageShell } from "@/components/layout/page-shell";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
CalendarClock, Plus, Trash2, CheckCircle2, Clock, AlertTriangle,
|
||||
BellOff, RefreshCw, X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
const TARGET_GROUPS = [
|
||||
{
|
||||
label: "Site",
|
||||
targets: [{ value: "all", label: "Entire Site" }],
|
||||
},
|
||||
{
|
||||
label: "Halls",
|
||||
targets: [
|
||||
{ value: "hall-a", label: "Hall A" },
|
||||
{ value: "hall-b", label: "Hall B" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Racks — Hall A",
|
||||
targets: [
|
||||
{ value: "rack-A01", label: "Rack A01" },
|
||||
{ value: "rack-A02", label: "Rack A02" },
|
||||
{ value: "rack-A03", label: "Rack A03" },
|
||||
{ value: "rack-A04", label: "Rack A04" },
|
||||
{ value: "rack-A05", label: "Rack A05" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Racks — Hall B",
|
||||
targets: [
|
||||
{ value: "rack-B01", label: "Rack B01" },
|
||||
{ value: "rack-B02", label: "Rack B02" },
|
||||
{ value: "rack-B03", label: "Rack B03" },
|
||||
{ value: "rack-B04", label: "Rack B04" },
|
||||
{ value: "rack-B05", label: "Rack B05" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "CRAC Units",
|
||||
targets: [
|
||||
{ value: "crac-01", label: "CRAC-01" },
|
||||
{ value: "crac-02", label: "CRAC-02" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "UPS",
|
||||
targets: [
|
||||
{ value: "ups-01", label: "UPS-01" },
|
||||
{ value: "ups-02", label: "UPS-02" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Generator",
|
||||
targets: [
|
||||
{ value: "gen-01", label: "Generator GEN-01" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Flat list for looking up labels
|
||||
const TARGETS_FLAT = TARGET_GROUPS.flatMap(g => g.targets);
|
||||
|
||||
const statusCfg = {
|
||||
active: { label: "Active", cls: "bg-green-500/10 text-green-400 border-green-500/20", icon: CheckCircle2 },
|
||||
scheduled: { label: "Scheduled", cls: "bg-blue-500/10 text-blue-400 border-blue-500/20", icon: Clock },
|
||||
expired: { label: "Expired", cls: "bg-muted/50 text-muted-foreground border-border", icon: AlertTriangle },
|
||||
};
|
||||
|
||||
function StatusChip({ status }: { status: MaintenanceWindow["status"] }) {
|
||||
const cfg = statusCfg[status];
|
||||
const Icon = cfg.icon;
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border uppercase tracking-wide", cfg.cls)}>
|
||||
<Icon className="w-3 h-3" /> {cfg.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDt(iso: string): string {
|
||||
return new Date(iso).toLocaleString([], { dateStyle: "short", timeStyle: "short" });
|
||||
}
|
||||
|
||||
// 7-day timeline strip
|
||||
const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
function TimelineStrip({ windows }: { windows: MaintenanceWindow[] }) {
|
||||
const relevant = windows.filter(w => w.status === "active" || w.status === "scheduled");
|
||||
if (relevant.length === 0) return null;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const totalMs = 7 * 24 * 3600_000;
|
||||
|
||||
// Day labels
|
||||
const days = Array.from({ length: 7 }, (_, i) => {
|
||||
const d = new Date(today.getTime() + i * 24 * 3600_000);
|
||||
return DAY_LABELS[d.getDay()];
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-muted/10 p-4 space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
7-Day Maintenance Timeline
|
||||
</p>
|
||||
|
||||
{/* Day column labels */}
|
||||
<div className="relative">
|
||||
<div className="flex text-[10px] text-muted-foreground mb-1">
|
||||
{days.map((day, i) => (
|
||||
<div key={i} className="flex-1 text-center">{day}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid lines */}
|
||||
<div className="relative h-auto">
|
||||
<div className="absolute inset-0 flex pointer-events-none">
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<div key={i} className="flex-1 border-l border-border/30 last:border-r" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Window bars */}
|
||||
<div className="space-y-1.5 pt-1 pb-1">
|
||||
{relevant.map(w => {
|
||||
const startMs = Math.max(0, new Date(w.start_dt).getTime() - today.getTime());
|
||||
const endMs = Math.min(totalMs, new Date(w.end_dt).getTime() - today.getTime());
|
||||
if (endMs <= 0 || startMs >= totalMs) return null;
|
||||
|
||||
const leftPct = (startMs / totalMs) * 100;
|
||||
const widthPct = ((endMs - startMs) / totalMs) * 100;
|
||||
|
||||
const barCls = w.status === "active"
|
||||
? "bg-green-500/30 border-green-500/50 text-green-300"
|
||||
: "bg-blue-500/20 border-blue-500/40 text-blue-300";
|
||||
|
||||
return (
|
||||
<div key={w.id} className="relative h-6">
|
||||
<div
|
||||
className={cn("absolute h-full rounded border text-[10px] font-medium flex items-center px-1.5 overflow-hidden", barCls)}
|
||||
style={{ left: `${leftPct}%`, width: `${widthPct}%`, minWidth: "4px" }}
|
||||
>
|
||||
<span className="truncate">{w.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Defaults for new window form: now → +2h
|
||||
function defaultStart() {
|
||||
const d = new Date();
|
||||
d.setSeconds(0, 0);
|
||||
return d.toISOString().slice(0, 16);
|
||||
}
|
||||
function defaultEnd() {
|
||||
const d = new Date(Date.now() + 2 * 3600_000);
|
||||
d.setSeconds(0, 0);
|
||||
return d.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
export default function MaintenancePage() {
|
||||
const [windows, setWindows] = useState<MaintenanceWindow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState("");
|
||||
const [target, setTarget] = useState("all");
|
||||
const [startDt, setStartDt] = useState(defaultStart);
|
||||
const [endDt, setEndDt] = useState(defaultEnd);
|
||||
const [suppress, setSuppress] = useState(true);
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchMaintenanceWindows(SITE_ID);
|
||||
setWindows(data);
|
||||
} catch { toast.error("Failed to load maintenance windows"); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const targetLabel = TARGETS_FLAT.find(t => t.value === target)?.label ?? target;
|
||||
await createMaintenanceWindow({
|
||||
site_id: SITE_ID,
|
||||
title: title.trim(),
|
||||
target,
|
||||
target_label: targetLabel,
|
||||
start_dt: new Date(startDt).toISOString(),
|
||||
end_dt: new Date(endDt).toISOString(),
|
||||
suppress_alarms: suppress,
|
||||
notes: notes.trim(),
|
||||
});
|
||||
await load();
|
||||
toast.success("Maintenance window created");
|
||||
setShowForm(false);
|
||||
setTitle(""); setNotes(""); setStartDt(defaultStart()); setEndDt(defaultEnd());
|
||||
} catch { toast.error("Failed to create maintenance window"); }
|
||||
finally { setSubmitting(false); }
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setDeleting(id);
|
||||
try { await deleteMaintenanceWindow(id); toast.success("Maintenance window deleted"); await load(); }
|
||||
catch { toast.error("Failed to delete maintenance window"); }
|
||||
finally { setDeleting(null); }
|
||||
}
|
||||
|
||||
const active = windows.filter(w => w.status === "active").length;
|
||||
const scheduled = windows.filter(w => w.status === "scheduled").length;
|
||||
|
||||
return (
|
||||
<PageShell className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Maintenance Windows</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — planned outages & alarm suppression</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={load} className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<Button size="sm" onClick={() => { setShowForm(true); setStartDt(defaultStart()); setEndDt(defaultEnd()); }} className="flex items-center gap-1.5">
|
||||
<Plus className="w-3.5 h-3.5" /> New Window
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{!loading && (
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
{active > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span className="font-semibold text-green-400">{active}</span>
|
||||
<span className="text-muted-foreground">active</span>
|
||||
</span>
|
||||
)}
|
||||
{scheduled > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<span className="font-semibold">{scheduled}</span>
|
||||
<span className="text-muted-foreground">scheduled</span>
|
||||
</span>
|
||||
)}
|
||||
{active === 0 && scheduled === 0 && (
|
||||
<span className="text-muted-foreground text-xs">No active or scheduled maintenance</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create form */}
|
||||
{showForm && (
|
||||
<Card className="border-primary/30">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<CalendarClock className="w-4 h-4 text-primary" /> New Maintenance Window
|
||||
</CardTitle>
|
||||
<button onClick={() => setShowForm(false)} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="sm:col-span-2 space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Title *</label>
|
||||
<input
|
||||
required
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="e.g. UPS-01 firmware update"
|
||||
className="w-full h-9 rounded-md border border-border bg-muted/30 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Target</label>
|
||||
<select
|
||||
value={target}
|
||||
onChange={e => setTarget(e.target.value)}
|
||||
className="w-full h-9 rounded-md border border-border bg-muted/30 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{TARGET_GROUPS.map(group => (
|
||||
<optgroup key={group.label} label={group.label}>
|
||||
{group.targets.map(t => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1 flex items-end gap-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer text-sm mb-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={suppress}
|
||||
onChange={e => setSuppress(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-xs font-medium">Suppress alarms</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Start</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
required
|
||||
value={startDt}
|
||||
onChange={e => setStartDt(e.target.value)}
|
||||
className="w-full h-9 rounded-md border border-border bg-muted/30 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">End</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
required
|
||||
value={endDt}
|
||||
onChange={e => setEndDt(e.target.value)}
|
||||
className="w-full h-9 rounded-md border border-border bg-muted/30 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2 space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Notes (optional)</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Reason, affected systems, contacts…"
|
||||
className="w-full rounded-md border border-border bg-muted/30 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setShowForm(false)}>Cancel</Button>
|
||||
<Button type="submit" size="sm" disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create Window"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Windows list */}
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-20" />)}
|
||||
</div>
|
||||
) : windows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3 text-muted-foreground">
|
||||
<CalendarClock className="w-8 h-8 opacity-30" />
|
||||
<p className="text-sm">No maintenance windows</p>
|
||||
<p className="text-xs">Click "New Window" to schedule planned downtime</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 7-day timeline strip */}
|
||||
<TimelineStrip windows={windows} />
|
||||
|
||||
<div className="space-y-3">
|
||||
{[...windows].sort((a, b) => {
|
||||
const order = { active: 0, scheduled: 1, expired: 2 };
|
||||
return (order[a.status] ?? 9) - (order[b.status] ?? 9) || a.start_dt.localeCompare(b.start_dt);
|
||||
}).map(w => (
|
||||
<Card key={w.id} className={cn(
|
||||
"border",
|
||||
w.status === "active" && "border-green-500/30",
|
||||
w.status === "scheduled" && "border-blue-500/20",
|
||||
w.status === "expired" && "opacity-60",
|
||||
)}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm truncate">{w.title}</span>
|
||||
<StatusChip status={w.status} />
|
||||
{w.suppress_alarms && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<BellOff className="w-3 h-3" /> Alarms suppressed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
|
||||
<span>Target: <strong className="text-foreground">{w.target_label}</strong></span>
|
||||
<span>{formatDt(w.start_dt)} → {formatDt(w.end_dt)}</span>
|
||||
</div>
|
||||
{w.notes && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{w.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive shrink-0"
|
||||
disabled={deleting === w.id}
|
||||
onClick={() => handleDelete(w.id)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
256
frontend/app/(dashboard)/network/page.tsx
Normal file
256
frontend/app/(dashboard)/network/page.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { fetchNetworkStatus, type NetworkSwitchStatus } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Network, Wifi, WifiOff, AlertTriangle, CheckCircle2,
|
||||
RefreshCw, Cpu, HardDrive, Thermometer, Activity,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
function formatUptime(seconds: number | null): string {
|
||||
if (seconds === null) return "—";
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
function StateChip({ state }: { state: NetworkSwitchStatus["state"] }) {
|
||||
const cfg = {
|
||||
up: { label: "Up", icon: CheckCircle2, cls: "bg-green-500/10 text-green-400 border-green-500/20" },
|
||||
degraded: { label: "Degraded", icon: AlertTriangle, cls: "bg-amber-500/10 text-amber-400 border-amber-500/20" },
|
||||
down: { label: "Down", icon: WifiOff, cls: "bg-destructive/10 text-destructive border-destructive/20" },
|
||||
unknown: { label: "Unknown", icon: WifiOff, cls: "bg-muted/50 text-muted-foreground border-border" },
|
||||
}[state] ?? { label: state, icon: WifiOff, cls: "bg-muted/50 text-muted-foreground border-border" };
|
||||
const Icon = cfg.icon;
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wide border", cfg.cls)}>
|
||||
<Icon className="w-3 h-3" /> {cfg.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniBar({ value, max, className }: { value: number; max: number; className?: string }) {
|
||||
const pct = Math.min(100, (value / max) * 100);
|
||||
return (
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div className={cn("h-full rounded-full transition-all", className)} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitchCard({ sw }: { sw: NetworkSwitchStatus }) {
|
||||
const portPct = sw.active_ports !== null ? Math.round((sw.active_ports / sw.port_count) * 100) : null;
|
||||
const stateOk = sw.state === "up";
|
||||
const stateDeg = sw.state === "degraded";
|
||||
|
||||
return (
|
||||
<Card className={cn(
|
||||
"border",
|
||||
sw.state === "down" && "border-destructive/40",
|
||||
sw.state === "degraded" && "border-amber-500/30",
|
||||
)}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-0.5 min-w-0">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Network className={cn(
|
||||
"w-4 h-4 shrink-0",
|
||||
stateOk ? "text-green-400" : stateDeg ? "text-amber-400" : "text-destructive"
|
||||
)} />
|
||||
<span className="truncate">{sw.name}</span>
|
||||
</CardTitle>
|
||||
<p className="text-[10px] text-muted-foreground font-mono">{sw.model}</p>
|
||||
</div>
|
||||
<StateChip state={sw.state} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground mt-1">
|
||||
<span className="capitalize">{sw.role}</span>
|
||||
<span>·</span>
|
||||
<span>{sw.room_id}</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono">{sw.rack_id}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Ports headline */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide">Ports Active</p>
|
||||
<p className="text-base font-bold tabular-nums leading-tight">
|
||||
{sw.active_ports !== null ? Math.round(sw.active_ports) : "—"} / {sw.port_count}
|
||||
</p>
|
||||
{portPct !== null && <p className="text-[10px] text-muted-foreground">{portPct}% utilised</p>}
|
||||
</div>
|
||||
</div>
|
||||
<MiniBar
|
||||
value={sw.active_ports ?? 0}
|
||||
max={sw.port_count}
|
||||
className={portPct !== null && portPct >= 90 ? "bg-amber-500" : "bg-primary"}
|
||||
/>
|
||||
|
||||
{/* Bandwidth */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||
<Activity className="w-3 h-3" /> Ingress
|
||||
</p>
|
||||
<p className="text-sm font-semibold tabular-nums">
|
||||
{sw.bandwidth_in_mbps !== null ? `${sw.bandwidth_in_mbps.toFixed(0)} Mbps` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||
<Activity className="w-3 h-3 rotate-180" /> Egress
|
||||
</p>
|
||||
<p className="text-sm font-semibold tabular-nums">
|
||||
{sw.bandwidth_out_mbps !== null ? `${sw.bandwidth_out_mbps.toFixed(0)} Mbps` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CPU + Mem */}
|
||||
<div className="border-t border-border/40 pt-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="w-3 h-3 text-muted-foreground shrink-0" />
|
||||
<span className="text-[10px] text-muted-foreground w-8">CPU</span>
|
||||
<MiniBar
|
||||
value={sw.cpu_pct ?? 0}
|
||||
max={100}
|
||||
className={
|
||||
(sw.cpu_pct ?? 0) >= 80 ? "bg-destructive" :
|
||||
(sw.cpu_pct ?? 0) >= 60 ? "bg-amber-500" : "bg-green-500"
|
||||
}
|
||||
/>
|
||||
<span className="text-xs font-semibold tabular-nums w-10 text-right">
|
||||
{sw.cpu_pct !== null ? `${sw.cpu_pct.toFixed(0)}%` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="w-3 h-3 text-muted-foreground shrink-0" />
|
||||
<span className="text-[10px] text-muted-foreground w-8">Mem</span>
|
||||
<MiniBar
|
||||
value={sw.mem_pct ?? 0}
|
||||
max={100}
|
||||
className={
|
||||
(sw.mem_pct ?? 0) >= 85 ? "bg-destructive" :
|
||||
(sw.mem_pct ?? 0) >= 70 ? "bg-amber-500" : "bg-blue-500"
|
||||
}
|
||||
/>
|
||||
<span className="text-xs font-semibold tabular-nums w-10 text-right">
|
||||
{sw.mem_pct !== null ? `${sw.mem_pct.toFixed(0)}%` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer stats */}
|
||||
<div className="flex items-center justify-between pt-1 border-t border-border/50 text-[10px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Thermometer className="w-3 h-3" />
|
||||
{sw.temperature_c !== null ? `${sw.temperature_c.toFixed(0)}°C` : "—"}
|
||||
</span>
|
||||
<span>
|
||||
Pkt loss: <span className={cn(
|
||||
"font-semibold",
|
||||
(sw.packet_loss_pct ?? 0) > 1 ? "text-destructive" :
|
||||
(sw.packet_loss_pct ?? 0) > 0.1 ? "text-amber-400" : "text-green-400"
|
||||
)}>
|
||||
{sw.packet_loss_pct !== null ? `${sw.packet_loss_pct.toFixed(2)}%` : "—"}
|
||||
</span>
|
||||
</span>
|
||||
<span>Up: {formatUptime(sw.uptime_s)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NetworkPage() {
|
||||
const [switches, setSwitches] = useState<NetworkSwitchStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchNetworkStatus(SITE_ID);
|
||||
setSwitches(data);
|
||||
} catch { toast.error("Failed to load network data"); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
const down = switches.filter((s) => s.state === "down").length;
|
||||
const degraded = switches.filter((s) => s.state === "degraded").length;
|
||||
const up = switches.filter((s) => s.state === "up").length;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Network Infrastructure</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — switch health · refreshes every 30s</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={load}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary chips */}
|
||||
{!loading && switches.length > 0 && (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span className="font-semibold">{up}</span>
|
||||
<span className="text-muted-foreground">up</span>
|
||||
</span>
|
||||
{degraded > 0 && (
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-500" />
|
||||
<span className="font-semibold text-amber-400">{degraded}</span>
|
||||
<span className="text-muted-foreground">degraded</span>
|
||||
</span>
|
||||
)}
|
||||
{down > 0 && (
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<span className="w-2 h-2 rounded-full bg-destructive" />
|
||||
<span className="font-semibold text-destructive">{down}</span>
|
||||
<span className="text-muted-foreground">down</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground ml-2">{switches.length} switches total</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Switch cards */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-64" />)}
|
||||
</div>
|
||||
) : switches.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3 text-muted-foreground">
|
||||
<Wifi className="w-8 h-8 opacity-30" />
|
||||
<p className="text-sm">No network switch data available</p>
|
||||
<p className="text-xs text-center">Ensure the simulator is running and network bots are publishing data</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{switches.map((sw) => <SwitchCard key={sw.switch_id} sw={sw} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
858
frontend/app/(dashboard)/power/page.tsx
Normal file
858
frontend/app/(dashboard)/power/page.tsx
Normal file
|
|
@ -0,0 +1,858 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
fetchKpis, fetchRackBreakdown, fetchRoomPowerHistory, fetchUpsStatus, fetchCapacitySummary,
|
||||
fetchGeneratorStatus, fetchAtsStatus, fetchPowerRedundancy, fetchPhaseBreakdown,
|
||||
type KpiData, type RoomPowerBreakdown, type PowerHistoryBucket, type UpsAsset, type CapacitySummary,
|
||||
type GeneratorStatus, type AtsStatus, type PowerRedundancy, type RoomPhase,
|
||||
} from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
|
||||
AreaChart, Area, Cell,
|
||||
} from "recharts";
|
||||
import { Zap, Battery, AlertTriangle, CheckCircle2, Activity, Fuel, ArrowLeftRight, ShieldCheck, Server, Thermometer, Gauge } from "lucide-react";
|
||||
import { UpsDetailSheet } from "@/components/dashboard/ups-detail-sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
const ROOM_COLORS: Record<string, string> = {
|
||||
"hall-a": "oklch(0.62 0.17 212)",
|
||||
"hall-b": "oklch(0.7 0.15 162)",
|
||||
};
|
||||
|
||||
const roomLabels: Record<string, string> = {
|
||||
"hall-a": "Hall A",
|
||||
"hall-b": "Hall B",
|
||||
};
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
// ── Site capacity bar ─────────────────────────────────────────────────────────
|
||||
|
||||
function SiteCapacityBar({ usedKw, capacityKw }: { usedKw: number; capacityKw: number }) {
|
||||
const pct = capacityKw > 0 ? Math.min(100, (usedKw / capacityKw) * 100) : 0;
|
||||
const barColor =
|
||||
pct >= 85 ? "bg-destructive" :
|
||||
pct >= 70 ? "bg-amber-500" :
|
||||
"bg-primary";
|
||||
const textColor =
|
||||
pct >= 85 ? "text-destructive" :
|
||||
pct >= 70 ? "text-amber-400" :
|
||||
"text-green-400";
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-muted/10 px-5 py-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Zap className="w-4 h-4 text-primary" />
|
||||
Site IT Load vs Rated Capacity
|
||||
</div>
|
||||
<span className={cn("text-xs font-semibold", textColor)}>
|
||||
{pct.toFixed(1)}% utilised
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-700", barColor)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
<strong className="text-foreground">{usedKw.toFixed(1)} kW</strong> in use
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-foreground">{(capacityKw - usedKw).toFixed(1)} kW</strong> headroom
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-foreground">{capacityKw.toFixed(0)} kW</strong> rated
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── KPI card ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function KpiCard({ label, value, sub, icon: Icon, accent }: {
|
||||
label: string; value: string; sub?: string; icon: React.ElementType; accent?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className={cn("p-2 rounded-lg", accent ? "bg-primary/10" : "bg-muted")}>
|
||||
<Icon className={cn("w-4 h-4", accent ? "text-primary" : "text-muted-foreground")} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
{sub && <p className="text-[10px] text-muted-foreground">{sub}</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── UPS card ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function UpsCard({ ups, onClick }: { ups: UpsAsset; onClick: () => void }) {
|
||||
const onBattery = ups.state === "battery";
|
||||
const overload = ups.state === "overload";
|
||||
const abnormal = onBattery || overload;
|
||||
const charge = ups.charge_pct ?? 0;
|
||||
const runtime = ups.runtime_min ?? null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn("border cursor-pointer hover:border-primary/40 transition-colors",
|
||||
overload && "border-destructive/50",
|
||||
onBattery && !overload && "border-amber-500/40",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Battery className="w-4 h-4 text-primary" />
|
||||
{ups.ups_id.toUpperCase()}
|
||||
</CardTitle>
|
||||
<span className={cn(
|
||||
"flex items-center gap-1.5 text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
|
||||
overload ? "bg-destructive/10 text-destructive" :
|
||||
onBattery ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-green-500/10 text-green-400"
|
||||
)}>
|
||||
{abnormal ? <AlertTriangle className="w-3 h-3" /> : <CheckCircle2 className="w-3 h-3" />}
|
||||
{overload ? "Overloaded" : onBattery ? "On Battery" : "Mains"}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Battery charge */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[11px] text-muted-foreground">
|
||||
<span>Battery charge</span>
|
||||
<span className="font-medium text-foreground">{ups.charge_pct !== null ? `${ups.charge_pct}%` : "—"}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-500",
|
||||
charge < 50 ? "bg-destructive" : charge < 80 ? "bg-amber-500" : "bg-green-500"
|
||||
)}
|
||||
style={{ width: `${charge}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-xs">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Load</p>
|
||||
<p className={cn("font-semibold",
|
||||
(ups.load_pct ?? 0) >= 95 ? "text-destructive" :
|
||||
(ups.load_pct ?? 0) >= 85 ? "text-amber-400" : "",
|
||||
)}>
|
||||
{ups.load_pct !== null ? `${ups.load_pct}%` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Runtime</p>
|
||||
<p className={cn(
|
||||
"font-semibold",
|
||||
runtime !== null && runtime < 5 ? "text-destructive" :
|
||||
runtime !== null && runtime < 15 ? "text-amber-400" : ""
|
||||
)}>
|
||||
{runtime !== null ? `${Math.round(runtime)} min` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Voltage</p>
|
||||
<p className={cn("font-semibold",
|
||||
ups.voltage_v !== null && (ups.voltage_v < 210 || ups.voltage_v > 250) ? "text-amber-400" : "",
|
||||
)}>
|
||||
{ups.voltage_v !== null ? `${ups.voltage_v} V` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Runtime bar */}
|
||||
{runtime !== null && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>Est. runtime remaining</span>
|
||||
<span className={cn(
|
||||
"font-medium",
|
||||
runtime < 5 ? "text-destructive" :
|
||||
runtime < 15 ? "text-amber-400" : "text-green-400"
|
||||
)}>
|
||||
{runtime < 5 ? "Critical" : runtime < 15 ? "Low" : "OK"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-500",
|
||||
runtime < 5 ? "bg-destructive" :
|
||||
runtime < 15 ? "bg-amber-500" : "bg-green-500"
|
||||
)}
|
||||
style={{ width: `${Math.min(100, (runtime / 120) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-muted-foreground/50 text-right">Click for details</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Rack bar chart ────────────────────────────────────────────────────────────
|
||||
|
||||
function RackPowerChart({ rooms }: { rooms: RoomPowerBreakdown[] }) {
|
||||
const [activeRoom, setActiveRoom] = useState(rooms[0]?.room_id ?? "");
|
||||
const room = rooms.find((r) => r.room_id === activeRoom);
|
||||
|
||||
if (!room) return null;
|
||||
|
||||
const maxKw = Math.max(...room.racks.map((r) => r.power_kw ?? 0), 1);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Per-Rack Power (kW)</CardTitle>
|
||||
<Tabs value={activeRoom} onValueChange={setActiveRoom}>
|
||||
<TabsList className="h-7">
|
||||
{rooms.map((r) => (
|
||||
<TabsTrigger key={r.room_id} value={r.room_id} className="text-xs px-2 py-0.5">
|
||||
{roomLabels[r.room_id] ?? r.room_id}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3 mb-3 text-[10px] text-muted-foreground">
|
||||
{[
|
||||
{ color: "oklch(0.62 0.17 212)", label: "Normal" },
|
||||
{ color: "oklch(0.68 0.14 162)", label: "Moderate" },
|
||||
{ color: "oklch(0.65 0.20 45)", label: "High (≥7.5 kW)" },
|
||||
{ color: "oklch(0.55 0.22 25)", label: "Critical (≥9.5 kW)" },
|
||||
].map(({ color, label }) => (
|
||||
<span key={label} className="flex items-center gap-1">
|
||||
<span className="w-3 h-2 rounded-sm inline-block" style={{ backgroundColor: color }} />
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={room.racks} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
|
||||
<XAxis
|
||||
dataKey="rack_id"
|
||||
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) => v.replace("rack-", "")}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
domain={[0, Math.ceil(maxKw * 1.2)]}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "12px" }}
|
||||
formatter={(v) => [`${v} kW`, "Power"]}
|
||||
labelFormatter={(l) => l}
|
||||
/>
|
||||
<ReferenceLine y={7.5} stroke="oklch(0.72 0.18 84)" strokeDasharray="4 4" strokeWidth={1}
|
||||
label={{ value: "Warn 7.5kW", fontSize: 9, fill: "oklch(0.72 0.18 84)", position: "insideTopRight" }} />
|
||||
<ReferenceLine y={9.5} stroke="oklch(0.55 0.22 25)" strokeDasharray="4 4" strokeWidth={1}
|
||||
label={{ value: "Crit 9.5kW", fontSize: 9, fill: "oklch(0.55 0.22 25)", position: "insideTopRight" }} />
|
||||
<Bar dataKey="power_kw" radius={[3, 3, 0, 0]} maxBarSize={32}>
|
||||
{room.racks.map((r) => (
|
||||
<Cell
|
||||
key={r.rack_id}
|
||||
fill={
|
||||
(r.power_kw ?? 0) >= 9.5 ? "oklch(0.55 0.22 25)" :
|
||||
(r.power_kw ?? 0) >= 7.5 ? "oklch(0.65 0.20 45)" :
|
||||
(r.power_kw ?? 0) >= 4.0 ? "oklch(0.68 0.14 162)" :
|
||||
ROOM_COLORS[room.room_id] ?? "oklch(0.62 0.17 212)"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Room power history chart ──────────────────────────────────────────────────
|
||||
|
||||
function RoomPowerHistoryChart({ data }: { data: PowerHistoryBucket[] }) {
|
||||
type Row = { time: string; [room: string]: string | number };
|
||||
const bucketMap = new Map<string, Row>();
|
||||
for (const row of data) {
|
||||
const time = formatTime(row.bucket);
|
||||
if (!bucketMap.has(time)) bucketMap.set(time, { time });
|
||||
bucketMap.get(time)![row.room_id] = row.total_kw;
|
||||
}
|
||||
const chartData = Array.from(bucketMap.values());
|
||||
const roomIds = [...new Set(data.map((d) => d.room_id))].sort();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Power by Room</CardTitle>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{roomIds.map((id) => (
|
||||
<span key={id} className="flex items-center gap-1">
|
||||
<span className="w-3 h-0.5 inline-block" style={{ backgroundColor: ROOM_COLORS[id] }} />
|
||||
{roomLabels[id] ?? id}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chartData.length === 0 ? (
|
||||
<div className="h-[200px] flex items-center justify-center text-sm text-muted-foreground">
|
||||
Waiting for data...
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
||||
<defs>
|
||||
{roomIds.map((id) => (
|
||||
<linearGradient key={id} id={`grad-${id}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={ROOM_COLORS[id]} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={ROOM_COLORS[id]} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
|
||||
<YAxis tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "12px" }}
|
||||
formatter={(v, name) => [`${v} kW`, roomLabels[String(name)] ?? String(name)]}
|
||||
/>
|
||||
{roomIds.map((id) => (
|
||||
<Area key={id} type="monotone" dataKey={id} stroke={ROOM_COLORS[id]} fill={`url(#grad-${id})`} strokeWidth={2} dot={false} />
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Generator card ────────────────────────────────────────────────
|
||||
|
||||
function GeneratorCard({ gen }: { gen: GeneratorStatus }) {
|
||||
const faulted = gen.state === "fault";
|
||||
const running = gen.state === "running" || gen.state === "test";
|
||||
const fuel = gen.fuel_pct ?? 0;
|
||||
const stateLabel = { standby: "Standby", running: "Running", test: "Test Run", fault: "FAULT", unknown: "Unknown" }[gen.state] ?? gen.state;
|
||||
|
||||
return (
|
||||
<Card className={cn("border", faulted && "border-destructive/50")}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Fuel className="w-4 h-4 text-primary" />
|
||||
{gen.gen_id.toUpperCase()}
|
||||
</CardTitle>
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
|
||||
faulted ? "bg-destructive/10 text-destructive" :
|
||||
running ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-green-500/10 text-green-400",
|
||||
)}>
|
||||
{faulted ? <AlertTriangle className="w-3 h-3 inline mr-0.5" /> : <CheckCircle2 className="w-3 h-3 inline mr-0.5" />}
|
||||
{stateLabel}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[11px] text-muted-foreground">
|
||||
<span>Fuel level</span>
|
||||
<span className={cn("font-medium", fuel < 10 ? "text-destructive" : fuel < 25 ? "text-amber-400" : "text-foreground")}>
|
||||
{gen.fuel_pct != null ? `${gen.fuel_pct.toFixed(1)}%` : "—"}
|
||||
{gen.fuel_litres != null ? ` (${gen.fuel_litres.toFixed(0)} L)` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div className={cn("h-full rounded-full transition-all duration-500",
|
||||
fuel < 10 ? "bg-destructive" : fuel < 25 ? "bg-amber-500" : "bg-green-500"
|
||||
)} style={{ width: `${fuel}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div><p className="text-muted-foreground">Load</p>
|
||||
<p className={cn("font-semibold",
|
||||
(gen.load_pct ?? 0) >= 95 ? "text-destructive" :
|
||||
(gen.load_pct ?? 0) >= 85 ? "text-amber-400" : ""
|
||||
)}>
|
||||
{gen.load_kw != null ? `${gen.load_kw} kW (${gen.load_pct}%)` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div><p className="text-muted-foreground">Run hours</p>
|
||||
<p className="font-semibold">{gen.run_hours != null ? `${gen.run_hours.toFixed(0)} h` : "—"}</p></div>
|
||||
<div><p className="text-muted-foreground">Output voltage</p>
|
||||
<p className="font-semibold">{gen.voltage_v != null && gen.voltage_v > 0 ? `${gen.voltage_v} V` : "—"}</p></div>
|
||||
<div><p className="text-muted-foreground">Frequency</p>
|
||||
<p className="font-semibold">{gen.frequency_hz != null && gen.frequency_hz > 0 ? `${gen.frequency_hz} Hz` : "—"}</p></div>
|
||||
<div><p className="text-muted-foreground flex items-center gap-1">
|
||||
<Thermometer className="w-3 h-3" />Coolant
|
||||
</p>
|
||||
<p className={cn("font-semibold",
|
||||
(gen.coolant_temp_c ?? 0) >= 105 ? "text-destructive" :
|
||||
(gen.coolant_temp_c ?? 0) >= 95 ? "text-amber-400" : ""
|
||||
)}>
|
||||
{gen.coolant_temp_c != null ? `${gen.coolant_temp_c}°C` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div><p className="text-muted-foreground flex items-center gap-1">
|
||||
<Thermometer className="w-3 h-3" />Exhaust
|
||||
</p>
|
||||
<p className="font-semibold">{gen.exhaust_temp_c != null && gen.exhaust_temp_c > 0 ? `${gen.exhaust_temp_c}°C` : "—"}</p>
|
||||
</div>
|
||||
<div><p className="text-muted-foreground flex items-center gap-1">
|
||||
<Gauge className="w-3 h-3" />Oil pressure
|
||||
</p>
|
||||
<p className={cn("font-semibold",
|
||||
gen.oil_pressure_bar !== null && gen.oil_pressure_bar < 2 ? "text-destructive" : ""
|
||||
)}>
|
||||
{gen.oil_pressure_bar != null && gen.oil_pressure_bar > 0 ? `${gen.oil_pressure_bar} bar` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div><p className="text-muted-foreground">Battery</p>
|
||||
<p className="font-semibold">{gen.battery_v != null ? `${gen.battery_v} V` : "—"}</p></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ATS card ──────────────────────────────────────────────────────
|
||||
|
||||
function AtsCard({ ats }: { ats: AtsStatus }) {
|
||||
const onGenerator = ats.active_feed === "generator";
|
||||
const transferring = ats.state === "transferring";
|
||||
const feedLabel: Record<string, string> = {
|
||||
"utility-a": "Utility A", "utility-b": "Utility B", "generator": "Generator",
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn("border", onGenerator && "border-amber-500/40")}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<ArrowLeftRight className="w-4 h-4 text-primary" />
|
||||
{ats.ats_id.toUpperCase()}
|
||||
</CardTitle>
|
||||
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
|
||||
transferring ? "bg-amber-500/10 text-amber-400 animate-pulse" :
|
||||
onGenerator ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-green-500/10 text-green-400",
|
||||
)}>
|
||||
{transferring ? "Transferring" : onGenerator ? "Generator feed" : "Stable"}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="text-center rounded-lg bg-muted/20 py-3">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Active Feed</p>
|
||||
<p className={cn("text-xl font-bold", onGenerator ? "text-amber-400" : "text-green-400")}>
|
||||
{feedLabel[ats.active_feed] ?? ats.active_feed}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-[11px]">
|
||||
{[
|
||||
{ label: "Utility A", v: ats.utility_a_v, active: ats.active_feed === "utility-a" },
|
||||
{ label: "Utility B", v: ats.utility_b_v, active: ats.active_feed === "utility-b" },
|
||||
{ label: "Generator", v: ats.generator_v, active: ats.active_feed === "generator" },
|
||||
].map(({ label, v, active }) => (
|
||||
<div key={label} className={cn("rounded-md px-2 py-1.5 text-center",
|
||||
active ? "bg-primary/10 border border-primary/20" : "bg-muted/20",
|
||||
)}>
|
||||
<p className="text-muted-foreground">{label}</p>
|
||||
<p className={cn("font-semibold", active ? "text-foreground" : "text-muted-foreground")}>
|
||||
{v != null && v > 0 ? `${v.toFixed(0)} V` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px] text-muted-foreground border-t border-border/30 pt-2">
|
||||
<span>Transfers: <strong className="text-foreground">{ats.transfer_count}</strong></span>
|
||||
{ats.last_transfer_ms != null && (
|
||||
<span>Last xfer: <strong className="text-foreground">{ats.last_transfer_ms} ms</strong></span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Redundancy banner ─────────────────────────────────────────────
|
||||
|
||||
function RedundancyBanner({ r }: { r: PowerRedundancy }) {
|
||||
const color =
|
||||
r.level === "2N" ? "border-green-500/30 bg-green-500/5 text-green-400" :
|
||||
r.level === "N+1" ? "border-amber-500/30 bg-amber-500/5 text-amber-400" :
|
||||
"border-destructive/30 bg-destructive/5 text-destructive";
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between rounded-xl border px-5 py-3", color)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck className="w-5 h-5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-bold">Power Redundancy: {r.level}</p>
|
||||
<p className="text-xs opacity-70">{r.notes}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-xs opacity-80 space-y-0.5">
|
||||
<p>UPS online: {r.ups_online}/{r.ups_total}</p>
|
||||
<p>Generator: {r.generator_ok ? "available" : "unavailable"}</p>
|
||||
<p>Feed: {r.ats_active_feed ?? "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Phase imbalance table ──────────────────────────────────────────
|
||||
|
||||
function PhaseImbalanceTable({ rooms }: { rooms: RoomPhase[] }) {
|
||||
const allRacks = rooms.flatMap(r => r.racks).filter(r => (r.imbalance_pct ?? 0) > 5);
|
||||
if (allRacks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400" />
|
||||
Phase Imbalance — {allRacks.length} rack{allRacks.length !== 1 ? "s" : ""} flagged
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b border-border/30">
|
||||
<th className="text-left py-1.5 pr-3 font-medium">Rack</th>
|
||||
<th className="text-right py-1.5 pr-3 font-medium">Phase A kW</th>
|
||||
<th className="text-right py-1.5 pr-3 font-medium">Phase B kW</th>
|
||||
<th className="text-right py-1.5 pr-3 font-medium">Phase C kW</th>
|
||||
<th className="text-right py-1.5 font-medium">Imbalance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allRacks.map(rack => {
|
||||
const imb = rack.imbalance_pct ?? 0;
|
||||
const crit = imb >= 15;
|
||||
return (
|
||||
<tr key={rack.rack_id} className="border-b border-border/10">
|
||||
<td className="py-1.5 pr-3 font-medium">{rack.rack_id}</td>
|
||||
<td className="text-right pr-3 tabular-nums">{rack.phase_a_kw?.toFixed(2) ?? "—"}</td>
|
||||
<td className="text-right pr-3 tabular-nums">{rack.phase_b_kw?.toFixed(2) ?? "—"}</td>
|
||||
<td className="text-right pr-3 tabular-nums">{rack.phase_c_kw?.toFixed(2) ?? "—"}</td>
|
||||
<td className={cn("text-right tabular-nums font-semibold", crit ? "text-destructive" : "text-amber-400")}>
|
||||
{imb.toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PowerPage() {
|
||||
const [kpis, setKpis] = useState<KpiData | null>(null);
|
||||
const [racks, setRacks] = useState<RoomPowerBreakdown[]>([]);
|
||||
const [history, setHistory] = useState<PowerHistoryBucket[]>([]);
|
||||
const [ups, setUps] = useState<UpsAsset[]>([]);
|
||||
const [capacity, setCapacity] = useState<CapacitySummary | null>(null);
|
||||
const [generators, setGenerators] = useState<GeneratorStatus[]>([]);
|
||||
const [atsUnits, setAtsUnits] = useState<AtsStatus[]>([]);
|
||||
const [redundancy, setRedundancy] = useState<PowerRedundancy | null>(null);
|
||||
const [phases, setPhases] = useState<RoomPhase[]>([]);
|
||||
const [historyHours, setHistoryHours] = useState(6);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [phaseExpanded, setPhaseExpanded] = useState(false);
|
||||
const [selectedUps, setSelectedUps] = useState<typeof ups[0] | null>(null);
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
try {
|
||||
const h = await fetchRoomPowerHistory(SITE_ID, historyHours);
|
||||
setHistory(h);
|
||||
} catch { /* keep stale */ }
|
||||
}, [historyHours]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [k, r, h, u, cap, g, a, red, ph] = await Promise.all([
|
||||
fetchKpis(SITE_ID),
|
||||
fetchRackBreakdown(SITE_ID),
|
||||
fetchRoomPowerHistory(SITE_ID, historyHours),
|
||||
fetchUpsStatus(SITE_ID),
|
||||
fetchCapacitySummary(SITE_ID),
|
||||
fetchGeneratorStatus(SITE_ID).catch(() => []),
|
||||
fetchAtsStatus(SITE_ID).catch(() => []),
|
||||
fetchPowerRedundancy(SITE_ID).catch(() => null),
|
||||
fetchPhaseBreakdown(SITE_ID).catch(() => []),
|
||||
]);
|
||||
setKpis(k);
|
||||
setRacks(r);
|
||||
setHistory(h);
|
||||
setUps(u);
|
||||
setCapacity(cap);
|
||||
setGenerators(g);
|
||||
setAtsUnits(a);
|
||||
setRedundancy(red);
|
||||
setPhases(ph);
|
||||
} catch {
|
||||
// keep stale data
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [historyHours]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => { loadHistory(); }, [historyHours, loadHistory]);
|
||||
|
||||
const totalKw = kpis?.total_power_kw ?? 0;
|
||||
const hallAKw = racks.find((r) => r.room_id === "hall-a")?.racks.reduce((s, r) => s + (r.power_kw ?? 0), 0) ?? 0;
|
||||
const hallBKw = racks.find((r) => r.room_id === "hall-b")?.racks.reduce((s, r) => s + (r.power_kw ?? 0), 0) ?? 0;
|
||||
const siteCapacity = capacity ? capacity.rooms.reduce((s, r) => s + r.power.capacity_kw, 0) : 0;
|
||||
|
||||
// Phase summary data
|
||||
const allPhaseRacks = phases.flatMap(r => r.racks);
|
||||
const phaseViolations = allPhaseRacks.filter(r => (r.imbalance_pct ?? 0) > 5);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Power Management</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — refreshes every 30s</p>
|
||||
</div>
|
||||
|
||||
{/* Internal anchor sub-nav */}
|
||||
<div className="sticky top-14 z-20 -mx-6 px-6 py-2 bg-background/95 backdrop-blur-sm border-b border-border/30">
|
||||
<nav className="flex gap-1">
|
||||
{[
|
||||
{ label: "Overview", href: "#power-overview" },
|
||||
{ label: "UPS", href: "#power-ups" },
|
||||
{ label: "Generator", href: "#power-generator" },
|
||||
{ label: "Transfer Switch", href: "#power-ats" },
|
||||
{ label: "Phase Analysis", href: "#power-phase" },
|
||||
].map(({ label, href }) => (
|
||||
<a key={href} href={href} className="px-3 py-1 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors">
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Site capacity bar */}
|
||||
<div id="power-overview">
|
||||
{!loading && siteCapacity > 0 && (
|
||||
<SiteCapacityBar usedKw={totalKw} capacityKw={siteCapacity} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* KPIs */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<KpiCard label="Total Site Load" value={`${totalKw} kW`} icon={Zap} accent />
|
||||
<KpiCard label="PUE" value={kpis?.pue.toFixed(2) ?? "—"} icon={Activity} sub="Target: < 1.4" />
|
||||
<KpiCard label="Hall A" value={`${hallAKw.toFixed(1)} kW`} icon={Zap} />
|
||||
<KpiCard label="Hall B" value={`${hallBKw.toFixed(1)} kW`} icon={Zap} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Power path diagram */}
|
||||
{!loading && redundancy && (
|
||||
<div className="rounded-xl border border-border bg-muted/10 px-5 py-3">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider font-semibold mb-3">Power Path</p>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{[
|
||||
{ label: "Grid", icon: Zap, ok: redundancy.ats_active_feed !== "generator" },
|
||||
{ label: "ATS", icon: ArrowLeftRight, ok: true },
|
||||
{ label: "UPS", icon: Battery, ok: redundancy.ups_online > 0 },
|
||||
{ label: "Racks", icon: Server, ok: true },
|
||||
].map(({ label, icon: Icon, ok }, i, arr) => (
|
||||
<React.Fragment key={label}>
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-3 py-2 border text-xs font-medium",
|
||||
ok ? "border-green-500/30 bg-green-500/5 text-green-400" : "border-amber-500/30 bg-amber-500/5 text-amber-400"
|
||||
)}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</div>
|
||||
{i < arr.length - 1 && (
|
||||
<div className="h-px flex-1 min-w-4 border-t border-dashed border-muted-foreground/30" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{redundancy.ats_active_feed === "generator" && (
|
||||
<span className="ml-auto text-[10px] text-amber-400 font-medium flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" /> Running on generator
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Charts */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Power History</p>
|
||||
<select
|
||||
value={historyHours}
|
||||
onChange={(e) => setHistoryHours(Number(e.target.value))}
|
||||
className="text-xs bg-muted border border-border rounded px-2 py-1 text-foreground"
|
||||
>
|
||||
{[1, 3, 6, 12, 24].map((h) => (
|
||||
<option key={h} value={h}>{h}h</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{loading ? (
|
||||
<>
|
||||
<Skeleton className="h-64" />
|
||||
<Skeleton className="h-64" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{racks.length > 0 && <RackPowerChart rooms={racks} />}
|
||||
<RoomPowerHistoryChart data={history} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Redundancy banner */}
|
||||
{!loading && redundancy && <RedundancyBanner r={redundancy} />}
|
||||
|
||||
{/* UPS */}
|
||||
<div id="power-ups">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">UPS Units</h2>
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Skeleton className="h-48" />
|
||||
<Skeleton className="h-48" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{ups.map((u) => (
|
||||
<UpsCard key={u.ups_id} ups={u} onClick={() => setSelectedUps(u)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Generator */}
|
||||
{(loading || generators.length > 0) && (
|
||||
<div id="power-generator">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">Generators</h2>
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"><Skeleton className="h-48" /></div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{generators.map((g) => <GeneratorCard key={g.gen_id} gen={g} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ATS */}
|
||||
{(loading || atsUnits.length > 0) && (
|
||||
<div id="power-ats">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">Transfer Switches</h2>
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"><Skeleton className="h-40" /></div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{atsUnits.map((a) => <AtsCard key={a.ats_id} ats={a} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UPS detail sheet */}
|
||||
<UpsDetailSheet ups={selectedUps} onClose={() => setSelectedUps(null)} />
|
||||
|
||||
{/* Phase analysis — always visible summary */}
|
||||
<div id="power-phase">
|
||||
{!loading && phases.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">Phase Analysis</h2>
|
||||
{phaseViolations.length === 0 ? (
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-green-500/10 text-green-400">
|
||||
<CheckCircle2 className="w-3 h-3" /> Phase balance OK
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setPhaseExpanded(!phaseExpanded)}
|
||||
className="flex items-center gap-1.5 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 transition-colors"
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{phaseViolations.length} rack{phaseViolations.length !== 1 ? "s" : ""} flagged
|
||||
{phaseExpanded ? " — Hide details" : " — Show details"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phase summary row */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(["Phase A", "Phase B", "Phase C"] as const).map((phase, idx) => {
|
||||
const phaseKey = (["phase_a_kw", "phase_b_kw", "phase_c_kw"] as const)[idx];
|
||||
const total = allPhaseRacks.reduce((s, r) => s + (r[phaseKey] ?? 0), 0);
|
||||
return (
|
||||
<div key={phase} className="rounded-lg bg-muted/20 px-4 py-3 text-center">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">{phase}</p>
|
||||
<p className="text-lg font-bold tabular-nums">{total.toFixed(1)} kW</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Expanded violation table */}
|
||||
{phaseExpanded && phaseViolations.length > 0 && (
|
||||
<PhaseImbalanceTable rooms={phases} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
426
frontend/app/(dashboard)/reports/page.tsx
Normal file
426
frontend/app/(dashboard)/reports/page.tsx
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { fetchReportSummary, fetchKpis, fetchAlarmStats, fetchEnergyReport, reportExportUrl, type ReportSummary, type KpiData, type AlarmStats, type EnergyReport } from "@/lib/api";
|
||||
import { PageShell } from "@/components/layout/page-shell";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Zap, Thermometer, Bell, AlertTriangle, CheckCircle2, Clock,
|
||||
Download, Wind, Battery, RefreshCw, Activity, DollarSign,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
function UptimeBar({ pct }: { pct: number }) {
|
||||
const color = pct >= 99 ? "bg-green-500" : pct >= 95 ? "bg-amber-500" : "bg-destructive";
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div className={cn("h-full rounded-full transition-all", color)} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-sm font-bold tabular-nums w-16 text-right",
|
||||
pct >= 99 ? "text-green-400" : pct >= 95 ? "text-amber-400" : "text-destructive"
|
||||
)}>
|
||||
{pct.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExportCard({ hours, setHours }: { hours: number; setHours: (h: number) => void }) {
|
||||
const exports: { label: string; type: "power" | "temperature" | "alarms"; icon: React.ElementType }[] = [
|
||||
{ label: "Power History", type: "power", icon: Zap },
|
||||
{ label: "Temperature History", type: "temperature", icon: Thermometer },
|
||||
{ label: "Alarm Log", type: "alarms", icon: Bell },
|
||||
];
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Download className="w-4 h-4 text-primary" /> Export Data
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
{([24, 48, 168] as const).map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => setHours(h)}
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded text-xs font-medium transition-colors",
|
||||
hours === h ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{h === 168 ? "7d" : `${h}h`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{exports.map(({ label, type, icon: Icon }) => (
|
||||
<a
|
||||
key={type}
|
||||
href={reportExportUrl(type, SITE_ID, hours)}
|
||||
download
|
||||
className="flex items-center justify-between rounded-lg border border-border px-3 py-2.5 hover:bg-muted/40 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Icon className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
<span>{label}</span>
|
||||
{type !== "alarms" && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(last {hours === 168 ? "7 days" : `${hours}h`})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Download className="w-3.5 h-3.5 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
</a>
|
||||
))}
|
||||
<p className="text-[10px] text-muted-foreground pt-1">CSV format — 5-minute bucketed averages</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const RANGE_HOURS: Record<"24h" | "7d" | "30d", number> = { "24h": 24, "7d": 168, "30d": 720 };
|
||||
const RANGE_DAYS: Record<"24h" | "7d" | "30d", number> = { "24h": 1, "7d": 7, "30d": 30 };
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [summary, setSummary] = useState<ReportSummary | null>(null);
|
||||
const [kpis, setKpis] = useState<KpiData | null>(null);
|
||||
const [alarmStats, setAlarmStats] = useState<AlarmStats | null>(null);
|
||||
const [energy, setEnergy] = useState<EnergyReport | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [exportHours, setExportHours] = useState(720);
|
||||
const [dateRange, setDateRange] = useState<"24h" | "7d" | "30d">("30d");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [s, k, a, e] = await Promise.all([
|
||||
fetchReportSummary(SITE_ID),
|
||||
fetchKpis(SITE_ID),
|
||||
fetchAlarmStats(SITE_ID),
|
||||
fetchEnergyReport(SITE_ID, RANGE_DAYS[dateRange]).catch(() => null),
|
||||
]);
|
||||
setSummary(s);
|
||||
setKpis(k);
|
||||
setAlarmStats(a);
|
||||
setEnergy(e);
|
||||
} catch { toast.error("Failed to load report data"); }
|
||||
finally { setLoading(false); }
|
||||
}, [dateRange]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
function handleRangeChange(r: "24h" | "7d" | "30d") {
|
||||
setDateRange(r);
|
||||
setExportHours(RANGE_HOURS[r]);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell className="p-6">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Reports</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — site summary & data exports</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Date range picker */}
|
||||
<div className="flex items-center gap-0.5 rounded-lg border border-border p-0.5 text-xs">
|
||||
{(["24h", "7d", "30d"] as const).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => handleRangeChange(r)}
|
||||
className={cn(
|
||||
"px-2.5 py-1 rounded-md font-medium transition-colors",
|
||||
dateRange === r ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={load}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export always visible at top */}
|
||||
<ExportCard hours={exportHours} setHours={setExportHours} />
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-48" />)}
|
||||
</div>
|
||||
) : !summary ? (
|
||||
<div className="flex items-center justify-center h-48 text-sm text-muted-foreground">
|
||||
Unable to load report data.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Generated {new Date(summary.generated_at).toLocaleString()} · Showing last {dateRange}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* KPI snapshot — expanded */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-primary" /> Site KPI Snapshot
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" /> Total Power
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{summary.kpis.total_power_kw} <span className="text-sm font-normal text-muted-foreground">kW</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Activity className="w-3 h-3" /> PUE
|
||||
</p>
|
||||
<p className={cn(
|
||||
"text-2xl font-bold",
|
||||
kpis && kpis.pue > 1.5 ? "text-amber-400" : ""
|
||||
)}>
|
||||
{kpis?.pue.toFixed(2) ?? "—"}
|
||||
<span className="text-xs font-normal text-muted-foreground ml-1">target <1.4</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Thermometer className="w-3 h-3" /> Avg Temp
|
||||
</p>
|
||||
<p className={cn(
|
||||
"text-2xl font-bold",
|
||||
summary.kpis.avg_temperature >= 28 ? "text-destructive" :
|
||||
summary.kpis.avg_temperature >= 25 ? "text-amber-400" : ""
|
||||
)}>
|
||||
{summary.kpis.avg_temperature}°<span className="text-sm font-normal text-muted-foreground">C</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Bell className="w-3 h-3" /> Active Alarms
|
||||
</p>
|
||||
<p className={cn("text-2xl font-bold", (alarmStats?.active ?? 0) > 0 ? "text-destructive" : "")}>
|
||||
{alarmStats?.active ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3 text-destructive" /> Critical
|
||||
</p>
|
||||
<p className={cn("text-2xl font-bold", (alarmStats?.critical ?? 0) > 0 ? "text-destructive" : "")}>
|
||||
{alarmStats?.critical ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3 text-green-400" /> Resolved
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-green-400">
|
||||
{alarmStats?.resolved ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Alarm breakdown */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Bell className="w-4 h-4 text-primary" /> Alarm Breakdown
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Active", value: summary.alarm_stats.active, icon: AlertTriangle, color: "text-destructive" },
|
||||
{ label: "Acknowledged", value: summary.alarm_stats.acknowledged, icon: Clock, color: "text-amber-400" },
|
||||
{ label: "Resolved", value: summary.alarm_stats.resolved, icon: CheckCircle2, color: "text-green-400" },
|
||||
].map(({ label, value, icon: Icon, color }) => (
|
||||
<div key={label} className="text-center space-y-1 rounded-lg bg-muted/30 p-3">
|
||||
<Icon className={cn("w-4 h-4 mx-auto", color)} />
|
||||
<p className={cn("text-xl font-bold", value > 0 && label === "Active" ? "text-destructive" : "")}>{value}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-4 pt-3 border-t border-border text-xs">
|
||||
<span className="text-muted-foreground">By severity:</span>
|
||||
<span className="flex items-center gap-1 text-destructive font-medium">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-destructive" />
|
||||
{summary.alarm_stats.critical} critical
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-amber-400 font-medium">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
|
||||
{summary.alarm_stats.warning} warning
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* CRAC uptime */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Wind className="w-4 h-4 text-primary" /> CRAC Uptime (last 24h)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{summary.crac_uptime.map((crac) => (
|
||||
<div key={crac.crac_id} className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="font-medium">{crac.crac_id.toUpperCase()}</span>
|
||||
<span className="text-muted-foreground">{crac.room_id}</span>
|
||||
</div>
|
||||
<UptimeBar pct={crac.uptime_pct} />
|
||||
</div>
|
||||
))}
|
||||
{summary.crac_uptime.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No CRAC data available</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* UPS uptime */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Battery className="w-4 h-4 text-primary" /> UPS Uptime (last 24h)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{summary.ups_uptime.map((ups) => (
|
||||
<div key={ups.ups_id} className="space-y-1.5">
|
||||
<div className="text-xs font-medium">{ups.ups_id.toUpperCase()}</div>
|
||||
<UptimeBar pct={ups.uptime_pct} />
|
||||
</div>
|
||||
))}
|
||||
{summary.ups_uptime.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No UPS data available</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Energy cost section */}
|
||||
{energy && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-primary" /> Energy Cost — Last {energy.period_days} Days
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Total kWh</p>
|
||||
<p className="text-2xl font-bold">{energy.kwh_total.toFixed(0)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{energy.from_date} → {energy.to_date}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Cost ({energy.currency})</p>
|
||||
<p className="text-2xl font-bold">${energy.cost_sgd.toLocaleString()}</p>
|
||||
<p className="text-[10px] text-muted-foreground">@ ${energy.tariff_sgd_kwh}/kWh</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Est. Annual kWh</p>
|
||||
<p className="text-2xl font-bold">{(energy.pue_trend.length > 0 ? energy.kwh_total / energy.period_days * 365 : 0).toFixed(0)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">at current pace</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">PUE (estimated)</p>
|
||||
<p className={cn("text-2xl font-bold", energy.pue_estimated > 1.5 ? "text-amber-400" : "")}>{energy.pue_estimated.toFixed(2)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">target < 1.4</p>
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const trend = energy.pue_trend ?? [];
|
||||
const thisWeek = trend.slice(-7);
|
||||
const lastWeek = trend.slice(-14, -7);
|
||||
const thisKwh = thisWeek.reduce((s, d) => s + d.avg_it_kw * 24, 0);
|
||||
const lastKwh = lastWeek.reduce((s, d) => s + d.avg_it_kw * 24, 0);
|
||||
const kwhDelta = lastKwh > 0 ? ((thisKwh - lastKwh) / lastKwh * 100) : 0;
|
||||
const thisAvgKw = thisWeek.length > 0 ? thisWeek.reduce((s, d) => s + d.avg_it_kw, 0) / thisWeek.length : 0;
|
||||
const lastAvgKw = lastWeek.length > 0 ? lastWeek.reduce((s, d) => s + d.avg_it_kw, 0) / lastWeek.length : 0;
|
||||
const kwDelta = lastAvgKw > 0 ? ((thisAvgKw - lastAvgKw) / lastAvgKw * 100) : 0;
|
||||
if (thisWeek.length === 0 || lastWeek.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-muted/10 px-4 py-3 flex items-center gap-6 text-xs flex-wrap mb-6">
|
||||
<span className="text-muted-foreground font-medium">This week vs last week:</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">kWh:</span>
|
||||
<span className="font-bold">{thisKwh.toFixed(0)}</span>
|
||||
<span className={cn(
|
||||
"font-semibold",
|
||||
kwhDelta > 5 ? "text-destructive" : kwhDelta < -5 ? "text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
({kwhDelta > 0 ? "+" : ""}{kwhDelta.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">Avg IT load:</span>
|
||||
<span className="font-bold">{thisAvgKw.toFixed(1)} kW</span>
|
||||
<span className={cn(
|
||||
"font-semibold",
|
||||
kwDelta > 5 ? "text-amber-400" : kwDelta < -5 ? "text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
({kwDelta > 0 ? "+" : ""}{kwDelta.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-[10px] ml-auto">based on last {thisWeek.length + lastWeek.length} days of data</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{energy.pue_trend.length > 0 && (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground mb-2 uppercase font-medium tracking-wider">Daily IT Load (kW)</p>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<AreaChart data={energy.pue_trend} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="energy-grad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="oklch(0.62 0.17 212)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="oklch(0.62 0.17 212)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
|
||||
<XAxis dataKey="day" tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false}
|
||||
tickFormatter={(v) => new Date(v).toLocaleDateString([], { month: "short", day: "numeric" })} />
|
||||
<YAxis tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "11px" }}
|
||||
formatter={(v) => [`${Number(v).toFixed(1)} kW`, "Avg IT Load"]}
|
||||
labelFormatter={(l) => new Date(l).toLocaleDateString()}
|
||||
/>
|
||||
<Area type="monotone" dataKey="avg_it_kw" stroke="oklch(0.62 0.17 212)" fill="url(#energy-grad)" strokeWidth={2} dot={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
</>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
468
frontend/app/(dashboard)/settings/page.tsx
Normal file
468
frontend/app/(dashboard)/settings/page.tsx
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Settings, Database, Bell, Globe, Sliders, Plug,
|
||||
Save, CheckCircle2, AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
fetchSiteSettings, updateSiteSettings,
|
||||
fetchNotifications, updateNotifications,
|
||||
fetchIntegrations, updateIntegrations,
|
||||
fetchPagePrefs, updatePagePrefs,
|
||||
type SiteSettings, type NotificationSettings,
|
||||
type IntegrationSettings, type PagePrefs,
|
||||
type SensorDevice,
|
||||
} from "@/lib/api";
|
||||
import { SensorTable } from "@/components/settings/SensorTable";
|
||||
import { SensorSheet } from "@/components/settings/SensorSheet";
|
||||
import { SensorDetailSheet } from "@/components/settings/SensorDetailSheet";
|
||||
import { ThresholdEditor } from "@/components/settings/ThresholdEditor";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function FieldGroup({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{label}</label>
|
||||
{children}
|
||||
{hint && <p className="text-[10px] text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextInput({ value, onChange, disabled, placeholder, type = "text" }: {
|
||||
value: string; onChange?: (v: string) => void; disabled?: boolean; placeholder?: string; type?: string;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
onChange={e => onChange?.(e.target.value)}
|
||||
className="w-full border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberInput({ value, onChange, min, max }: { value: number; onChange: (v: number) => void; min?: number; max?: number }) {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={e => onChange(Number(e.target.value))}
|
||||
className="w-full border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
|
||||
checked ? "bg-primary" : "bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform",
|
||||
checked ? "translate-x-4" : "translate-x-0",
|
||||
)} />
|
||||
</button>
|
||||
{label && <span className="text-sm">{label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveButton({ saving, saved, onClick }: { saving: boolean; saved: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<Button size="sm" onClick={onClick} disabled={saving} className="gap-2">
|
||||
{saving ? (
|
||||
<span className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : saved ? (
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{saving ? "Saving..." : saved ? "Saved" : "Save Changes"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3 border-b border-border/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||
{action}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 space-y-4">
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Site Tab ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function SiteTab() {
|
||||
const [form, setForm] = useState<SiteSettings>({ name: "", timezone: "", description: "" });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSiteSettings(SITE_ID).then(setForm).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await updateSiteSettings(SITE_ID, form);
|
||||
setForm(updated);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} catch { /* ignore */ }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const TIMEZONES = [
|
||||
"Asia/Singapore", "Asia/Tokyo", "Asia/Hong_Kong", "Asia/Kuala_Lumpur",
|
||||
"Europe/London", "Europe/Paris", "America/New_York", "America/Los_Angeles",
|
||||
"UTC",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<SectionCard title="Site Information">
|
||||
<FieldGroup label="Site Name">
|
||||
<TextInput value={form.name} onChange={v => setForm(f => ({ ...f, name: v }))} />
|
||||
</FieldGroup>
|
||||
<FieldGroup label="Description">
|
||||
<TextInput value={form.description} onChange={v => setForm(f => ({ ...f, description: v }))} placeholder="Optional description" />
|
||||
</FieldGroup>
|
||||
<FieldGroup label="Timezone">
|
||||
<select
|
||||
value={form.timezone}
|
||||
onChange={e => setForm(f => ({ ...f, timezone: e.target.value }))}
|
||||
className="w-full border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{TIMEZONES.map(tz => <option key={tz} value={tz}>{tz}</option>)}
|
||||
</select>
|
||||
</FieldGroup>
|
||||
<div className="pt-2">
|
||||
<SaveButton saving={saving} saved={saved} onClick={save} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Site Overview">
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
{[
|
||||
{ label: "Site ID", value: SITE_ID },
|
||||
{ label: "Halls", value: "2 (Hall A, Hall B)" },
|
||||
{ label: "Total Racks", value: "80 (40 per hall)" },
|
||||
{ label: "UPS Units", value: "2" },
|
||||
{ label: "Generators", value: "1" },
|
||||
{ label: "CRAC Units", value: "2" },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<p className="text-muted-foreground">{label}</p>
|
||||
<p className="font-medium mt-0.5">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sensors Tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function SensorsTab() {
|
||||
const [editingSensor, setEditingSensor] = useState<SensorDevice | null>(null);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [detailId, setDetailId] = useState<number | null>(null);
|
||||
const [tableKey, setTableKey] = useState(0); // force re-render after save
|
||||
|
||||
const handleSaved = () => {
|
||||
setAddOpen(false);
|
||||
setEditingSensor(null);
|
||||
setTableKey(k => k + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Device-level sensor registry. Each device can be enabled/disabled, and protocol configuration is stored for all supported connection types. Currently only MQTT is active.
|
||||
</div>
|
||||
|
||||
<SensorTable
|
||||
key={tableKey}
|
||||
onAdd={() => setAddOpen(true)}
|
||||
onEdit={s => setEditingSensor(s)}
|
||||
onDetail={id => setDetailId(id)}
|
||||
/>
|
||||
|
||||
<SensorSheet
|
||||
siteId={SITE_ID}
|
||||
sensor={editingSensor}
|
||||
open={addOpen || !!editingSensor}
|
||||
onClose={() => { setAddOpen(false); setEditingSensor(null); }}
|
||||
onSaved={handleSaved}
|
||||
/>
|
||||
|
||||
<SensorDetailSheet
|
||||
sensorId={detailId}
|
||||
onClose={() => setDetailId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Notifications Tab ──────────────────────────────────────────────────────────
|
||||
|
||||
function NotificationsTab() {
|
||||
const [form, setForm] = useState<NotificationSettings>({
|
||||
critical_alarms: true, warning_alarms: true,
|
||||
generator_events: true, maintenance_reminders: true,
|
||||
webhook_url: "", email_recipients: "",
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications(SITE_ID).then(setForm).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await updateNotifications(SITE_ID, form);
|
||||
setForm(updated);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} catch { /* ignore */ }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const toggles: { key: keyof NotificationSettings; label: string; desc: string }[] = [
|
||||
{ key: "critical_alarms", label: "Critical alarms", desc: "Notify on all critical severity alarms" },
|
||||
{ key: "warning_alarms", label: "Warning alarms", desc: "Notify on warning severity alarms" },
|
||||
{ key: "generator_events", label: "Generator events", desc: "Generator start, stop, and fault events" },
|
||||
{ key: "maintenance_reminders", label: "Maintenance reminders", desc: "Upcoming scheduled maintenance windows" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<SectionCard title="Alarm Notifications">
|
||||
<div className="space-y-4">
|
||||
{toggles.map(({ key, label, desc }) => (
|
||||
<div key={key} className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<p className="text-xs text-muted-foreground">{desc}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={form[key] as boolean}
|
||||
onChange={v => setForm(f => ({ ...f, [key]: v }))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Delivery Channels">
|
||||
<FieldGroup label="Webhook URL" hint="POST request sent on each new alarm — leave blank to disable">
|
||||
<TextInput
|
||||
value={form.webhook_url}
|
||||
onChange={v => setForm(f => ({ ...f, webhook_url: v }))}
|
||||
placeholder="https://hooks.example.com/alarm"
|
||||
type="url"
|
||||
/>
|
||||
</FieldGroup>
|
||||
<FieldGroup label="Email Recipients" hint="Comma-separated email addresses">
|
||||
<TextInput
|
||||
value={form.email_recipients}
|
||||
onChange={v => setForm(f => ({ ...f, email_recipients: v }))}
|
||||
placeholder="ops@example.com, oncall@example.com"
|
||||
/>
|
||||
</FieldGroup>
|
||||
</SectionCard>
|
||||
|
||||
<div>
|
||||
<SaveButton saving={saving} saved={saved} onClick={save} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page Preferences Tab ───────────────────────────────────────────────────────
|
||||
|
||||
function PagePrefsTab() {
|
||||
const [form, setForm] = useState<PagePrefs>({ default_time_range_hours: 6, refresh_interval_seconds: 30 });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPagePrefs(SITE_ID).then(setForm).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await updatePagePrefs(SITE_ID, form);
|
||||
setForm(updated);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} catch { /* ignore */ }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<SectionCard title="Dashboard Defaults">
|
||||
<FieldGroup label="Default Time Range" hint="Used by charts on all pages as the initial view">
|
||||
<select
|
||||
value={form.default_time_range_hours}
|
||||
onChange={e => setForm(f => ({ ...f, default_time_range_hours: Number(e.target.value) }))}
|
||||
className="w-full border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{[1, 3, 6, 12, 24].map(h => <option key={h} value={h}>{h} hour{h !== 1 ? "s" : ""}</option>)}
|
||||
</select>
|
||||
</FieldGroup>
|
||||
<FieldGroup label="Auto-refresh Interval" hint="How often pages poll for new data">
|
||||
<select
|
||||
value={form.refresh_interval_seconds}
|
||||
onChange={e => setForm(f => ({ ...f, refresh_interval_seconds: Number(e.target.value) }))}
|
||||
className="w-full border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{[10, 15, 30, 60, 120].map(s => <option key={s} value={s}>Every {s} seconds</option>)}
|
||||
</select>
|
||||
</FieldGroup>
|
||||
<div className="pt-2">
|
||||
<SaveButton saving={saving} saved={saved} onClick={save} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<div className="rounded-lg border border-border/30 bg-muted/10 px-4 py-3 text-xs text-muted-foreground">
|
||||
Per-page configuration (visible panels, default overlays, etc.) is coming in a future update.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Integrations Tab ───────────────────────────────────────────────────────────
|
||||
|
||||
function IntegrationsTab() {
|
||||
const [form, setForm] = useState<IntegrationSettings>({ mqtt_host: "mqtt", mqtt_port: 1883 });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchIntegrations(SITE_ID).then(setForm).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await updateIntegrations(SITE_ID, form);
|
||||
setForm(updated);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} catch { /* ignore */ }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const future = [
|
||||
{ name: "Slack", desc: "Post alarm notifications to a Slack channel" },
|
||||
{ name: "PagerDuty", desc: "Create incidents for critical alarms" },
|
||||
{ name: "Email SMTP", desc: "Send alarm emails via custom SMTP server" },
|
||||
{ name: "Syslog", desc: "Forward alarms to syslog / SIEM" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<SectionCard title="MQTT Broker">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<FieldGroup label="Host" hint="Broker hostname or IP">
|
||||
<TextInput value={form.mqtt_host} onChange={v => setForm(f => ({ ...f, mqtt_host: v }))} />
|
||||
</FieldGroup>
|
||||
<FieldGroup label="Port">
|
||||
<NumberInput value={form.mqtt_port} onChange={v => setForm(f => ({ ...f, mqtt_port: v }))} min={1} max={65535} />
|
||||
</FieldGroup>
|
||||
</div>
|
||||
<div className="rounded-md bg-amber-500/5 border border-amber-500/20 px-3 py-2 text-xs text-amber-400 flex items-start gap-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
Changing the MQTT broker requires a backend restart to take effect.
|
||||
</div>
|
||||
<div>
|
||||
<SaveButton saving={saving} saved={saved} onClick={save} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Future Integrations">
|
||||
<div className="grid gap-2">
|
||||
{future.map(({ name, desc }) => (
|
||||
<div key={name} className="flex items-center justify-between rounded-lg border border-border/30 bg-muted/10 px-3 py-2.5">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{name}</p>
|
||||
<p className="text-xs text-muted-foreground">{desc}</p>
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
|
||||
Coming soon
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-primary" />
|
||||
Settings
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">Site-wide configuration for Singapore DC01</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="site">
|
||||
<TabsList className="h-9 mb-6">
|
||||
<TabsTrigger value="site" className="gap-1.5 text-xs px-3"><Globe className="w-3.5 h-3.5" />Site</TabsTrigger>
|
||||
<TabsTrigger value="sensors" className="gap-1.5 text-xs px-3"><Database className="w-3.5 h-3.5" />Sensors</TabsTrigger>
|
||||
<TabsTrigger value="thresholds" className="gap-1.5 text-xs px-3"><Sliders className="w-3.5 h-3.5" />Thresholds</TabsTrigger>
|
||||
<TabsTrigger value="notifications" className="gap-1.5 text-xs px-3"><Bell className="w-3.5 h-3.5" />Notifications</TabsTrigger>
|
||||
<TabsTrigger value="page-prefs" className="gap-1.5 text-xs px-3"><Settings className="w-3.5 h-3.5" />Page Prefs</TabsTrigger>
|
||||
<TabsTrigger value="integrations" className="gap-1.5 text-xs px-3"><Plug className="w-3.5 h-3.5" />Integrations</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="site"> <SiteTab /> </TabsContent>
|
||||
<TabsContent value="sensors"> <SensorsTab /> </TabsContent>
|
||||
<TabsContent value="thresholds"> <ThresholdEditor siteId={SITE_ID} /> </TabsContent>
|
||||
<TabsContent value="notifications"> <NotificationsTab /> </TabsContent>
|
||||
<TabsContent value="page-prefs"> <PagePrefsTab /> </TabsContent>
|
||||
<TabsContent value="integrations"> <IntegrationsTab /> </TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
frontend/app/favicon.ico
Normal file
BIN
frontend/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
126
frontend/app/globals.css
Normal file
126
frontend/app/globals.css
Normal 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
46
frontend/app/layout.tsx
Normal 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
5
frontend/app/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
21
frontend/app/sign-in/[[...sign-in]]/page.tsx
Normal file
21
frontend/app/sign-in/[[...sign-in]]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/app/sign-up/[[...sign-up]]/page.tsx
Normal file
21
frontend/app/sign-up/[[...sign-up]]/page.tsx
Normal 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
23
frontend/components.json
Normal 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": {}
|
||||
}
|
||||
124
frontend/components/dashboard/alarm-feed.tsx
Normal file
124
frontend/components/dashboard/alarm-feed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
frontend/components/dashboard/coming-soon.tsx
Normal file
26
frontend/components/dashboard/coming-soon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
473
frontend/components/dashboard/crac-detail-sheet.tsx
Normal file
473
frontend/components/dashboard/crac-detail-sheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
489
frontend/components/dashboard/generator-detail-sheet.tsx
Normal file
489
frontend/components/dashboard/generator-detail-sheet.tsx
Normal 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: 70–90°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: 200–420°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: 40–70°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;
|
||||
77
frontend/components/dashboard/kpi-card.tsx
Normal file
77
frontend/components/dashboard/kpi-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
frontend/components/dashboard/mini-floor-map.tsx
Normal file
144
frontend/components/dashboard/mini-floor-map.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
frontend/components/dashboard/power-trend-chart.tsx
Normal file
58
frontend/components/dashboard/power-trend-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
422
frontend/components/dashboard/rack-detail-sheet.tsx
Normal file
422
frontend/components/dashboard/rack-detail-sheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
frontend/components/dashboard/room-status-grid.tsx
Normal file
97
frontend/components/dashboard/room-status-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
frontend/components/dashboard/temperature-trend-chart.tsx
Normal file
75
frontend/components/dashboard/temperature-trend-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
357
frontend/components/dashboard/ups-detail-sheet.tsx
Normal file
357
frontend/components/dashboard/ups-detail-sheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
frontend/components/error-boundary.tsx
Normal file
47
frontend/components/error-boundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
18
frontend/components/layout/page-shell.tsx
Normal file
18
frontend/components/layout/page-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
frontend/components/layout/sidebar.tsx
Normal file
211
frontend/components/layout/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
frontend/components/layout/topbar.tsx
Normal file
100
frontend/components/layout/topbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
289
frontend/components/settings/SensorDetailSheet.tsx
Normal file
289
frontend/components/settings/SensorDetailSheet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
574
frontend/components/settings/SensorSheet.tsx
Normal file
574
frontend/components/settings/SensorSheet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
243
frontend/components/settings/SensorTable.tsx
Normal file
243
frontend/components/settings/SensorTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
719
frontend/components/settings/ThresholdEditor.tsx
Normal file
719
frontend/components/settings/ThresholdEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
281
frontend/components/simulator/SimulatorPanel.tsx
Normal file
281
frontend/components/simulator/SimulatorPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
frontend/components/theme-provider.tsx
Normal file
11
frontend/components/theme-provider.tsx
Normal 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>;
|
||||
}
|
||||
48
frontend/components/ui/badge.tsx
Normal file
48
frontend/components/ui/badge.tsx
Normal 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 }
|
||||
64
frontend/components/ui/button.tsx
Normal file
64
frontend/components/ui/button.tsx
Normal 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 }
|
||||
92
frontend/components/ui/card.tsx
Normal file
92
frontend/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
257
frontend/components/ui/dropdown-menu.tsx
Normal file
257
frontend/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
45
frontend/components/ui/empty-card.tsx
Normal file
45
frontend/components/ui/empty-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
frontend/components/ui/error-card.tsx
Normal file
54
frontend/components/ui/error-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
frontend/components/ui/separator.tsx
Normal file
28
frontend/components/ui/separator.tsx
Normal 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 }
|
||||
143
frontend/components/ui/sheet.tsx
Normal file
143
frontend/components/ui/sheet.tsx
Normal 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,
|
||||
}
|
||||
13
frontend/components/ui/skeleton.tsx
Normal file
13
frontend/components/ui/skeleton.tsx
Normal 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 }
|
||||
48
frontend/components/ui/tabs.tsx
Normal file
48
frontend/components/ui/tabs.tsx
Normal 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 };
|
||||
37
frontend/components/ui/time-range-picker.tsx
Normal file
37
frontend/components/ui/time-range-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
frontend/components/ui/tooltip.tsx
Normal file
57
frontend/components/ui/tooltip.tsx
Normal 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 }
|
||||
18
frontend/eslint.config.mjs
Normal file
18
frontend/eslint.config.mjs
Normal 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;
|
||||
34
frontend/lib/alarm-context.tsx
Normal file
34
frontend/lib/alarm-context.tsx
Normal 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
711
frontend/lib/api.ts
Normal 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 }),
|
||||
})
|
||||
101
frontend/lib/threshold-context.tsx
Normal file
101
frontend/lib/threshold-context.tsx
Normal 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
114
frontend/lib/thresholds.ts
Normal 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
6
frontend/lib/utils.ts
Normal 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
16
frontend/next.config.ts
Normal 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
38
frontend/package.json
Normal 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
8195
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
3
frontend/pnpm-workspace.yaml
Normal file
3
frontend/pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
7
frontend/postcss.config.mjs
Normal file
7
frontend/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
15
frontend/proxy.ts
Normal file
15
frontend/proxy.ts
Normal 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
1
frontend/public/file.svg
Normal 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 |
1
frontend/public/globe.svg
Normal file
1
frontend/public/globe.svg
Normal 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
1
frontend/public/next.svg
Normal 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 |
1
frontend/public/vercel.svg
Normal file
1
frontend/public/vercel.svg
Normal 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 |
1
frontend/public/window.svg
Normal file
1
frontend/public/window.svg
Normal 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
34
frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue