357 lines
14 KiB
TypeScript
357 lines
14 KiB
TypeScript
"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>
|
|
);
|
|
}
|