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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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