first commit
This commit is contained in:
commit
4b98219bf7
144 changed files with 31561 additions and 0 deletions
289
frontend/components/settings/SensorDetailSheet.tsx
Normal file
289
frontend/components/settings/SensorDetailSheet.tsx
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
"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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue