574 lines
19 KiB
TypeScript
574 lines
19 KiB
TypeScript
"use client"
|
|
|
|
import React, { useEffect, useState } from "react"
|
|
import { Loader2, Info } from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from "@/components/ui/sheet"
|
|
import { cn } from "@/lib/utils"
|
|
import {
|
|
type SensorDevice,
|
|
type SensorCreate,
|
|
type DeviceType,
|
|
type Protocol,
|
|
createSensor,
|
|
updateSensor,
|
|
} from "@/lib/api"
|
|
|
|
// ── Option maps ───────────────────────────────────────────────────────────────
|
|
|
|
const DEVICE_TYPE_OPTIONS: { value: DeviceType; label: string }[] = [
|
|
{ value: "ups", label: "UPS" },
|
|
{ value: "generator", label: "Generator" },
|
|
{ value: "crac", label: "CRAC Unit" },
|
|
{ value: "chiller", label: "Chiller" },
|
|
{ value: "ats", label: "Transfer Switch (ATS)" },
|
|
{ value: "rack", label: "Rack PDU" },
|
|
{ value: "network_switch", label: "Network Switch" },
|
|
{ value: "leak", label: "Leak Sensor" },
|
|
{ value: "fire_zone", label: "Fire / VESDA Zone" },
|
|
{ value: "custom", label: "Custom" },
|
|
]
|
|
|
|
const PROTOCOL_OPTIONS: { value: Protocol; label: string }[] = [
|
|
{ value: "mqtt", label: "MQTT" },
|
|
{ value: "modbus_tcp", label: "Modbus TCP" },
|
|
{ value: "modbus_rtu", label: "Modbus RTU" },
|
|
{ value: "snmp", label: "SNMP" },
|
|
{ value: "bacnet", label: "BACnet" },
|
|
{ value: "http", label: "HTTP Poll" },
|
|
]
|
|
|
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
|
|
|
interface Props {
|
|
siteId: string
|
|
sensor: SensorDevice | null // null = add mode
|
|
open: boolean
|
|
onClose: () => void
|
|
onSaved: (s: SensorDevice) => void
|
|
}
|
|
|
|
// ── Shared input / select class ───────────────────────────────────────────────
|
|
|
|
const inputCls =
|
|
"border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground w-full focus:outline-none focus:ring-1 focus:ring-primary"
|
|
|
|
// ── Protocol-specific config state types ──────────────────────────────────────
|
|
|
|
type MqttConfig = { topic: string }
|
|
type ModbusTcpConfig = { host: string; port: number; unit_id: number }
|
|
type ModbusRtuConfig = { serial_port: string; baud_rate: number; unit_id: number }
|
|
type SnmpConfig = { host: string; community: string; version: "v1" | "v2c" | "v3" }
|
|
type BacnetConfig = { host: string; device_id: number }
|
|
type HttpConfig = { url: string; poll_interval_s: number; json_path: string }
|
|
|
|
type ProtocolConfig =
|
|
| MqttConfig
|
|
| ModbusTcpConfig
|
|
| ModbusRtuConfig
|
|
| SnmpConfig
|
|
| BacnetConfig
|
|
| HttpConfig
|
|
|
|
function defaultProtocolConfig(protocol: Protocol): ProtocolConfig {
|
|
switch (protocol) {
|
|
case "mqtt": return { topic: "" }
|
|
case "modbus_tcp": return { host: "", port: 502, unit_id: 1 }
|
|
case "modbus_rtu": return { serial_port: "", baud_rate: 9600, unit_id: 1 }
|
|
case "snmp": return { host: "", community: "public", version: "v2c" }
|
|
case "bacnet": return { host: "", device_id: 0 }
|
|
case "http": return { url: "", poll_interval_s: 30, json_path: "$.value" }
|
|
}
|
|
}
|
|
|
|
function configFromSensor(sensor: SensorDevice): ProtocolConfig {
|
|
const raw = sensor.protocol_config ?? {}
|
|
switch (sensor.protocol) {
|
|
case "mqtt":
|
|
return { topic: (raw.topic as string) ?? "" }
|
|
case "modbus_tcp":
|
|
return {
|
|
host: (raw.host as string) ?? "",
|
|
port: (raw.port as number) ?? 502,
|
|
unit_id: (raw.unit_id as number) ?? 1,
|
|
}
|
|
case "modbus_rtu":
|
|
return {
|
|
serial_port: (raw.serial_port as string) ?? "",
|
|
baud_rate: (raw.baud_rate as number) ?? 9600,
|
|
unit_id: (raw.unit_id as number) ?? 1,
|
|
}
|
|
case "snmp":
|
|
return {
|
|
host: (raw.host as string) ?? "",
|
|
community: (raw.community as string) ?? "public",
|
|
version: (raw.version as "v1" | "v2c" | "v3") ?? "v2c",
|
|
}
|
|
case "bacnet":
|
|
return {
|
|
host: (raw.host as string) ?? "",
|
|
device_id: (raw.device_id as number) ?? 0,
|
|
}
|
|
case "http":
|
|
return {
|
|
url: (raw.url as string) ?? "",
|
|
poll_interval_s: (raw.poll_interval_s as number) ?? 30,
|
|
json_path: (raw.json_path as string) ?? "$.value",
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Protocol-specific field editors ──────────────────────────────────────────
|
|
|
|
interface ConfigEditorProps<T> {
|
|
config: T
|
|
onChange: (next: T) => void
|
|
}
|
|
|
|
function MqttEditor({ config, onChange }: ConfigEditorProps<MqttConfig>) {
|
|
return (
|
|
<Field label="MQTT Topic">
|
|
<input
|
|
className={inputCls}
|
|
value={config.topic}
|
|
placeholder="sensors/site/device/metric"
|
|
onChange={e => onChange({ ...config, topic: e.target.value })}
|
|
/>
|
|
</Field>
|
|
)
|
|
}
|
|
|
|
function ModbusTcpEditor({ config, onChange }: ConfigEditorProps<ModbusTcpConfig>) {
|
|
return (
|
|
<>
|
|
<Field label="Host">
|
|
<input
|
|
className={inputCls}
|
|
value={config.host}
|
|
placeholder="192.168.1.10"
|
|
onChange={e => onChange({ ...config, host: e.target.value })}
|
|
/>
|
|
</Field>
|
|
<Field label="Port">
|
|
<input
|
|
type="number"
|
|
className={inputCls}
|
|
value={config.port}
|
|
onChange={e => onChange({ ...config, port: Number(e.target.value) })}
|
|
/>
|
|
</Field>
|
|
<Field label="Unit ID">
|
|
<input
|
|
type="number"
|
|
className={inputCls}
|
|
value={config.unit_id}
|
|
onChange={e => onChange({ ...config, unit_id: Number(e.target.value) })}
|
|
/>
|
|
</Field>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function ModbusRtuEditor({ config, onChange }: ConfigEditorProps<ModbusRtuConfig>) {
|
|
return (
|
|
<>
|
|
<Field label="Serial Port">
|
|
<input
|
|
className={inputCls}
|
|
value={config.serial_port}
|
|
placeholder="/dev/ttyUSB0"
|
|
onChange={e => onChange({ ...config, serial_port: e.target.value })}
|
|
/>
|
|
</Field>
|
|
<Field label="Baud Rate">
|
|
<input
|
|
type="number"
|
|
className={inputCls}
|
|
value={config.baud_rate}
|
|
onChange={e => onChange({ ...config, baud_rate: Number(e.target.value) })}
|
|
/>
|
|
</Field>
|
|
<Field label="Unit ID">
|
|
<input
|
|
type="number"
|
|
className={inputCls}
|
|
value={config.unit_id}
|
|
onChange={e => onChange({ ...config, unit_id: Number(e.target.value) })}
|
|
/>
|
|
</Field>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function SnmpEditor({ config, onChange }: ConfigEditorProps<SnmpConfig>) {
|
|
return (
|
|
<>
|
|
<Field label="Host">
|
|
<input
|
|
className={inputCls}
|
|
value={config.host}
|
|
placeholder="192.168.1.20"
|
|
onChange={e => onChange({ ...config, host: e.target.value })}
|
|
/>
|
|
</Field>
|
|
<Field label="Community">
|
|
<input
|
|
className={inputCls}
|
|
value={config.community}
|
|
placeholder="public"
|
|
onChange={e => onChange({ ...config, community: e.target.value })}
|
|
/>
|
|
</Field>
|
|
<Field label="Version">
|
|
<select
|
|
className={inputCls}
|
|
value={config.version}
|
|
onChange={e => onChange({ ...config, version: e.target.value as "v1" | "v2c" | "v3" })}
|
|
>
|
|
<option value="v1">v1</option>
|
|
<option value="v2c">v2c</option>
|
|
<option value="v3">v3</option>
|
|
</select>
|
|
</Field>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function BacnetEditor({ config, onChange }: ConfigEditorProps<BacnetConfig>) {
|
|
return (
|
|
<>
|
|
<Field label="Host">
|
|
<input
|
|
className={inputCls}
|
|
value={config.host}
|
|
placeholder="192.168.1.30"
|
|
onChange={e => onChange({ ...config, host: e.target.value })}
|
|
/>
|
|
</Field>
|
|
<Field label="Device ID">
|
|
<input
|
|
type="number"
|
|
className={inputCls}
|
|
value={config.device_id}
|
|
onChange={e => onChange({ ...config, device_id: Number(e.target.value) })}
|
|
/>
|
|
</Field>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function HttpEditor({ config, onChange }: ConfigEditorProps<HttpConfig>) {
|
|
return (
|
|
<>
|
|
<Field label="URL">
|
|
<input
|
|
className={inputCls}
|
|
value={config.url}
|
|
placeholder="http://device/api/status"
|
|
onChange={e => onChange({ ...config, url: e.target.value })}
|
|
/>
|
|
</Field>
|
|
<Field label="Poll Interval (seconds)">
|
|
<input
|
|
type="number"
|
|
className={inputCls}
|
|
value={config.poll_interval_s}
|
|
onChange={e => onChange({ ...config, poll_interval_s: Number(e.target.value) })}
|
|
/>
|
|
</Field>
|
|
<Field label="JSON Path">
|
|
<input
|
|
className={inputCls}
|
|
value={config.json_path}
|
|
placeholder="$.value"
|
|
onChange={e => onChange({ ...config, json_path: e.target.value })}
|
|
/>
|
|
</Field>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// ── Reusable field wrapper ────────────────────────────────────────────────────
|
|
|
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-xs text-muted-foreground">{label}</span>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Toggle switch ─────────────────────────────────────────────────────────────
|
|
|
|
function Toggle({
|
|
checked,
|
|
onChange,
|
|
}: {
|
|
checked: boolean
|
|
onChange: (v: boolean) => void
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={checked}
|
|
onClick={() => onChange(!checked)}
|
|
className={cn(
|
|
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2",
|
|
checked ? "bg-primary" : "bg-muted"
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
|
checked ? "translate-x-4" : "translate-x-0"
|
|
)}
|
|
/>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
// ── Main component ────────────────────────────────────────────────────────────
|
|
|
|
export function SensorSheet({ siteId, sensor, open, onClose, onSaved }: Props) {
|
|
const isEdit = sensor !== null
|
|
|
|
// ── Form state ────────────────────────────────────────────────────────────
|
|
const [deviceId, setDeviceId] = useState("")
|
|
const [name, setName] = useState("")
|
|
const [deviceType, setDeviceType] = useState<DeviceType>("ups")
|
|
const [room, setRoom] = useState("")
|
|
const [enabled, setEnabled] = useState(true)
|
|
const [protocol, setProtocol] = useState<Protocol>("mqtt")
|
|
const [protoConfig, setProtoConfig] = useState<ProtocolConfig>(defaultProtocolConfig("mqtt"))
|
|
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// ── Populate form when sheet opens / sensor changes ───────────────────────
|
|
useEffect(() => {
|
|
if (!open) return
|
|
setError(null)
|
|
setSaving(false)
|
|
if (sensor) {
|
|
setDeviceId(sensor.device_id)
|
|
setName(sensor.name)
|
|
setDeviceType(sensor.device_type)
|
|
setRoom(sensor.room_id ?? "")
|
|
setEnabled(sensor.enabled)
|
|
setProtocol(sensor.protocol)
|
|
setProtoConfig(configFromSensor(sensor))
|
|
} else {
|
|
setDeviceId("")
|
|
setName("")
|
|
setDeviceType("ups")
|
|
setRoom("")
|
|
setEnabled(true)
|
|
setProtocol("mqtt")
|
|
setProtoConfig(defaultProtocolConfig("mqtt"))
|
|
}
|
|
}, [open, sensor])
|
|
|
|
// ── Protocol change — reset config to defaults ────────────────────────────
|
|
function handleProtocolChange(p: Protocol) {
|
|
setProtocol(p)
|
|
setProtoConfig(defaultProtocolConfig(p))
|
|
}
|
|
|
|
// ── Submit ────────────────────────────────────────────────────────────────
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setError(null)
|
|
setSaving(true)
|
|
try {
|
|
const body: SensorCreate = {
|
|
device_id: deviceId.trim(),
|
|
name: name.trim(),
|
|
device_type: deviceType,
|
|
room_id: room.trim() || null,
|
|
protocol,
|
|
protocol_config: protoConfig as Record<string, unknown>,
|
|
enabled,
|
|
}
|
|
const saved = isEdit
|
|
? await updateSensor(sensor!.id, body)
|
|
: await createSensor(siteId, body)
|
|
onSaved(saved)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Save failed")
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
// ── Protocol config editor ────────────────────────────────────────────────
|
|
function renderProtoFields() {
|
|
switch (protocol) {
|
|
case "mqtt":
|
|
return (
|
|
<MqttEditor
|
|
config={protoConfig as MqttConfig}
|
|
onChange={c => setProtoConfig(c)}
|
|
/>
|
|
)
|
|
case "modbus_tcp":
|
|
return (
|
|
<ModbusTcpEditor
|
|
config={protoConfig as ModbusTcpConfig}
|
|
onChange={c => setProtoConfig(c)}
|
|
/>
|
|
)
|
|
case "modbus_rtu":
|
|
return (
|
|
<ModbusRtuEditor
|
|
config={protoConfig as ModbusRtuConfig}
|
|
onChange={c => setProtoConfig(c)}
|
|
/>
|
|
)
|
|
case "snmp":
|
|
return (
|
|
<SnmpEditor
|
|
config={protoConfig as SnmpConfig}
|
|
onChange={c => setProtoConfig(c)}
|
|
/>
|
|
)
|
|
case "bacnet":
|
|
return (
|
|
<BacnetEditor
|
|
config={protoConfig as BacnetConfig}
|
|
onChange={c => setProtoConfig(c)}
|
|
/>
|
|
)
|
|
case "http":
|
|
return (
|
|
<HttpEditor
|
|
config={protoConfig as HttpConfig}
|
|
onChange={c => setProtoConfig(c)}
|
|
/>
|
|
)
|
|
}
|
|
}
|
|
|
|
// ── 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>{isEdit ? "Edit Sensor" : "Add Sensor"}</SheetTitle>
|
|
</SheetHeader>
|
|
|
|
<form onSubmit={handleSubmit} className="flex flex-col flex-1 px-6 pb-6 gap-5">
|
|
{/* ── Device ID ── */}
|
|
<Field label="Device ID *">
|
|
<input
|
|
className={cn(inputCls, isEdit && "opacity-60 cursor-not-allowed")}
|
|
value={deviceId}
|
|
required
|
|
disabled={isEdit}
|
|
placeholder="ups-01"
|
|
onChange={e => setDeviceId(e.target.value)}
|
|
/>
|
|
</Field>
|
|
|
|
{/* ── Name ── */}
|
|
<Field label="Name *">
|
|
<input
|
|
className={inputCls}
|
|
value={name}
|
|
required
|
|
placeholder="Main UPS — Hall A"
|
|
onChange={e => setName(e.target.value)}
|
|
/>
|
|
</Field>
|
|
|
|
{/* ── Device Type ── */}
|
|
<Field label="Device Type">
|
|
<select
|
|
className={inputCls}
|
|
value={deviceType}
|
|
onChange={e => setDeviceType(e.target.value as DeviceType)}
|
|
>
|
|
{DEVICE_TYPE_OPTIONS.map(o => (
|
|
<option key={o.value} value={o.value}>{o.label}</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
|
|
{/* ── Room ── */}
|
|
<Field label="Room (optional)">
|
|
<input
|
|
className={inputCls}
|
|
value={room}
|
|
placeholder="hall-a"
|
|
onChange={e => setRoom(e.target.value)}
|
|
/>
|
|
</Field>
|
|
|
|
{/* ── Enabled toggle ── */}
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-muted-foreground">Enabled</span>
|
|
<Toggle checked={enabled} onChange={setEnabled} />
|
|
</div>
|
|
|
|
{/* ── Divider ── */}
|
|
<hr className="border-border" />
|
|
|
|
{/* ── Protocol ── */}
|
|
<Field label="Protocol">
|
|
<select
|
|
className={inputCls}
|
|
value={protocol}
|
|
onChange={e => handleProtocolChange(e.target.value as Protocol)}
|
|
>
|
|
{PROTOCOL_OPTIONS.map(o => (
|
|
<option key={o.value} value={o.value}>{o.label}</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
|
|
{/* ── Protocol-specific fields ── */}
|
|
{renderProtoFields()}
|
|
|
|
{/* ── Non-MQTT collector notice ── */}
|
|
{protocol !== "mqtt" && (
|
|
<div className="flex gap-2 rounded-md border border-border bg-muted/40 p-3 text-xs text-muted-foreground">
|
|
<Info className="mt-0.5 size-3.5 shrink-0 text-primary" />
|
|
<span>
|
|
Protocol config stored — active polling not yet implemented.
|
|
Data will appear once the collector is enabled.
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Error ── */}
|
|
{error && (
|
|
<p className="text-xs text-destructive">{error}</p>
|
|
)}
|
|
|
|
{/* ── Actions ── */}
|
|
<div className="mt-auto flex gap-2 pt-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="flex-1"
|
|
onClick={onClose}
|
|
disabled={saving}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" className="flex-1" disabled={saving}>
|
|
{saving && <Loader2 className="animate-spin" />}
|
|
{saving ? "Saving…" : isEdit ? "Save Changes" : "Add Sensor"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</SheetContent>
|
|
</Sheet>
|
|
)
|
|
}
|