first commit

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,58 @@
"use client";
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { PowerBucket } from "@/lib/api";
interface Props {
data: PowerBucket[];
loading?: boolean;
}
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
export function PowerTrendChart({ data, loading }: Props) {
const chartData = data.map((d) => ({ time: formatTime(d.bucket), power: d.total_kw }));
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold">Total Power (kW)</CardTitle>
<span className="text-xs text-muted-foreground">Last 60 minutes</span>
</div>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-64 w-full" />
) : chartData.length === 0 ? (
<div className="h-[200px] flex items-center justify-center text-sm text-muted-foreground">
Waiting for data...
</div>
) : (
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="powerGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="oklch(0.62 0.17 212)" stopOpacity={0.3} />
<stop offset="95%" stopColor="oklch(0.62 0.17 212)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
<XAxis dataKey="time" tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "12px" }}
formatter={(value) => [`${value} kW`, "Power"]}
/>
<Area type="monotone" dataKey="power" stroke="oklch(0.62 0.17 212)" strokeWidth={2} fill="url(#powerGradient)" dot={false} activeDot={{ r: 4 }} />
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}

View file

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

View file

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

View file

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

View file

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