BMS/frontend/components/settings/SensorDetailSheet.tsx
2026-03-19 11:32:17 +00:00

289 lines
11 KiB
TypeScript

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