first commit
This commit is contained in:
commit
4b98219bf7
144 changed files with 31561 additions and 0 deletions
124
frontend/components/dashboard/alarm-feed.tsx
Normal file
124
frontend/components/dashboard/alarm-feed.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { acknowledgeAlarm, type Alarm } from "@/lib/api";
|
||||
import { useState } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
alarms: Alarm[];
|
||||
loading?: boolean;
|
||||
onAcknowledge?: () => void;
|
||||
onAlarmClick?: (alarm: Alarm) => void;
|
||||
}
|
||||
|
||||
const severityStyles: Record<string, { badge: string; dot: string }> = {
|
||||
critical: { badge: "bg-destructive/20 text-destructive border-destructive/30", dot: "bg-destructive" },
|
||||
warning: { badge: "bg-amber-500/20 text-amber-400 border-amber-500/30", dot: "bg-amber-400" },
|
||||
info: { badge: "bg-primary/20 text-primary border-primary/30", dot: "bg-primary" },
|
||||
};
|
||||
|
||||
function timeAgo(iso: string) {
|
||||
const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (secs < 60) return `${secs}s ago`;
|
||||
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
|
||||
return `${Math.floor(secs / 3600)}h ago`;
|
||||
}
|
||||
|
||||
export function AlarmFeed({ alarms, loading, onAcknowledge, onAlarmClick }: Props) {
|
||||
const [acking, setAcking] = useState<number | null>(null);
|
||||
|
||||
async function handleAck(id: number) {
|
||||
setAcking(id);
|
||||
try {
|
||||
await acknowledgeAlarm(id);
|
||||
onAcknowledge?.();
|
||||
} catch { /* ignore */ }
|
||||
finally { setAcking(null); }
|
||||
}
|
||||
|
||||
const activeCount = alarms.filter((a) => a.state === "active").length;
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Active Alarms</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{!loading && (
|
||||
<Badge variant={activeCount > 0 ? "destructive" : "outline"} className="text-[10px] h-4 px-1.5">
|
||||
{activeCount > 0 ? `${activeCount} active` : "All clear"}
|
||||
</Badge>
|
||||
)}
|
||||
<Link href="/alarms" className="text-[10px] text-muted-foreground hover:text-primary transition-colors">
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 space-y-3 overflow-y-auto max-h-72">
|
||||
{loading ? (
|
||||
<Skeleton className="h-80 w-full" />
|
||||
) : alarms.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-24 text-sm text-muted-foreground">
|
||||
No active alarms
|
||||
</div>
|
||||
) : (
|
||||
alarms.map((alarm) => {
|
||||
const style = severityStyles[alarm.severity] ?? severityStyles.info;
|
||||
const clickable = !!onAlarmClick;
|
||||
const locationLabel = [alarm.rack_id, alarm.room_id].find(Boolean);
|
||||
return (
|
||||
<div
|
||||
key={alarm.id}
|
||||
onClick={() => onAlarmClick?.(alarm)}
|
||||
className={cn(
|
||||
"flex gap-2.5 group rounded-md px-1 py-1 -mx-1 transition-colors",
|
||||
clickable && "cursor-pointer hover:bg-muted/40"
|
||||
)}
|
||||
>
|
||||
<div className="mt-1.5 shrink-0">
|
||||
<span className={cn("block w-2 h-2 rounded-full", style.dot)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs leading-snug text-foreground">{alarm.message}</p>
|
||||
<div className="mt-0.5 flex items-center gap-1.5">
|
||||
{locationLabel && (
|
||||
<span className="text-[10px] text-muted-foreground font-medium">{locationLabel}</span>
|
||||
)}
|
||||
{locationLabel && <span className="text-[10px] text-muted-foreground/50">·</span>}
|
||||
<span className="text-[10px] text-muted-foreground">{timeAgo(alarm.triggered_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
<Badge variant="outline" className={cn("text-[9px] h-4 px-1 uppercase tracking-wide", style.badge)}>
|
||||
{alarm.severity}
|
||||
</Badge>
|
||||
{alarm.state === "active" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 px-1 text-[9px] opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
disabled={acking === alarm.id}
|
||||
onClick={(e) => { e.stopPropagation(); handleAck(alarm.id); }}
|
||||
>
|
||||
Ack
|
||||
</Button>
|
||||
)}
|
||||
{clickable && (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors mt-auto" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
26
frontend/components/dashboard/coming-soon.tsx
Normal file
26
frontend/components/dashboard/coming-soon.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
interface ComingSoonProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
phase: number;
|
||||
}
|
||||
|
||||
export function ComingSoon({ title, description, icon: Icon, phase }: ComingSoonProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 text-center space-y-4">
|
||||
<div className="flex items-center justify-center w-16 h-16 rounded-2xl bg-muted">
|
||||
<Icon className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">{description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary/10 border border-primary/20">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
|
||||
<span className="text-xs text-primary font-medium">Planned for Phase {phase}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
473
frontend/components/dashboard/crac-detail-sheet.tsx
Normal file
473
frontend/components/dashboard/crac-detail-sheet.tsx
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchCracStatus, fetchCracHistory, type CracStatus, type CracHistoryPoint } from "@/lib/api";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { TimeRangePicker } from "@/components/ui/time-range-picker";
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, ReferenceLine,
|
||||
} from "recharts";
|
||||
import { Thermometer, Wind, Zap, Gauge, Settings2, ArrowRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
siteId: string;
|
||||
cracId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function fmt(v: number | null | undefined, dec = 1, unit = "") {
|
||||
if (v == null) return "—";
|
||||
return `${v.toFixed(dec)}${unit}`;
|
||||
}
|
||||
|
||||
function formatTime(iso: string) {
|
||||
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); }
|
||||
catch { return iso; }
|
||||
}
|
||||
|
||||
function FillBar({
|
||||
value, max, color, warn, crit,
|
||||
}: {
|
||||
value: number | null; max: number; color: string; warn?: number; crit?: number;
|
||||
}) {
|
||||
const pct = value != null ? Math.min(100, (value / max) * 100) : 0;
|
||||
const barColor =
|
||||
crit && value != null && value >= crit ? "#ef4444" :
|
||||
warn && value != null && value >= warn ? "#f59e0b" :
|
||||
color;
|
||||
return (
|
||||
<div className="h-1.5 rounded-full bg-muted overflow-hidden w-full">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%`, backgroundColor: barColor }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ icon: Icon, title }: { icon: React.ElementType; title: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mt-5 mb-2">
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
|
||||
return (
|
||||
<div className="flex justify-between items-center py-1.5 border-b border-border/40 last:border-0">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className={cn("text-sm font-mono font-medium", highlight && "text-amber-400")}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RefrigerantRow({ label, value, status }: { label: string; value: string; status: "ok" | "warn" | "crit" }) {
|
||||
return (
|
||||
<div className="flex justify-between items-center py-2 border-b border-border/40 last:border-0">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-mono font-medium">{value}</span>
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full uppercase tracking-wide",
|
||||
status === "ok" ? "bg-green-500/10 text-green-400" :
|
||||
status === "warn" ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-destructive/10 text-destructive",
|
||||
)}>
|
||||
{status === "ok" ? "normal" : status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniChart({
|
||||
data, dataKey, color, label, unit, refLine,
|
||||
}: {
|
||||
data: CracHistoryPoint[];
|
||||
dataKey: keyof CracHistoryPoint;
|
||||
color: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
refLine?: number;
|
||||
}) {
|
||||
const vals = data.map(d => d[dataKey] as number | null).filter(v => v != null) as number[];
|
||||
const last = vals[vals.length - 1];
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<div className="flex justify-between items-baseline mb-1">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-mono font-medium" style={{ color }}>
|
||||
{last != null ? `${last.toFixed(1)}${unit}` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={60}>
|
||||
<LineChart data={data} margin={{ top: 2, right: 4, left: -28, bottom: 0 }}>
|
||||
<XAxis
|
||||
dataKey="bucket"
|
||||
tickFormatter={formatTime}
|
||||
tick={{ fontSize: 9 }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 9 }} domain={["auto", "auto"]} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
|
||||
<Tooltip
|
||||
formatter={(v: unknown) => [`${(v as number).toFixed(1)}${unit}`, label]}
|
||||
labelFormatter={(l: unknown) => formatTime(String(l))}
|
||||
contentStyle={{ fontSize: 11, background: "#1e1e2e", border: "1px solid #333" }}
|
||||
/>
|
||||
{refLine != null && (
|
||||
<ReferenceLine y={refLine} stroke="rgba(251,191,36,0.4)" strokeDasharray="4 2" />
|
||||
)}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
stroke={color}
|
||||
dot={false}
|
||||
strokeWidth={1.5}
|
||||
connectNulls
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Overview tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function OverviewTab({ status }: { status: CracStatus }) {
|
||||
const deltaWarn = (status.delta ?? 0) > 11;
|
||||
const deltaCrit = (status.delta ?? 0) > 14;
|
||||
const capWarn = (status.cooling_capacity_pct ?? 0) > 75;
|
||||
const capCrit = (status.cooling_capacity_pct ?? 0) > 90;
|
||||
const copWarn = (status.cop ?? 99) < 1.5;
|
||||
const filterWarn = (status.filter_dp_pa ?? 0) > 80;
|
||||
const filterCrit = (status.filter_dp_pa ?? 0) > 120;
|
||||
const compWarn = (status.compressor_load_pct ?? 0) > 95;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ── Thermal hero ──────────────────────────────────────────── */}
|
||||
<SectionLabel icon={Thermometer} title="Thermal" />
|
||||
<div className="rounded-lg bg-muted/20 px-4 py-3 mb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Supply</p>
|
||||
<p className="text-2xl font-bold tabular-nums text-blue-400">
|
||||
{fmt(status.supply_temp, 1)}°C
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center gap-1 px-4">
|
||||
<p className={cn(
|
||||
"text-sm font-bold tabular-nums",
|
||||
deltaCrit ? "text-destructive" : deltaWarn ? "text-amber-400" : "text-muted-foreground",
|
||||
)}>
|
||||
ΔT {fmt(status.delta, 1)}°C
|
||||
</p>
|
||||
<div className="flex items-center gap-1 w-full">
|
||||
<div className="flex-1 h-px bg-muted-foreground/30" />
|
||||
<ArrowRight className="w-3 h-3 text-muted-foreground/50 shrink-0" />
|
||||
<div className="flex-1 h-px bg-muted-foreground/30" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Return</p>
|
||||
<p className={cn(
|
||||
"text-2xl font-bold tabular-nums",
|
||||
deltaCrit ? "text-destructive" : deltaWarn ? "text-amber-400" : "text-orange-400",
|
||||
)}>
|
||||
{fmt(status.return_temp, 1)}°C
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg px-3 py-1 mb-3">
|
||||
<StatRow label="Supply Humidity" value={fmt(status.supply_humidity, 0, "%")} />
|
||||
<StatRow label="Return Humidity" value={fmt(status.return_humidity, 0, "%")} />
|
||||
<StatRow label="Airflow" value={status.airflow_cfm != null ? `${Math.round(status.airflow_cfm).toLocaleString()} CFM` : "—"} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-[10px] mb-1">
|
||||
<span className="text-muted-foreground">Filter ΔP</span>
|
||||
<span className={cn(
|
||||
"font-mono",
|
||||
filterCrit ? "text-destructive" : filterWarn ? "text-amber-400" : "text-foreground",
|
||||
)}>
|
||||
{fmt(status.filter_dp_pa, 0)} Pa
|
||||
{!filterWarn && <span className="text-green-400 ml-1.5">✓</span>}
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={status.filter_dp_pa} max={150} color="#94a3b8" warn={80} crit={120} />
|
||||
</div>
|
||||
|
||||
{/* ── Cooling capacity ──────────────────────────────────────── */}
|
||||
<SectionLabel icon={Gauge} title="Cooling Capacity" />
|
||||
<div className="mb-1.5">
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
<span className={cn(
|
||||
"text-sm font-bold tabular-nums",
|
||||
capCrit ? "text-destructive" : capWarn ? "text-amber-400" : "text-foreground",
|
||||
)}>
|
||||
{fmt(status.cooling_capacity_kw, 1)} kW
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">of {status.rated_capacity_kw} kW rated</span>
|
||||
</div>
|
||||
<FillBar value={status.cooling_capacity_pct} max={100} color="#34d399" warn={75} crit={90} />
|
||||
<p className={cn(
|
||||
"text-[10px] mt-1 text-right",
|
||||
capCrit ? "text-destructive" : capWarn ? "text-amber-400" : "text-muted-foreground",
|
||||
)}>
|
||||
{fmt(status.cooling_capacity_pct, 1)}% utilised
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded-lg px-3 py-1">
|
||||
<StatRow label="COP" value={fmt(status.cop, 2)} highlight={copWarn} />
|
||||
<StatRow label="Sensible Heat Ratio" value={fmt(status.sensible_heat_ratio, 2)} />
|
||||
</div>
|
||||
|
||||
{/* ── Compressor ────────────────────────────────────────────── */}
|
||||
<SectionLabel icon={Settings2} title="Compressor" />
|
||||
<div className="mb-2">
|
||||
<div className="flex justify-between text-[10px] mb-1">
|
||||
<span className="text-muted-foreground">
|
||||
Load
|
||||
<span className={cn(
|
||||
"ml-2 font-semibold px-1.5 py-0.5 rounded-full",
|
||||
status.compressor_state === 1
|
||||
? "bg-green-500/10 text-green-400"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}>
|
||||
{status.compressor_state === 1 ? "● Running" : "○ Off"}
|
||||
</span>
|
||||
</span>
|
||||
<span className={cn("font-mono", compWarn ? "text-amber-400" : "text-foreground")}>
|
||||
{fmt(status.compressor_load_pct, 1)}%
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={status.compressor_load_pct} max={100} color="#e879f9" warn={80} crit={95} />
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded-lg px-3 py-1">
|
||||
<StatRow label="Power" value={fmt(status.compressor_power_kw, 2, " kW")} />
|
||||
<StatRow label="Run Hours" value={status.compressor_run_hours != null ? status.compressor_run_hours.toLocaleString() + " h" : "—"} />
|
||||
</div>
|
||||
|
||||
{/* ── Fan ───────────────────────────────────────────────────── */}
|
||||
<SectionLabel icon={Wind} title="Fan" />
|
||||
<div className="mb-2">
|
||||
<div className="flex justify-between text-[10px] mb-1">
|
||||
<span className="text-muted-foreground">Speed</span>
|
||||
<span className="font-mono text-foreground">
|
||||
{fmt(status.fan_pct, 1)}%
|
||||
{status.fan_rpm != null ? ` · ${Math.round(status.fan_rpm).toLocaleString()} rpm` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={status.fan_pct} max={100} color="#60a5fa" />
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded-lg px-3 py-1">
|
||||
<StatRow label="Fan Power" value={fmt(status.fan_power_kw, 2, " kW")} />
|
||||
<StatRow label="Run Hours" value={status.fan_run_hours != null ? status.fan_run_hours.toLocaleString() + " h" : "—"} />
|
||||
</div>
|
||||
|
||||
{/* ── Electrical ────────────────────────────────────────────── */}
|
||||
<SectionLabel icon={Zap} title="Electrical" />
|
||||
<div className="bg-muted/30 rounded-lg px-3 py-1">
|
||||
<StatRow label="Total Unit Power" value={fmt(status.total_unit_power_kw, 2, " kW")} />
|
||||
<StatRow label="Input Voltage" value={fmt(status.input_voltage_v, 1, " V")} />
|
||||
<StatRow label="Input Current" value={fmt(status.input_current_a, 1, " A")} />
|
||||
<StatRow label="Power Factor" value={fmt(status.power_factor, 3)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Refrigerant tab ───────────────────────────────────────────────────────────
|
||||
|
||||
function RefrigerantTab({ status }: { status: CracStatus }) {
|
||||
const hiP = status.high_pressure_bar ?? 0;
|
||||
const loP = status.low_pressure_bar ?? 99;
|
||||
const sh = status.discharge_superheat_c ?? 0;
|
||||
const sc = status.liquid_subcooling_c ?? 0;
|
||||
const load = status.compressor_load_pct ?? 0;
|
||||
|
||||
// Normal ranges for R410A-style DX unit
|
||||
const hiPStatus: "ok" | "warn" | "crit" = hiP > 22 ? "crit" : hiP > 20 ? "warn" : "ok";
|
||||
const loPStatus: "ok" | "warn" | "crit" = loP < 3 ? "crit" : loP < 4 ? "warn" : "ok";
|
||||
const shStatus: "ok" | "warn" | "crit" = sh > 16 ? "warn" : sh < 4 ? "warn" : "ok";
|
||||
const scStatus: "ok" | "warn" | "crit" = sc < 2 ? "warn" : "ok";
|
||||
const ldStatus: "ok" | "warn" | "crit" = load > 95 ? "warn" : "ok";
|
||||
|
||||
const compRunning = status.compressor_state === 1;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mt-4 mb-4 leading-relaxed">
|
||||
Refrigerant circuit data for the DX cooling system. Pressures assume an R410A charge.
|
||||
Values outside normal range are flagged automatically.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg bg-muted/20 px-3 mb-4">
|
||||
<div className="flex justify-between items-center py-3 border-b border-border/30">
|
||||
<span className="text-xs text-muted-foreground">Compressor State</span>
|
||||
<span className={cn(
|
||||
"text-xs font-semibold px-2 py-0.5 rounded-full",
|
||||
compRunning ? "bg-green-500/10 text-green-400" : "bg-muted text-muted-foreground",
|
||||
)}>
|
||||
{compRunning ? "● Running" : "○ Off"}
|
||||
</span>
|
||||
</div>
|
||||
<RefrigerantRow
|
||||
label="High Side Pressure"
|
||||
value={fmt(status.high_pressure_bar, 2, " bar")}
|
||||
status={hiPStatus}
|
||||
/>
|
||||
<RefrigerantRow
|
||||
label="Low Side Pressure"
|
||||
value={fmt(status.low_pressure_bar, 2, " bar")}
|
||||
status={loPStatus}
|
||||
/>
|
||||
<RefrigerantRow
|
||||
label="Discharge Superheat"
|
||||
value={fmt(status.discharge_superheat_c, 1, "°C")}
|
||||
status={shStatus}
|
||||
/>
|
||||
<RefrigerantRow
|
||||
label="Liquid Subcooling"
|
||||
value={fmt(status.liquid_subcooling_c, 1, "°C")}
|
||||
status={scStatus}
|
||||
/>
|
||||
<RefrigerantRow
|
||||
label="Compressor Load"
|
||||
value={fmt(status.compressor_load_pct, 1, "%")}
|
||||
status={ldStatus}
|
||||
/>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-xs text-muted-foreground">Compressor Power</span>
|
||||
<span className="text-sm font-mono font-medium">{fmt(status.compressor_power_kw, 2, " kW")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-muted/30 px-3 py-2.5 text-xs text-muted-foreground space-y-1">
|
||||
<p className="font-semibold text-foreground mb-1.5">Normal Ranges (R410A)</p>
|
||||
<p>High side pressure: 15 – 20 bar</p>
|
||||
<p>Low side pressure: 4 – 6 bar</p>
|
||||
<p>Discharge superheat: 5 – 15°C</p>
|
||||
<p>Liquid subcooling: 3 – 8°C</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Trends tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function TrendsTab({ history }: { history: CracHistoryPoint[] }) {
|
||||
if (history.length < 2) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground mt-6 text-center">
|
||||
Not enough history yet — data accumulates every 5 minutes.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<MiniChart data={history} dataKey="supply_temp" color="#60a5fa" label="Supply Temp" unit="°C" />
|
||||
<MiniChart data={history} dataKey="return_temp" color="#f97316" label="Return Temp" unit="°C" refLine={36} />
|
||||
<MiniChart data={history} dataKey="delta_t" color="#a78bfa" label="ΔT" unit="°C" />
|
||||
<MiniChart data={history} dataKey="capacity_kw" color="#34d399" label="Cooling kW" unit=" kW" />
|
||||
<MiniChart data={history} dataKey="capacity_pct"color="#fbbf24" label="Utilisation" unit="%" refLine={90} />
|
||||
<MiniChart data={history} dataKey="cop" color="#38bdf8" label="COP" unit="" refLine={1.5} />
|
||||
<MiniChart data={history} dataKey="comp_load" color="#e879f9" label="Comp Load" unit="%" refLine={95} />
|
||||
<MiniChart data={history} dataKey="filter_dp" color="#fb923c" label="Filter ΔP" unit=" Pa" refLine={80} />
|
||||
<MiniChart data={history} dataKey="fan_pct" color="#94a3b8" label="Fan Speed" unit="%" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sheet ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CracDetailSheet({ siteId, cracId, onClose }: Props) {
|
||||
const [hours, setHours] = useState(6);
|
||||
const [status, setStatus] = useState<CracStatus | null>(null);
|
||||
const [history, setHistory] = useState<CracHistoryPoint[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cracId) return;
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
fetchCracStatus(siteId),
|
||||
fetchCracHistory(siteId, cracId, hours),
|
||||
]).then(([statuses, hist]) => {
|
||||
setStatus(statuses.find(c => c.crac_id === cracId) ?? null);
|
||||
setHistory(hist);
|
||||
}).finally(() => setLoading(false));
|
||||
}, [siteId, cracId, hours]);
|
||||
|
||||
return (
|
||||
<Sheet open={cracId != null} onOpenChange={open => { if (!open) onClose(); }}>
|
||||
<SheetContent className="w-[500px] sm:w-[560px] overflow-y-auto" side="right">
|
||||
<SheetHeader className="mb-2">
|
||||
<SheetTitle className="flex items-center justify-between">
|
||||
<span>{cracId?.toUpperCase() ?? "CRAC Unit"}</span>
|
||||
{status && (
|
||||
<Badge
|
||||
variant={status.state === "online" ? "default" : "destructive"}
|
||||
className="text-xs"
|
||||
>
|
||||
{status.state}
|
||||
</Badge>
|
||||
)}
|
||||
</SheetTitle>
|
||||
{status && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{status.room_id ? `Room: ${status.room_id}` : ""}
|
||||
{status.state === "online" ? " · Mode: Cooling · Setpoint 22°C" : ""}
|
||||
</p>
|
||||
)}
|
||||
</SheetHeader>
|
||||
|
||||
{loading && !status ? (
|
||||
<div className="space-y-3 mt-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : status ? (
|
||||
<Tabs defaultValue="overview">
|
||||
<div className="flex items-center justify-between mt-3 mb-1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="refrigerant">Refrigerant</TabsTrigger>
|
||||
<TabsTrigger value="trends">Trends</TabsTrigger>
|
||||
</TabsList>
|
||||
<TimeRangePicker value={hours} onChange={setHours} />
|
||||
</div>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<OverviewTab status={status} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="refrigerant">
|
||||
<RefrigerantTab status={status} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="trends">
|
||||
<TrendsTab history={history} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground mt-4">No data available for {cracId}.</p>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
489
frontend/components/dashboard/generator-detail-sheet.tsx
Normal file
489
frontend/components/dashboard/generator-detail-sheet.tsx
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
fetchGeneratorStatus, fetchGeneratorHistory,
|
||||
type GeneratorStatus, type GeneratorHistoryPoint,
|
||||
} from "@/lib/api";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { TimeRangePicker } from "@/components/ui/time-range-picker";
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, ReferenceLine,
|
||||
} from "recharts";
|
||||
import {
|
||||
Fuel, Zap, Gauge, Thermometer, Wind, Activity, Battery, Settings2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
siteId: string;
|
||||
genId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function fmt(v: number | null | undefined, dec = 1, unit = "") {
|
||||
if (v == null) return "—";
|
||||
return `${v.toFixed(dec)}${unit}`;
|
||||
}
|
||||
|
||||
function formatTime(iso: string) {
|
||||
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); }
|
||||
catch { return iso; }
|
||||
}
|
||||
|
||||
const STATE_BADGE: Record<string, string> = {
|
||||
running: "bg-green-500/15 text-green-400 border-green-500/30",
|
||||
standby: "bg-blue-500/15 text-blue-400 border-blue-500/30",
|
||||
test: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
||||
fault: "bg-destructive/15 text-destructive border-destructive/30",
|
||||
unknown: "bg-muted/30 text-muted-foreground border-border",
|
||||
};
|
||||
|
||||
function FillBar({
|
||||
value, max, color, warn, crit, invert = false,
|
||||
}: {
|
||||
value: number | null; max: number; color: string; warn?: number; crit?: number; invert?: boolean;
|
||||
}) {
|
||||
const pct = value != null ? Math.min(100, (value / max) * 100) : 0;
|
||||
const v = value ?? 0;
|
||||
const barColor =
|
||||
crit && (invert ? v <= crit : v >= crit) ? "#ef4444" :
|
||||
warn && (invert ? v <= warn : v >= warn) ? "#f59e0b" :
|
||||
color;
|
||||
return (
|
||||
<div className="h-1.5 rounded-full bg-muted overflow-hidden w-full">
|
||||
<div className="h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%`, backgroundColor: barColor }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ icon: Icon, title }: { icon: React.ElementType; title: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mt-5 mb-2">
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">{title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatRow({
|
||||
label, value, highlight, status,
|
||||
}: {
|
||||
label: string; value: string; highlight?: boolean; status?: "ok" | "warn" | "crit";
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-between items-center py-1.5 border-b border-border/40 last:border-0">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"text-sm font-mono font-medium",
|
||||
highlight && "text-amber-400",
|
||||
status === "crit" && "text-destructive",
|
||||
status === "warn" && "text-amber-400",
|
||||
status === "ok" && "text-green-400",
|
||||
)}>{value}</span>
|
||||
{status && (
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full uppercase tracking-wide",
|
||||
status === "ok" ? "bg-green-500/10 text-green-400" :
|
||||
status === "warn" ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-destructive/10 text-destructive",
|
||||
)}>
|
||||
{status === "ok" ? "normal" : status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniChart({
|
||||
data, dataKey, color, label, unit, refLine,
|
||||
}: {
|
||||
data: GeneratorHistoryPoint[];
|
||||
dataKey: keyof GeneratorHistoryPoint;
|
||||
color: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
refLine?: number;
|
||||
}) {
|
||||
const vals = data.map(d => d[dataKey] as number | null).filter(v => v != null) as number[];
|
||||
const last = vals[vals.length - 1];
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<div className="flex justify-between items-baseline mb-1">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-mono font-medium" style={{ color }}>
|
||||
{last != null ? `${last.toFixed(1)}${unit}` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={60}>
|
||||
<LineChart data={data} margin={{ top: 2, right: 4, left: -28, bottom: 0 }}>
|
||||
<XAxis dataKey="bucket" tickFormatter={formatTime} tick={{ fontSize: 9 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 9 }} domain={["auto", "auto"]} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
|
||||
<Tooltip
|
||||
formatter={(v: unknown) => [`${(v as number).toFixed(1)}${unit}`, label]}
|
||||
labelFormatter={(l: unknown) => formatTime(String(l))}
|
||||
contentStyle={{ fontSize: 11, background: "#1e1e2e", border: "1px solid #333" }}
|
||||
/>
|
||||
{refLine != null && <ReferenceLine y={refLine} stroke="rgba(251,191,36,0.4)" strokeDasharray="4 2" />}
|
||||
<Line type="monotone" dataKey={dataKey} stroke={color} dot={false} strokeWidth={1.5} connectNulls />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Overview tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function OverviewTab({ gen }: { gen: GeneratorStatus }) {
|
||||
const isRunning = gen.state === "running" || gen.state === "test";
|
||||
const fuelLow = (gen.fuel_pct ?? 100) < 25;
|
||||
const fuelCrit = (gen.fuel_pct ?? 100) < 10;
|
||||
const loadWarn = (gen.load_pct ?? 0) > 75;
|
||||
const loadCrit = (gen.load_pct ?? 0) > 90;
|
||||
|
||||
// Estimated runtime from fuel and consumption rate
|
||||
const runtimeH = gen.fuel_rate_lph && gen.fuel_rate_lph > 0 && gen.fuel_litres
|
||||
? gen.fuel_litres / gen.fuel_rate_lph
|
||||
: gen.fuel_litres && gen.load_kw && gen.load_kw > 0
|
||||
? gen.fuel_litres / (gen.load_kw * 0.27)
|
||||
: null;
|
||||
|
||||
// Computed electrical values
|
||||
const outputKva = gen.load_kw && gen.power_factor && gen.power_factor > 0
|
||||
? gen.load_kw / gen.power_factor : null;
|
||||
const outputCurrent = gen.load_kw && gen.voltage_v && gen.voltage_v > 0 && gen.power_factor
|
||||
? (gen.load_kw * 1000) / (gen.voltage_v * 1.732 * gen.power_factor) : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Fuel */}
|
||||
<SectionLabel icon={Fuel} title="Fuel" />
|
||||
<div className="rounded-lg bg-muted/20 px-4 py-3 space-y-2">
|
||||
<div className="flex justify-between items-baseline mb-1">
|
||||
<span className="text-xs text-muted-foreground">Tank Level</span>
|
||||
<span className={cn(
|
||||
"text-2xl font-bold tabular-nums",
|
||||
fuelCrit ? "text-destructive" : fuelLow ? "text-amber-400" : "text-green-400",
|
||||
)}>
|
||||
{fmt(gen.fuel_pct, 1)}%
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={gen.fuel_pct} max={100} color="#22c55e" warn={25} crit={10} />
|
||||
<div className="flex justify-between text-xs text-muted-foreground mt-1">
|
||||
<span>{gen.fuel_litres != null ? `${gen.fuel_litres.toFixed(0)} L remaining` : "—"}</span>
|
||||
{gen.fuel_rate_lph != null && gen.fuel_rate_lph > 0 && (
|
||||
<span>{fmt(gen.fuel_rate_lph, 1)} L/hr consumption</span>
|
||||
)}
|
||||
</div>
|
||||
{runtimeH != null && (
|
||||
<div className={cn(
|
||||
"text-xs font-semibold text-right",
|
||||
runtimeH < 4 ? "text-destructive" : runtimeH < 12 ? "text-amber-400" : "text-green-400",
|
||||
)}>
|
||||
Est. runtime: {Math.floor(runtimeH)}h {Math.round((runtimeH % 1) * 60)}m
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Load */}
|
||||
<SectionLabel icon={Zap} title="Load" />
|
||||
<div className="rounded-lg bg-muted/20 px-4 py-3 space-y-2">
|
||||
<div className="flex justify-between items-baseline mb-1">
|
||||
<span className="text-xs text-muted-foreground">Active Load</span>
|
||||
<span className={cn(
|
||||
"text-2xl font-bold tabular-nums",
|
||||
loadCrit ? "text-destructive" : loadWarn ? "text-amber-400" : "text-foreground",
|
||||
)}>
|
||||
{fmt(gen.load_kw, 1)} kW
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={gen.load_pct} max={100} color="#60a5fa" warn={75} crit={90} />
|
||||
<div className="flex justify-between text-xs mt-1">
|
||||
<span className={cn(
|
||||
"font-medium tabular-nums",
|
||||
loadCrit ? "text-destructive" : loadWarn ? "text-amber-400" : "text-muted-foreground",
|
||||
)}>
|
||||
{fmt(gen.load_pct, 1)}% of 500 kW rated
|
||||
</span>
|
||||
{outputKva != null && (
|
||||
<span className="text-muted-foreground">{outputKva.toFixed(1)} kVA apparent</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick stats */}
|
||||
<SectionLabel icon={Activity} title="Quick Stats" />
|
||||
<div className="bg-muted/30 rounded-lg px-3 py-1">
|
||||
<StatRow label="Output Current" value={outputCurrent != null ? `${outputCurrent.toFixed(1)} A` : "—"} />
|
||||
<StatRow label="Power Factor" value={fmt(gen.power_factor, 3)} />
|
||||
<StatRow label="Run Hours" value={gen.run_hours != null ? `${gen.run_hours.toLocaleString()} h` : "—"} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Engine tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function EngineTab({ gen }: { gen: GeneratorStatus }) {
|
||||
const coolantWarn = (gen.coolant_temp_c ?? 0) > 85;
|
||||
const coolantCrit = (gen.coolant_temp_c ?? 0) > 95;
|
||||
const exhaustWarn = (gen.exhaust_temp_c ?? 0) > 420;
|
||||
const exhaustCrit = (gen.exhaust_temp_c ?? 0) > 480;
|
||||
const altTempWarn = (gen.alternator_temp_c ?? 0) > 70;
|
||||
const altTempCrit = (gen.alternator_temp_c ?? 0) > 85;
|
||||
const oilLow = (gen.oil_pressure_bar ?? 99) < 2.0;
|
||||
const oilWarn = (gen.oil_pressure_bar ?? 99) < 3.0;
|
||||
const battLow = (gen.battery_v ?? 99) < 23.0;
|
||||
const battWarn = (gen.battery_v ?? 99) < 24.0;
|
||||
const rpmWarn = gen.engine_rpm != null && gen.engine_rpm > 0 && Math.abs(gen.engine_rpm - 1500) > 20;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mt-4 mb-4 leading-relaxed">
|
||||
Mechanical health of the diesel engine. Normal operating range assumes a 50 Hz, 4-pole synchronous generator at 1500 RPM.
|
||||
</p>
|
||||
|
||||
{/* Temperature gauges */}
|
||||
<SectionLabel icon={Thermometer} title="Temperatures" />
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-muted-foreground">Coolant</span>
|
||||
<span className={cn("font-mono", coolantCrit ? "text-destructive" : coolantWarn ? "text-amber-400" : "text-foreground")}>
|
||||
{fmt(gen.coolant_temp_c, 1)}°C
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={gen.coolant_temp_c} max={120} color="#34d399" warn={85} crit={95} />
|
||||
<p className="text-[10px] text-muted-foreground text-right mt-0.5">Normal: 70–90°C</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-muted-foreground">Exhaust Stack</span>
|
||||
<span className={cn("font-mono", exhaustCrit ? "text-destructive" : exhaustWarn ? "text-amber-400" : "text-foreground")}>
|
||||
{fmt(gen.exhaust_temp_c, 0)}°C
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={gen.exhaust_temp_c} max={600} color="#f97316" warn={420} crit={480} />
|
||||
<p className="text-[10px] text-muted-foreground text-right mt-0.5">Normal: 200–420°C at load</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-muted-foreground">Alternator Windings</span>
|
||||
<span className={cn("font-mono", altTempCrit ? "text-destructive" : altTempWarn ? "text-amber-400" : "text-foreground")}>
|
||||
{fmt(gen.alternator_temp_c, 1)}°C
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={gen.alternator_temp_c} max={110} color="#a78bfa" warn={70} crit={85} />
|
||||
<p className="text-[10px] text-muted-foreground text-right mt-0.5">Normal: 40–70°C at load</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Engine mechanical */}
|
||||
<SectionLabel icon={Settings2} title="Mechanical" />
|
||||
<div className="bg-muted/30 rounded-lg px-3 py-1">
|
||||
<StatRow
|
||||
label="Engine RPM"
|
||||
value={gen.engine_rpm != null && gen.engine_rpm > 0 ? `${gen.engine_rpm.toFixed(0)} RPM` : "— (stopped)"}
|
||||
status={rpmWarn ? "warn" : gen.engine_rpm && gen.engine_rpm > 0 ? "ok" : undefined}
|
||||
/>
|
||||
<StatRow
|
||||
label="Oil Pressure"
|
||||
value={gen.oil_pressure_bar != null && gen.oil_pressure_bar > 0 ? `${gen.oil_pressure_bar.toFixed(2)} bar` : "— (stopped)"}
|
||||
status={oilLow ? "crit" : oilWarn ? "warn" : gen.oil_pressure_bar && gen.oil_pressure_bar > 0 ? "ok" : undefined}
|
||||
/>
|
||||
<StatRow
|
||||
label="Run Hours"
|
||||
value={gen.run_hours != null ? `${gen.run_hours.toLocaleString()} h` : "—"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Starter battery */}
|
||||
<SectionLabel icon={Battery} title="Starter Battery" />
|
||||
<div className="bg-muted/30 rounded-lg px-3 py-1">
|
||||
<StatRow
|
||||
label="Battery Voltage (24 V system)"
|
||||
value={fmt(gen.battery_v, 2, " V")}
|
||||
status={battLow ? "crit" : battWarn ? "warn" : "ok"}
|
||||
/>
|
||||
<div className="py-1.5">
|
||||
<FillBar value={gen.battery_v} max={29} color="#22c55e" warn={24} crit={23} invert />
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground pb-1.5">
|
||||
Float charge: 27.2 V · Low threshold: 24 V · Critical: 23 V
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Electrical tab ────────────────────────────────────────────────────────────
|
||||
|
||||
function ElectricalTab({ gen }: { gen: GeneratorStatus }) {
|
||||
const freqWarn = gen.frequency_hz != null && gen.frequency_hz > 0 && Math.abs(gen.frequency_hz - 50) > 0.3;
|
||||
const freqCrit = gen.frequency_hz != null && gen.frequency_hz > 0 && Math.abs(gen.frequency_hz - 50) > 0.5;
|
||||
const voltWarn = gen.voltage_v != null && gen.voltage_v > 0 && Math.abs(gen.voltage_v - 415) > 10;
|
||||
|
||||
const outputKva = gen.load_kw && gen.power_factor && gen.power_factor > 0
|
||||
? gen.load_kw / gen.power_factor : null;
|
||||
const outputKvar = outputKva && gen.load_kw
|
||||
? Math.sqrt(Math.max(0, outputKva ** 2 - gen.load_kw ** 2)) : null;
|
||||
const outputCurrent = gen.load_kw && gen.voltage_v && gen.voltage_v > 0 && gen.power_factor
|
||||
? (gen.load_kw * 1000) / (gen.voltage_v * 1.732 * gen.power_factor) : null;
|
||||
const phaseCurrentA = outputCurrent ? outputCurrent / 3 : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mt-4 mb-4 leading-relaxed">
|
||||
AC output electrical parameters. The generator feeds the ATS which transfers site load during utility failure. Rated 500 kW / 555 kVA at 0.90 PF, 415 V, 50 Hz.
|
||||
</p>
|
||||
|
||||
{/* AC output hero */}
|
||||
<div className="rounded-lg bg-muted/20 px-4 py-3 grid grid-cols-3 gap-3 mb-4 text-center">
|
||||
{[
|
||||
{ label: "Output Voltage", value: gen.voltage_v && gen.voltage_v > 0 ? `${gen.voltage_v.toFixed(0)} V` : "—", warn: voltWarn },
|
||||
{ label: "Frequency", value: gen.frequency_hz && gen.frequency_hz > 0 ? `${gen.frequency_hz.toFixed(2)} Hz` : "—", warn: freqWarn || freqCrit },
|
||||
{ label: "Power Factor", value: fmt(gen.power_factor, 3), warn: false },
|
||||
].map(({ label, value, warn }) => (
|
||||
<div key={label}>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">{label}</p>
|
||||
<p className={cn("text-lg font-bold tabular-nums", warn ? "text-amber-400" : "text-foreground")}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SectionLabel icon={Zap} title="Power Output" />
|
||||
<div className="bg-muted/30 rounded-lg px-3 py-1">
|
||||
<StatRow label="Active Power (kW)" value={fmt(gen.load_kw, 1, " kW")} />
|
||||
<StatRow label="Apparent Power (kVA)" value={outputKva != null ? `${outputKva.toFixed(1)} kVA` : "—"} />
|
||||
<StatRow label="Reactive Power (kVAR)" value={outputKvar != null ? `${outputKvar.toFixed(1)} kVAR` : "—"} />
|
||||
<StatRow label="Load %" value={fmt(gen.load_pct, 1, "%")} />
|
||||
</div>
|
||||
|
||||
<SectionLabel icon={Activity} title="Current (3-Phase)" />
|
||||
<div className="bg-muted/30 rounded-lg px-3 py-1">
|
||||
<StatRow label="Total Output Current" value={outputCurrent != null ? `${outputCurrent.toFixed(1)} A` : "—"} />
|
||||
<StatRow label="Per Phase (balanced)" value={phaseCurrentA != null ? `${phaseCurrentA.toFixed(1)} A` : "—"} />
|
||||
<StatRow label="Rated Current (500 kW)" value="694 A" />
|
||||
</div>
|
||||
|
||||
<SectionLabel icon={Wind} title="Frequency" />
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-muted-foreground">Output Frequency</span>
|
||||
<span className={cn("font-mono", freqCrit ? "text-destructive" : freqWarn ? "text-amber-400" : "text-green-400")}>
|
||||
{gen.frequency_hz && gen.frequency_hz > 0 ? `${gen.frequency_hz.toFixed(2)} Hz` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={gen.frequency_hz ?? 0} max={55} color="#34d399" warn={50.3} crit={50.5} />
|
||||
<p className="text-[10px] text-muted-foreground text-right mt-0.5">
|
||||
Nominal 50 Hz · Grid tolerance ±0.5 Hz
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Trends tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function TrendsTab({ history }: { history: GeneratorHistoryPoint[] }) {
|
||||
if (history.length < 2) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground mt-6 text-center">
|
||||
Not enough history yet — data accumulates every 5 minutes.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<MiniChart data={history} dataKey="load_pct" color="#60a5fa" label="Load %" unit="%" refLine={90} />
|
||||
<MiniChart data={history} dataKey="fuel_pct" color="#22c55e" label="Fuel Level" unit="%" refLine={25} />
|
||||
<MiniChart data={history} dataKey="coolant_temp_c" color="#34d399" label="Coolant Temp" unit="°C" refLine={95} />
|
||||
<MiniChart data={history} dataKey="exhaust_temp_c" color="#f97316" label="Exhaust Temp" unit="°C" refLine={420} />
|
||||
<MiniChart data={history} dataKey="frequency_hz" color="#a78bfa" label="Frequency" unit=" Hz" refLine={50.5} />
|
||||
<MiniChart data={history} dataKey="alternator_temp_c" color="#e879f9" label="Alternator Temp" unit="°C" refLine={85} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sheet ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function GeneratorDetailSheet({ siteId, genId, onClose }: Props) {
|
||||
const [hours, setHours] = useState(6);
|
||||
const [status, setStatus] = useState<GeneratorStatus | null>(null);
|
||||
const [history, setHistory] = useState<GeneratorHistoryPoint[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!genId) return;
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
fetchGeneratorStatus(siteId),
|
||||
fetchGeneratorHistory(siteId, genId, hours),
|
||||
]).then(([statuses, hist]) => {
|
||||
setStatus(statuses.find(g => g.gen_id === genId) ?? null);
|
||||
setHistory(hist);
|
||||
}).finally(() => setLoading(false));
|
||||
}, [siteId, genId, hours]);
|
||||
|
||||
return (
|
||||
<Sheet open={genId != null} onOpenChange={open => { if (!open) onClose(); }}>
|
||||
<SheetContent className="w-[500px] sm:w-[560px] overflow-y-auto" side="right">
|
||||
<SheetHeader className="mb-2">
|
||||
<SheetTitle className="flex items-center justify-between">
|
||||
<span>{genId?.toUpperCase() ?? "Generator"}</span>
|
||||
{status && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-xs capitalize border", STATUS_BADGE_CLASS[status.state] ?? STATUS_BADGE_CLASS.unknown)}
|
||||
>
|
||||
{status.state}
|
||||
</Badge>
|
||||
)}
|
||||
</SheetTitle>
|
||||
{status && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Diesel generator · 500 kW rated · 2,000 L tank
|
||||
{status.run_hours != null ? ` · ${status.run_hours.toLocaleString()} run hours` : ""}
|
||||
</p>
|
||||
)}
|
||||
</SheetHeader>
|
||||
|
||||
{loading && !status ? (
|
||||
<div className="space-y-3 mt-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-8 w-full" />)}
|
||||
</div>
|
||||
) : status ? (
|
||||
<Tabs defaultValue="overview">
|
||||
<div className="flex items-center justify-between mt-3 mb-1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="engine">Engine</TabsTrigger>
|
||||
<TabsTrigger value="electrical">Electrical</TabsTrigger>
|
||||
<TabsTrigger value="trends">Trends</TabsTrigger>
|
||||
</TabsList>
|
||||
<TimeRangePicker value={hours} onChange={setHours} />
|
||||
</div>
|
||||
<TabsContent value="overview"> <OverviewTab gen={status} /></TabsContent>
|
||||
<TabsContent value="engine"> <EngineTab gen={status} /></TabsContent>
|
||||
<TabsContent value="electrical"> <ElectricalTab gen={status} /></TabsContent>
|
||||
<TabsContent value="trends"> <TrendsTab history={history} /></TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground mt-4">No data available for {genId}.</p>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_BADGE_CLASS: Record<string, string> = STATE_BADGE;
|
||||
77
frontend/components/dashboard/kpi-card.tsx
Normal file
77
frontend/components/dashboard/kpi-card.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import Link from "next/link";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { LucideIcon, TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface KpiCardProps {
|
||||
title: string;
|
||||
value: string;
|
||||
icon: LucideIcon;
|
||||
iconColor: string;
|
||||
status: "ok" | "warning" | "critical";
|
||||
hint?: string;
|
||||
loading?: boolean;
|
||||
trend?: number | null;
|
||||
trendLabel?: string;
|
||||
trendInvert?: boolean;
|
||||
href?: string; // optional navigation target
|
||||
}
|
||||
|
||||
const statusBorder: Record<string, string> = {
|
||||
ok: "border-l-4 border-l-green-500",
|
||||
warning: "border-l-4 border-l-amber-500",
|
||||
critical: "border-l-4 border-l-destructive",
|
||||
};
|
||||
|
||||
export function KpiCard({ title, value, icon: Icon, iconColor, status, hint, loading, trend, trendLabel, trendInvert, href }: KpiCardProps) {
|
||||
const hasTrend = trend !== null && trend !== undefined;
|
||||
const isUp = hasTrend && trend! > 0;
|
||||
const isDown = hasTrend && trend! < 0;
|
||||
const isFlat = hasTrend && trend! === 0;
|
||||
|
||||
// For temp/alarms: up is bad (red), down is good (green)
|
||||
// For power: up might be warning, down is fine
|
||||
const trendGood = trendInvert ? isDown : isUp;
|
||||
const trendBad = trendInvert ? isUp : false;
|
||||
|
||||
const trendColor = isFlat ? "text-muted-foreground"
|
||||
: trendGood ? "text-green-400"
|
||||
: trendBad ? "text-destructive"
|
||||
: "text-amber-400";
|
||||
|
||||
const inner = (
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1 flex-1">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{title}
|
||||
</p>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-24" />
|
||||
) : (
|
||||
<p className="text-2xl font-bold tracking-tight">{value}</p>
|
||||
)}
|
||||
{hasTrend && !loading ? (
|
||||
<div className={cn("flex items-center gap-1 text-[10px]", trendColor)}>
|
||||
{isFlat ? <Minus className="w-3 h-3" /> : isUp ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
|
||||
<span>{trendLabel ?? (trend! > 0 ? `+${trend}` : `${trend}`)}</span>
|
||||
<span className="text-muted-foreground">vs prev period</span>
|
||||
</div>
|
||||
) : (
|
||||
hint && <p className="text-[10px] text-muted-foreground">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-2 rounded-md bg-muted/50 shrink-0">
|
||||
<Icon className={cn("w-5 h-5", iconColor)} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className={cn("relative overflow-hidden", statusBorder[status], href && "cursor-pointer hover:bg-muted/20 transition-colors")}>
|
||||
{href ? <Link href={href} className="block">{inner}</Link> : inner}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
144
frontend/components/dashboard/mini-floor-map.tsx
Normal file
144
frontend/components/dashboard/mini-floor-map.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Map as MapIcon, Wind } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useThresholds } from "@/lib/threshold-context";
|
||||
import type { RackCapacity } from "@/lib/api";
|
||||
|
||||
type RowLayout = { label: string; racks: string[] };
|
||||
type RoomLayout = { label: string; crac_id: string; rows: RowLayout[] };
|
||||
type FloorLayout = Record<string, RoomLayout>;
|
||||
|
||||
interface Props {
|
||||
layout: FloorLayout | null;
|
||||
racks: RackCapacity[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function tempBg(temp: number | null, warn: number, crit: number): string {
|
||||
if (temp === null) return "oklch(0.22 0.02 265)";
|
||||
if (temp >= crit + 4) return "oklch(0.55 0.22 25)";
|
||||
if (temp >= crit) return "oklch(0.65 0.20 45)";
|
||||
if (temp >= warn) return "oklch(0.72 0.18 84)";
|
||||
if (temp >= warn - 2) return "oklch(0.78 0.14 140)";
|
||||
if (temp >= warn - 4) return "oklch(0.68 0.14 162)";
|
||||
return "oklch(0.60 0.15 212)";
|
||||
}
|
||||
|
||||
export function MiniFloorMap({ layout, racks, loading }: Props) {
|
||||
const { thresholds } = useThresholds();
|
||||
const warn = thresholds.temp.warn;
|
||||
const crit = thresholds.temp.critical;
|
||||
|
||||
const rackMap: globalThis.Map<string, RackCapacity> = new globalThis.Map(racks.map(r => [r.rack_id, r] as [string, RackCapacity]));
|
||||
const roomIds = layout ? Object.keys(layout) : [];
|
||||
const [activeRoom, setActiveRoom] = useState<string>(() => roomIds[0] ?? "");
|
||||
const currentRoomId = activeRoom || roomIds[0] || "";
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col">
|
||||
<CardHeader className="pb-2 shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<MapIcon className="w-4 h-4 text-primary" />
|
||||
Floor Map — Temperature
|
||||
</CardTitle>
|
||||
<Link
|
||||
href="/floor-map"
|
||||
className="text-[10px] text-primary hover:underline"
|
||||
>
|
||||
Full map →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Room tabs */}
|
||||
{roomIds.length > 1 && (
|
||||
<div className="flex gap-1 mt-2">
|
||||
{roomIds.map(id => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setActiveRoom(id)}
|
||||
className={cn(
|
||||
"px-3 py-1 rounded text-[11px] font-medium transition-colors",
|
||||
currentRoomId === id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted/40 text-muted-foreground hover:bg-muted/60"
|
||||
)}
|
||||
>
|
||||
{layout?.[id]?.label ?? id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 min-h-0">
|
||||
{loading ? (
|
||||
<Skeleton className="h-40 w-full rounded-lg" />
|
||||
) : !layout || roomIds.length === 0 ? (
|
||||
<div className="h-40 flex items-center justify-center text-sm text-muted-foreground">
|
||||
No layout configured
|
||||
</div>
|
||||
) : (
|
||||
<Link href="/floor-map" className="block group">
|
||||
<div className="space-y-2">
|
||||
{(layout[currentRoomId]?.rows ?? []).map((row, rowIdx, allRows) => (
|
||||
<div key={row.label}>
|
||||
{/* Rack row */}
|
||||
<div className="flex flex-wrap gap-[3px]">
|
||||
{row.racks.map(rackId => {
|
||||
const rack = rackMap.get(rackId);
|
||||
const bg = tempBg(rack?.temp ?? null, warn, crit);
|
||||
return (
|
||||
<div
|
||||
key={rackId}
|
||||
title={`${rackId}${rack?.temp != null ? ` · ${rack.temp}°C` : " · offline"}`}
|
||||
className="rounded-[2px] shrink-0"
|
||||
style={{ width: 14, height: 20, backgroundColor: bg }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Cold aisle separator between rows */}
|
||||
{rowIdx < allRows.length - 1 && (
|
||||
<div className="flex items-center gap-2 my-1.5 text-[9px] font-semibold uppercase tracking-widest"
|
||||
style={{ color: "oklch(0.62 0.17 212 / 70%)" }}>
|
||||
<div className="flex-1 h-px border-t border-dashed" style={{ borderColor: "oklch(0.62 0.17 212 / 25%)" }} />
|
||||
<Wind className="w-2.5 h-2.5 shrink-0" />
|
||||
<span>Cold Aisle</span>
|
||||
<div className="flex-1 h-px border-t border-dashed" style={{ borderColor: "oklch(0.62 0.17 212 / 25%)" }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* CRAC label */}
|
||||
{layout[currentRoomId]?.crac_id && (
|
||||
<div className="flex items-center justify-center gap-1.5 rounded-md py-1 text-[10px] font-medium"
|
||||
style={{ backgroundColor: "oklch(0.62 0.17 212 / 8%)", color: "oklch(0.62 0.17 212)" }}>
|
||||
<Wind className="w-3 h-3" />
|
||||
{layout[currentRoomId].crac_id.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Temp legend */}
|
||||
<div className="flex items-center gap-1 mt-3 text-[10px] text-muted-foreground">
|
||||
<span>Cool</span>
|
||||
{(["oklch(0.60 0.15 212)","oklch(0.68 0.14 162)","oklch(0.78 0.14 140)","oklch(0.72 0.18 84)","oklch(0.65 0.20 45)","oklch(0.55 0.22 25)"] as string[]).map((c, i) => (
|
||||
<span key={i} className="w-5 h-2.5 rounded-sm inline-block" style={{ backgroundColor: c }} />
|
||||
))}
|
||||
<span>Hot</span>
|
||||
<span className="ml-auto opacity-60 group-hover:opacity-100 transition-opacity">Click to open full map</span>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
58
frontend/components/dashboard/power-trend-chart.tsx
Normal file
58
frontend/components/dashboard/power-trend-chart.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { PowerBucket } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
data: PowerBucket[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function PowerTrendChart({ data, loading }: Props) {
|
||||
const chartData = data.map((d) => ({ time: formatTime(d.bucket), power: d.total_kw }));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Total Power (kW)</CardTitle>
|
||||
<span className="text-xs text-muted-foreground">Last 60 minutes</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-[200px] flex items-center justify-center text-sm text-muted-foreground">
|
||||
Waiting for data...
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="powerGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="oklch(0.62 0.17 212)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="oklch(0.62 0.17 212)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
|
||||
<YAxis tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "12px" }}
|
||||
formatter={(value) => [`${value} kW`, "Power"]}
|
||||
/>
|
||||
<Area type="monotone" dataKey="power" stroke="oklch(0.62 0.17 212)" strokeWidth={2} fill="url(#powerGradient)" dot={false} activeDot={{ r: 4 }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
422
frontend/components/dashboard/rack-detail-sheet.tsx
Normal file
422
frontend/components/dashboard/rack-detail-sheet.tsx
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
fetchRackHistory, fetchRackDevices,
|
||||
type RackHistory, type Device,
|
||||
} from "@/lib/api";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { TimeRangePicker } from "@/components/ui/time-range-picker";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
LineChart, Line, AreaChart, Area,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { Thermometer, Zap, Droplets, Bell, Server } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
siteId: string;
|
||||
rackId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function timeAgo(iso: string) {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
return `${Math.floor(m / 60)}h ago`;
|
||||
}
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
// ── Device type styles ────────────────────────────────────────────────────────
|
||||
|
||||
const TYPE_STYLES: Record<string, { bg: string; border: string; text: string; label: string }> = {
|
||||
server: { bg: "bg-blue-500/15", border: "border-blue-500/40", text: "text-blue-300", label: "Server" },
|
||||
switch: { bg: "bg-green-500/15", border: "border-green-500/40", text: "text-green-300", label: "Switch" },
|
||||
patch_panel: { bg: "bg-slate-500/15", border: "border-slate-400/40", text: "text-slate-300", label: "Patch Panel" },
|
||||
pdu: { bg: "bg-amber-500/15", border: "border-amber-500/40", text: "text-amber-300", label: "PDU" },
|
||||
storage: { bg: "bg-purple-500/15", border: "border-purple-500/40", text: "text-purple-300", label: "Storage" },
|
||||
firewall: { bg: "bg-red-500/15", border: "border-red-500/40", text: "text-red-300", label: "Firewall" },
|
||||
kvm: { bg: "bg-teal-500/15", border: "border-teal-500/40", text: "text-teal-300", label: "KVM" },
|
||||
};
|
||||
|
||||
const TYPE_DOT: Record<string, string> = {
|
||||
server: "bg-blue-400",
|
||||
switch: "bg-green-400",
|
||||
patch_panel: "bg-slate-400",
|
||||
pdu: "bg-amber-400",
|
||||
storage: "bg-purple-400",
|
||||
firewall: "bg-red-400",
|
||||
kvm: "bg-teal-400",
|
||||
};
|
||||
|
||||
// ── U-diagram ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const TOTAL_U = 42;
|
||||
const U_PX = 20; // height per U in pixels
|
||||
|
||||
type Segment = { u: number; height: number; device: Device | null };
|
||||
|
||||
function buildSegments(devices: Device[]): Segment[] {
|
||||
const sorted = [...devices].sort((a, b) => a.u_start - b.u_start);
|
||||
const segs: Segment[] = [];
|
||||
let u = 1;
|
||||
let i = 0;
|
||||
while (u <= TOTAL_U) {
|
||||
if (i < sorted.length && sorted[i].u_start === u) {
|
||||
const d = sorted[i];
|
||||
segs.push({ u, height: d.u_height, device: d });
|
||||
u += d.u_height;
|
||||
i++;
|
||||
} else {
|
||||
const nextU = i < sorted.length ? sorted[i].u_start : TOTAL_U + 1;
|
||||
const empty = Math.min(nextU, TOTAL_U + 1) - u;
|
||||
if (empty > 0) {
|
||||
segs.push({ u, height: empty, device: null });
|
||||
u += empty;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return segs;
|
||||
}
|
||||
|
||||
function RackDiagram({ devices, loading }: { devices: Device[]; loading: boolean }) {
|
||||
const [selected, setSelected] = useState<Device | null>(null);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mt-3 space-y-1 flex-1">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-5 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const segments = buildSegments(devices);
|
||||
const totalPower = devices.reduce((s, d) => s + d.power_draw_w, 0);
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-col flex-1 min-h-0 gap-3">
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 shrink-0">
|
||||
{Object.entries(TYPE_STYLES).map(([type, style]) => (
|
||||
devices.some(d => d.type === type) ? (
|
||||
<div key={type} className="flex items-center gap-1">
|
||||
<span className={cn("w-2 h-2 rounded-sm", TYPE_DOT[type])} />
|
||||
<span className="text-[10px] text-muted-foreground">{style.label}</span>
|
||||
</div>
|
||||
) : null
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Rack diagram */}
|
||||
<div className="flex flex-col flex-1 min-h-0 border border-border/50 rounded-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex bg-muted/40 border-b border-border/50 px-2 py-1 shrink-0">
|
||||
<span className="w-8 text-[9px] text-muted-foreground text-right pr-1 shrink-0">U</span>
|
||||
<span className="flex-1 text-[9px] text-muted-foreground pl-2">
|
||||
{devices[0]?.rack_id.toUpperCase() ?? "Rack"} — 42U
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground">{totalPower} W total</span>
|
||||
</div>
|
||||
|
||||
{/* Slots */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{segments.map((seg) => {
|
||||
const style = seg.device ? (TYPE_STYLES[seg.device.type] ?? TYPE_STYLES.server) : null;
|
||||
const isSelected = selected?.device_id === seg.device?.device_id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={seg.u}
|
||||
style={{ height: seg.height * U_PX }}
|
||||
className={cn(
|
||||
"flex items-stretch border-b border-border/20 last:border-0",
|
||||
seg.device && "cursor-pointer",
|
||||
)}
|
||||
onClick={() => setSelected(seg.device && isSelected ? null : seg.device)}
|
||||
>
|
||||
{/* U number */}
|
||||
<div className="w-8 flex items-start justify-end pt-1 pr-1.5 shrink-0">
|
||||
<span className="text-[9px] text-muted-foreground/50 font-mono leading-none">
|
||||
{seg.u}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Device or empty */}
|
||||
<div className="flex-1 flex items-stretch py-px pr-1">
|
||||
{seg.device ? (
|
||||
<div className={cn(
|
||||
"flex-1 rounded border flex items-center px-2 gap-2 transition-colors",
|
||||
style!.bg, style!.border,
|
||||
isSelected && "ring-1 ring-primary/50",
|
||||
)}>
|
||||
<span className={cn("text-xs font-medium truncate flex-1", style!.text)}>
|
||||
{seg.device.name}
|
||||
</span>
|
||||
{seg.height >= 2 && (
|
||||
<span className="text-[9px] text-muted-foreground/60 font-mono shrink-0">
|
||||
{seg.device.ip !== "-" ? seg.device.ip : ""}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[9px] text-muted-foreground/60 font-mono shrink-0">
|
||||
{seg.device.u_height}U
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 rounded border border-dashed border-border/25 flex items-center px-2">
|
||||
{seg.height > 1 && (
|
||||
<span className="text-[9px] text-muted-foreground/25">
|
||||
{seg.height}U empty
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected device detail */}
|
||||
{selected && (() => { // shrink-0 via parent gap
|
||||
const style = TYPE_STYLES[selected.type] ?? TYPE_STYLES.server;
|
||||
return (
|
||||
<div className={cn("rounded-lg border p-3 space-y-2", style.bg, style.border)}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className={cn("text-sm font-semibold", style.text)}>{selected.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">{style.label}</p>
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-green-500/10 text-green-400">
|
||||
● Online
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||
<div><span className="text-muted-foreground">Serial: </span><span className="font-mono">{selected.serial}</span></div>
|
||||
<div><span className="text-muted-foreground">IP: </span><span className="font-mono">{selected.ip !== "-" ? selected.ip : "—"}</span></div>
|
||||
<div><span className="text-muted-foreground">Position: </span><span>U{selected.u_start}–U{selected.u_start + selected.u_height - 1}</span></div>
|
||||
<div><span className="text-muted-foreground">Power: </span><span>{selected.power_draw_w} W</span></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── History tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
function HistoryTab({ data, hours, onHoursChange, loading }: {
|
||||
data: RackHistory | null;
|
||||
hours: number;
|
||||
onHoursChange: (h: number) => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const chartData = (data?.history ?? []).map(p => ({ ...p, time: formatTime(p.bucket) }));
|
||||
const latest = chartData[chartData.length - 1];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-end mt-3 mb-4">
|
||||
<TimeRangePicker value={hours} onChange={onHoursChange} />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-40 w-full" /><Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{latest && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="rounded-lg bg-muted/30 p-3 text-center">
|
||||
<Thermometer className="w-4 h-4 mx-auto mb-1 text-primary" />
|
||||
<p className="text-lg font-bold">{latest.temperature !== undefined ? `${latest.temperature}°C` : "—"}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Temperature</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/30 p-3 text-center">
|
||||
<Zap className="w-4 h-4 mx-auto mb-1 text-amber-400" />
|
||||
<p className="text-lg font-bold">{latest.power_kw !== undefined ? `${latest.power_kw} kW` : "—"}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Power</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/30 p-3 text-center">
|
||||
<Droplets className="w-4 h-4 mx-auto mb-1 text-blue-400" />
|
||||
<p className="text-lg font-bold">{latest.humidity !== undefined ? `${latest.humidity}%` : "—"}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Humidity</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
||||
<Thermometer className="w-3 h-3" /> Temperature (°C)
|
||||
</p>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<LineChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
|
||||
<YAxis tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} domain={["auto", "auto"]} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "11px" }}
|
||||
formatter={(v) => [`${v}°C`, "Temp"]}
|
||||
/>
|
||||
<Line type="monotone" dataKey="temperature" stroke="oklch(0.62 0.17 212)" strokeWidth={2} dot={false} activeDot={{ r: 3 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" /> Power Draw (kW)
|
||||
</p>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="powerGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="oklch(0.78 0.17 84)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="oklch(0.78 0.17 84)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
|
||||
<YAxis tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} domain={[0, "auto"]} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "11px" }}
|
||||
formatter={(v) => [`${v} kW`, "Power"]}
|
||||
/>
|
||||
<Area type="monotone" dataKey="power_kw" stroke="oklch(0.78 0.17 84)" fill="url(#powerGrad)" strokeWidth={2} dot={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Alarms tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
const severityColors: Record<string, string> = {
|
||||
critical: "bg-destructive/15 text-destructive border-destructive/30",
|
||||
warning: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
||||
info: "bg-blue-500/15 text-blue-400 border-blue-500/30",
|
||||
};
|
||||
const stateColors: Record<string, string> = {
|
||||
active: "bg-destructive/10 text-destructive",
|
||||
acknowledged: "bg-amber-500/10 text-amber-400",
|
||||
resolved: "bg-green-500/10 text-green-400",
|
||||
};
|
||||
|
||||
function AlarmsTab({ data }: { data: RackHistory | null }) {
|
||||
return (
|
||||
<div className="mt-3">
|
||||
{!data || data.alarms.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-8">
|
||||
No alarms on record for this rack.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.alarms.map((alarm) => (
|
||||
<div
|
||||
key={alarm.id}
|
||||
className={cn("rounded-lg border px-3 py-2 text-xs", severityColors[alarm.severity] ?? severityColors.info)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{alarm.message}</span>
|
||||
<Badge className={cn("text-[9px] border-0 shrink-0", stateColors[alarm.state] ?? stateColors.active)}>
|
||||
{alarm.state}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-0.5 opacity-70">{timeAgo(alarm.triggered_at)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sheet ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function RackDetailSheet({ siteId, rackId, onClose }: Props) {
|
||||
const [data, setData] = useState<RackHistory | null>(null);
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [hours, setHours] = useState(6);
|
||||
const [histLoading, setHistLoad] = useState(false);
|
||||
const [devLoading, setDevLoad] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rackId) { setData(null); setDevices([]); return; }
|
||||
|
||||
setHistLoad(true);
|
||||
setDevLoad(true);
|
||||
|
||||
fetchRackHistory(siteId, rackId, hours)
|
||||
.then(setData).catch(() => setData(null))
|
||||
.finally(() => setHistLoad(false));
|
||||
|
||||
fetchRackDevices(siteId, rackId)
|
||||
.then(setDevices).catch(() => setDevices([]))
|
||||
.finally(() => setDevLoad(false));
|
||||
}, [siteId, rackId, hours]);
|
||||
|
||||
const alarmCount = data?.alarms.filter(a => a.state === "active").length ?? 0;
|
||||
|
||||
return (
|
||||
<Sheet open={!!rackId} onOpenChange={open => { if (!open) onClose(); }}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col h-dvh overflow-hidden">
|
||||
<SheetHeader className="mb-2 shrink-0">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-primary" />
|
||||
{rackId?.toUpperCase() ?? ""}
|
||||
</SheetTitle>
|
||||
<p className="text-xs text-muted-foreground">Singapore DC01</p>
|
||||
</SheetHeader>
|
||||
|
||||
<Tabs defaultValue="layout" className="flex flex-col flex-1 min-h-0">
|
||||
<TabsList className="w-full shrink-0">
|
||||
<TabsTrigger value="layout" className="flex-1">Layout</TabsTrigger>
|
||||
<TabsTrigger value="history" className="flex-1">History</TabsTrigger>
|
||||
<TabsTrigger value="alarms" className="flex-1">
|
||||
Alarms
|
||||
{alarmCount > 0 && (
|
||||
<span className="ml-1.5 text-[9px] font-bold text-destructive">{alarmCount}</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="layout" className="flex flex-col flex-1 min-h-0 overflow-hidden mt-0">
|
||||
<RackDiagram devices={devices} loading={devLoading} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history" className="flex-1 overflow-y-auto">
|
||||
<HistoryTab
|
||||
data={data}
|
||||
hours={hours}
|
||||
onHoursChange={setHours}
|
||||
loading={histLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="alarms" className="flex-1 overflow-y-auto">
|
||||
<AlarmsTab data={data} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
97
frontend/components/dashboard/room-status-grid.tsx
Normal file
97
frontend/components/dashboard/room-status-grid.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Thermometer, Zap, Bell, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { RoomStatus } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
rooms: RoomStatus[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { label: string; dot: string; bg: string }> = {
|
||||
ok: { label: "Healthy", dot: "bg-green-500", bg: "bg-green-500/10 text-green-400" },
|
||||
warning: { label: "Warning", dot: "bg-amber-500", bg: "bg-amber-500/10 text-amber-400" },
|
||||
critical: { label: "Critical", dot: "bg-destructive", bg: "bg-destructive/10 text-destructive" },
|
||||
};
|
||||
|
||||
const roomLabels: Record<string, string> = {
|
||||
"hall-a": "Hall A",
|
||||
"hall-b": "Hall B",
|
||||
"hall-c": "Hall C",
|
||||
};
|
||||
|
||||
export function RoomStatusGrid({ rooms, loading }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">Room Status — Singapore DC01</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-28 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : rooms.length === 0 ? (
|
||||
<div className="h-28 flex items-center justify-center text-sm text-muted-foreground">
|
||||
Waiting for room data...
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{rooms.map((room) => {
|
||||
const s = statusConfig[room.status];
|
||||
return (
|
||||
<button
|
||||
key={room.room_id}
|
||||
onClick={() => router.push("/environmental")}
|
||||
className="rounded-lg border border-border bg-muted/30 p-4 space-y-3 text-left w-full hover:bg-muted/50 hover:border-primary/30 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
{roomLabels[room.room_id] ?? room.room_id}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("flex items-center gap-1.5 text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide", s.bg)}>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", s.dot)} />
|
||||
{s.label}
|
||||
</span>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground/40 group-hover:text-primary transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<Thermometer className="w-3 h-3" /> Temp
|
||||
</span>
|
||||
<span className="font-semibold">{room.avg_temp.toFixed(1)}°C</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<Zap className="w-3 h-3" /> Power
|
||||
</span>
|
||||
<span className="font-semibold">{room.total_kw.toFixed(1)} kW</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<Bell className="w-3 h-3" /> Alarms
|
||||
</span>
|
||||
<span className={cn("font-semibold", room.alarm_count > 0 ? "text-destructive" : "")}>
|
||||
{room.alarm_count === 0 ? "None" : room.alarm_count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
75
frontend/components/dashboard/temperature-trend-chart.tsx
Normal file
75
frontend/components/dashboard/temperature-trend-chart.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"use client";
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from "recharts";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { TempBucket } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
data: TempBucket[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
type ChartRow = { time: string; [roomId: string]: string | number };
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function TemperatureTrendChart({ data, loading }: Props) {
|
||||
// Pivot flat rows [{bucket, room_id, avg_temp}] into [{time, "hall-a": 23.1, "hall-b": 24.2}]
|
||||
const bucketMap = new Map<string, ChartRow>();
|
||||
for (const row of data) {
|
||||
const time = formatTime(row.bucket);
|
||||
if (!bucketMap.has(time)) bucketMap.set(time, { time });
|
||||
bucketMap.get(time)![row.room_id] = row.avg_temp;
|
||||
}
|
||||
const chartData = Array.from(bucketMap.values());
|
||||
const roomIds = [...new Set(data.map((d) => d.room_id))].sort();
|
||||
|
||||
const LINE_COLORS = ["oklch(0.62 0.17 212)", "oklch(0.7 0.15 162)", "oklch(0.75 0.18 84)"];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Temperature (°C)</CardTitle>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{roomIds.map((id, i) => (
|
||||
<span key={id} className="flex items-center gap-1">
|
||||
<span className="w-3 h-0.5 inline-block" style={{ backgroundColor: LINE_COLORS[i] }} />
|
||||
{id}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-[200px] flex items-center justify-center text-sm text-muted-foreground">
|
||||
Waiting for data...
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
|
||||
<YAxis tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} domain={["auto", "auto"]} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "12px" }}
|
||||
formatter={(value, name) => [`${value}°C`, name]}
|
||||
/>
|
||||
<ReferenceLine y={26} stroke="oklch(0.78 0.17 84)" strokeDasharray="4 4" strokeWidth={1}
|
||||
label={{ value: "Warn", fontSize: 9, fill: "oklch(0.78 0.17 84)", position: "right" }} />
|
||||
{roomIds.map((id, i) => (
|
||||
<Line key={id} type="monotone" dataKey={id} stroke={LINE_COLORS[i]} strokeWidth={2} dot={false} activeDot={{ r: 4 }} />
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
357
frontend/components/dashboard/ups-detail-sheet.tsx
Normal file
357
frontend/components/dashboard/ups-detail-sheet.tsx
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as RechartTooltip,
|
||||
ResponsiveContainer, ReferenceLine,
|
||||
} from "recharts";
|
||||
import { Battery, Zap, Activity, AlertTriangle, CheckCircle2, TrendingDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fetchUpsHistory, type UpsAsset, type UpsHistoryPoint } from "@/lib/api";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
function fmt(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function StatRow({ label, value, color }: { label: string; value: string; color?: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 border-b border-border/20 last:border-0">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<span className={cn("text-sm font-semibold tabular-nums", color)}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GaugeBar({
|
||||
label, value, max, unit, warnAt, critAt, reverse = false,
|
||||
}: {
|
||||
label: string; value: number | null; max: number; unit: string;
|
||||
warnAt?: number; critAt?: number; reverse?: boolean;
|
||||
}) {
|
||||
const v = value ?? 0;
|
||||
const pct = Math.min(100, (v / max) * 100);
|
||||
const isWarn = warnAt !== undefined && (reverse ? v < warnAt : v >= warnAt);
|
||||
const isCrit = critAt !== undefined && (reverse ? v < critAt : v >= critAt);
|
||||
const barColor = isCrit ? "bg-destructive" : isWarn ? "bg-amber-500" : "bg-green-500";
|
||||
const textColor = isCrit ? "text-destructive" : isWarn ? "text-amber-400" : "";
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className={cn("font-semibold", textColor)}>
|
||||
{value !== null ? `${value}${unit}` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div className={cn("h-full rounded-full transition-all duration-500", barColor)}
|
||||
style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniChart({
|
||||
data, dataKey, color, refLines = [], unit, domain,
|
||||
}: {
|
||||
data: UpsHistoryPoint[]; dataKey: keyof UpsHistoryPoint; color: string;
|
||||
refLines?: { y: number; color: string; label: string }[];
|
||||
unit?: string; domain?: [number, number];
|
||||
}) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="h-36 flex items-center justify-center text-xs text-muted-foreground">
|
||||
Waiting for data...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={144}>
|
||||
<LineChart data={data} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
|
||||
<XAxis dataKey="bucket" tickFormatter={fmt}
|
||||
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
|
||||
<YAxis domain={domain} tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
|
||||
tickLine={false} axisLine={false} />
|
||||
<RechartTooltip
|
||||
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "11px" }}
|
||||
formatter={(v) => [`${v}${unit ?? ""}`, String(dataKey)]}
|
||||
labelFormatter={(l) => fmt(String(l))}
|
||||
/>
|
||||
{refLines.map((r) => (
|
||||
<ReferenceLine key={r.y} y={r.y} stroke={r.color} strokeDasharray="4 4" strokeWidth={1}
|
||||
label={{ value: r.label, fontSize: 8, fill: r.color, position: "insideTopRight" }} />
|
||||
))}
|
||||
<Line type="monotone" dataKey={dataKey as string} stroke={color}
|
||||
dot={false} strokeWidth={2} connectNulls />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Overview Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function OverviewTab({ ups }: { ups: UpsAsset }) {
|
||||
const onBattery = ups.state === "battery";
|
||||
const overload = ups.state === "overload";
|
||||
const charge = ups.charge_pct ?? 0;
|
||||
const runtime = ups.runtime_min ?? null;
|
||||
const load = ups.load_pct ?? null;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* State hero */}
|
||||
<div className={cn(
|
||||
"rounded-xl border px-5 py-4 flex items-center gap-4",
|
||||
overload ? "border-destructive/40 bg-destructive/5" :
|
||||
onBattery ? "border-amber-500/40 bg-amber-500/5" :
|
||||
"border-green-500/30 bg-green-500/5",
|
||||
)}>
|
||||
<Battery className={cn("w-8 h-8",
|
||||
overload ? "text-destructive" : onBattery ? "text-amber-400" : "text-green-400"
|
||||
)} />
|
||||
<div>
|
||||
<p className={cn("text-xl font-bold",
|
||||
overload ? "text-destructive" : onBattery ? "text-amber-400" : "text-green-400"
|
||||
)}>
|
||||
{overload ? "Overloaded" : onBattery ? "On Battery" : "Mains Power"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{overload
|
||||
? "Critical — load exceeds safe capacity"
|
||||
: onBattery
|
||||
? "Mains power lost — running on battery"
|
||||
: "Grid power normal — charging battery"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gauges */}
|
||||
<div className="space-y-4">
|
||||
<GaugeBar label="Battery charge" value={ups.charge_pct} max={100} unit="%" warnAt={80} critAt={50} reverse />
|
||||
<GaugeBar label="Load" value={load} max={100} unit="%" warnAt={85} critAt={95} />
|
||||
</div>
|
||||
|
||||
{/* Runtime + voltage row */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="rounded-lg bg-muted/20 px-4 py-3 text-center">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Est. Runtime</p>
|
||||
<p className={cn("text-2xl font-bold tabular-nums",
|
||||
runtime !== null && runtime < 5 ? "text-destructive" :
|
||||
runtime !== null && runtime < 15 ? "text-amber-400" : "text-green-400",
|
||||
)}>
|
||||
{runtime !== null ? `${Math.round(runtime)}` : "—"}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">minutes</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/20 px-4 py-3 text-center">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Input Voltage</p>
|
||||
<p className={cn("text-2xl font-bold tabular-nums",
|
||||
ups.voltage_v !== null && (ups.voltage_v < 210 || ups.voltage_v > 250) ? "text-amber-400" : "",
|
||||
)}>
|
||||
{ups.voltage_v !== null ? `${ups.voltage_v}` : "—"}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">V AC</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="space-y-0">
|
||||
<StatRow label="Unit ID" value={ups.ups_id.toUpperCase()} />
|
||||
<StatRow
|
||||
label="Battery charge"
|
||||
value={charge < 50 ? `${charge.toFixed(1)}% — Low` : `${charge.toFixed(1)}%`}
|
||||
color={charge < 50 ? "text-destructive" : charge < 80 ? "text-amber-400" : "text-green-400"}
|
||||
/>
|
||||
<StatRow
|
||||
label="Runtime remaining"
|
||||
value={runtime !== null ? `${Math.round(runtime)} min` : "—"}
|
||||
color={runtime !== null && runtime < 5 ? "text-destructive" : runtime !== null && runtime < 15 ? "text-amber-400" : ""}
|
||||
/>
|
||||
<StatRow
|
||||
label="Load"
|
||||
value={load !== null ? `${load.toFixed(1)}%` : "—"}
|
||||
color={load !== null && load >= 95 ? "text-destructive" : load !== null && load >= 85 ? "text-amber-400" : ""}
|
||||
/>
|
||||
<StatRow
|
||||
label="Input voltage"
|
||||
value={ups.voltage_v !== null ? `${ups.voltage_v} V` : "—"}
|
||||
color={ups.voltage_v !== null && (ups.voltage_v < 210 || ups.voltage_v > 250) ? "text-amber-400" : ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Battery Tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
function BatteryTab({ history }: { history: UpsHistoryPoint[] }) {
|
||||
const charge = history.map((d) => ({ ...d, bucket: d.bucket }));
|
||||
const runtime = history.map((d) => ({ ...d, bucket: d.bucket }));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Battery Charge (%)
|
||||
</p>
|
||||
<MiniChart
|
||||
data={charge}
|
||||
dataKey="charge_pct"
|
||||
color="oklch(0.65 0.16 145)"
|
||||
unit="%"
|
||||
domain={[0, 100]}
|
||||
refLines={[
|
||||
{ y: 80, color: "oklch(0.72 0.18 84)", label: "Warn 80%" },
|
||||
{ y: 50, color: "oklch(0.55 0.22 25)", label: "Crit 50%" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Estimated Runtime (min)
|
||||
</p>
|
||||
<MiniChart
|
||||
data={runtime}
|
||||
dataKey="runtime_min"
|
||||
color="oklch(0.62 0.17 212)"
|
||||
unit=" min"
|
||||
refLines={[
|
||||
{ y: 15, color: "oklch(0.72 0.18 84)", label: "Warn 15m" },
|
||||
{ y: 5, color: "oklch(0.55 0.22 25)", label: "Crit 5m" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Load Tab ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function LoadTab({ history }: { history: UpsHistoryPoint[] }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Load (%)
|
||||
</p>
|
||||
<MiniChart
|
||||
data={history}
|
||||
dataKey="load_pct"
|
||||
color="oklch(0.7 0.15 50)"
|
||||
unit="%"
|
||||
domain={[0, 100]}
|
||||
refLines={[
|
||||
{ y: 85, color: "oklch(0.72 0.18 84)", label: "Warn 85%" },
|
||||
{ y: 95, color: "oklch(0.55 0.22 25)", label: "Crit 95%" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Input Voltage (V)
|
||||
</p>
|
||||
<MiniChart
|
||||
data={history}
|
||||
dataKey="voltage_v"
|
||||
color="oklch(0.62 0.17 280)"
|
||||
unit=" V"
|
||||
refLines={[
|
||||
{ y: 210, color: "oklch(0.55 0.22 25)", label: "Low 210V" },
|
||||
{ y: 250, color: "oklch(0.55 0.22 25)", label: "High 250V" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sheet ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
ups: UpsAsset | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function UpsDetailSheet({ ups, onClose }: Props) {
|
||||
const [history, setHistory] = useState<UpsHistoryPoint[]>([]);
|
||||
const [hours, setHours] = useState(6);
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
if (!ups) return;
|
||||
try {
|
||||
const h = await fetchUpsHistory(SITE_ID, ups.ups_id, hours);
|
||||
setHistory(h);
|
||||
} catch { /* keep stale */ }
|
||||
}, [ups, hours]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ups) loadHistory();
|
||||
}, [ups, loadHistory]);
|
||||
|
||||
if (!ups) return null;
|
||||
|
||||
const overload = ups.state === "overload";
|
||||
const onBattery = ups.state === "battery";
|
||||
|
||||
return (
|
||||
<Sheet open={!!ups} onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-lg overflow-y-auto">
|
||||
<SheetHeader className="pb-4 border-b border-border/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Battery className="w-5 h-5 text-primary" />
|
||||
{ups.ups_id.toUpperCase()}
|
||||
</SheetTitle>
|
||||
<span className={cn(
|
||||
"flex items-center gap-1.5 text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
|
||||
overload ? "bg-destructive/10 text-destructive" :
|
||||
onBattery ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-green-500/10 text-green-400",
|
||||
)}>
|
||||
{overload || onBattery
|
||||
? <AlertTriangle className="w-3 h-3" />
|
||||
: <CheckCircle2 className="w-3 h-3" />}
|
||||
{overload ? "Overloaded" : onBattery ? "On Battery" : "Mains"}
|
||||
</span>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="pt-4">
|
||||
<Tabs defaultValue="overview">
|
||||
<div className="flex items-center justify-between mb-4 gap-3">
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="overview" className="text-xs px-3">Overview</TabsTrigger>
|
||||
<TabsTrigger value="battery" className="text-xs px-3">Battery</TabsTrigger>
|
||||
<TabsTrigger value="load" className="text-xs px-3">Load & Voltage</TabsTrigger>
|
||||
</TabsList>
|
||||
<select
|
||||
value={hours}
|
||||
onChange={(e) => setHours(Number(e.target.value))}
|
||||
className="text-xs bg-muted border border-border rounded px-2 py-1 text-foreground"
|
||||
>
|
||||
{[1, 3, 6, 12, 24].map((h) => <option key={h} value={h}>{h}h</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<TabsContent value="overview" className="mt-0">
|
||||
<OverviewTab ups={ups} />
|
||||
</TabsContent>
|
||||
<TabsContent value="battery" className="mt-0">
|
||||
<BatteryTab history={history} />
|
||||
</TabsContent>
|
||||
<TabsContent value="load" className="mt-0">
|
||||
<LoadTab history={history} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue