first commit

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

View file

@ -0,0 +1,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>
);
}