289 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|