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,574 @@
"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>
)
}