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,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>
)
}

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>
)
}

View file

@ -0,0 +1,243 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Plus, Pencil, Trash2, Search, Eye, ToggleLeft, ToggleRight, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { fetchSensors, updateSensor, deleteSensor, type SensorDevice, type DeviceType, type Protocol } from "@/lib/api";
const SITE_ID = "sg-01";
export const DEVICE_TYPE_LABELS: Record<string, string> = {
ups: "UPS",
generator: "Generator",
crac: "CRAC Unit",
chiller: "Chiller",
ats: "Transfer Switch",
rack: "Rack PDU",
network_switch: "Network Switch",
leak: "Leak Sensor",
fire_zone: "Fire / VESDA",
custom: "Custom",
};
export const PROTOCOL_LABELS: Record<string, string> = {
mqtt: "MQTT",
modbus_tcp: "Modbus TCP",
modbus_rtu: "Modbus RTU",
snmp: "SNMP",
bacnet: "BACnet",
http: "HTTP Poll",
};
const TYPE_COLORS: Record<string, string> = {
ups: "bg-blue-500/10 text-blue-400",
generator: "bg-amber-500/10 text-amber-400",
crac: "bg-cyan-500/10 text-cyan-400",
chiller: "bg-sky-500/10 text-sky-400",
ats: "bg-purple-500/10 text-purple-400",
rack: "bg-green-500/10 text-green-400",
network_switch: "bg-indigo-500/10 text-indigo-400",
leak: "bg-teal-500/10 text-teal-400",
fire_zone: "bg-red-500/10 text-red-400",
custom: "bg-muted text-muted-foreground",
};
const PROTOCOL_COLORS: Record<string, string> = {
mqtt: "bg-green-500/10 text-green-400",
modbus_tcp: "bg-orange-500/10 text-orange-400",
modbus_rtu: "bg-orange-500/10 text-orange-400",
snmp: "bg-violet-500/10 text-violet-400",
bacnet: "bg-pink-500/10 text-pink-400",
http: "bg-yellow-500/10 text-yellow-400",
};
interface Props {
onAdd: () => void;
onEdit: (s: SensorDevice) => void;
onDetail: (id: number) => void;
}
export function SensorTable({ onAdd, onEdit, onDetail }: Props) {
const [sensors, setSensors] = useState<SensorDevice[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [confirmDel, setConfirmDel] = useState<number | null>(null);
const [toggling, setToggling] = useState<number | null>(null);
const load = useCallback(async () => {
try {
const data = await fetchSensors(SITE_ID);
setSensors(data);
} catch { /* keep stale */ }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const handleToggle = async (s: SensorDevice) => {
setToggling(s.id);
try {
const updated = await updateSensor(s.id, { enabled: !s.enabled });
setSensors(prev => prev.map(x => x.id === s.id ? updated : x));
} catch { /* ignore */ }
finally { setToggling(null); }
};
const handleDelete = async (id: number) => {
try {
await deleteSensor(id);
setSensors(prev => prev.filter(x => x.id !== id));
} catch { /* ignore */ }
finally { setConfirmDel(null); }
};
const filtered = sensors.filter(s => {
const matchType = typeFilter === "all" || s.device_type === typeFilter;
const q = search.toLowerCase();
const matchSearch = !q || s.device_id.toLowerCase().includes(q) || s.name.toLowerCase().includes(q) || (s.room_id ?? "").toLowerCase().includes(q);
return matchType && matchSearch;
});
const typeOptions = [...new Set(sensors.map(s => s.device_type))].sort();
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 min-w-48">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search by name or ID..."
className="w-full pl-8 pr-3 py-1.5 text-sm bg-muted/30 border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<select
value={typeFilter}
onChange={e => setTypeFilter(e.target.value)}
className="text-sm bg-muted/30 border border-border rounded-md px-2 py-1.5 text-foreground focus:outline-none"
>
<option value="all">All types</option>
{typeOptions.map(t => (
<option key={t} value={t}>{DEVICE_TYPE_LABELS[t] ?? t}</option>
))}
</select>
<Button size="sm" variant="ghost" onClick={load} className="gap-1.5">
<RefreshCw className="w-3.5 h-3.5" /> Refresh
</Button>
<Button size="sm" onClick={onAdd} className="gap-1.5">
<Plus className="w-3.5 h-3.5" /> Add Sensor
</Button>
</div>
<div className="text-xs text-muted-foreground">
{filtered.length} of {sensors.length} devices
</div>
{/* Table */}
{loading ? (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-10 rounded bg-muted/30 animate-pulse" />
))}
</div>
) : filtered.length === 0 ? (
<div className="text-center py-12 text-sm text-muted-foreground">
No sensors found{search ? ` matching "${search}"` : ""}
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-border/40">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/30 bg-muted/10">
<th className="text-left px-3 py-2 text-xs font-medium text-muted-foreground">Device ID</th>
<th className="text-left px-3 py-2 text-xs font-medium text-muted-foreground">Name</th>
<th className="text-left px-3 py-2 text-xs font-medium text-muted-foreground">Type</th>
<th className="text-left px-3 py-2 text-xs font-medium text-muted-foreground">Room</th>
<th className="text-left px-3 py-2 text-xs font-medium text-muted-foreground">Protocol</th>
<th className="text-center px-3 py-2 text-xs font-medium text-muted-foreground">Enabled</th>
<th className="text-right px-3 py-2 text-xs font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{filtered.map(s => (
<tr key={s.id} className="border-b border-border/10 hover:bg-muted/10 transition-colors">
<td className="px-3 py-2 font-mono text-xs text-muted-foreground">{s.device_id}</td>
<td className="px-3 py-2 font-medium">{s.name}</td>
<td className="px-3 py-2">
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", TYPE_COLORS[s.device_type] ?? "bg-muted text-muted-foreground")}>
{DEVICE_TYPE_LABELS[s.device_type] ?? s.device_type}
</span>
</td>
<td className="px-3 py-2 text-xs text-muted-foreground">{s.room_id ?? "—"}</td>
<td className="px-3 py-2">
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", PROTOCOL_COLORS[s.protocol] ?? "bg-muted text-muted-foreground")}>
{PROTOCOL_LABELS[s.protocol] ?? s.protocol}
</span>
</td>
<td className="px-3 py-2 text-center">
<button
onClick={() => handleToggle(s)}
disabled={toggling === s.id}
className={cn("transition-opacity", toggling === s.id && "opacity-50")}
>
{s.enabled
? <ToggleRight className="w-5 h-5 text-green-400" />
: <ToggleLeft className="w-5 h-5 text-muted-foreground" />
}
</button>
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => onDetail(s.id)}
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
title="View details"
>
<Eye className="w-3.5 h-3.5" />
</button>
<button
onClick={() => onEdit(s)}
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
title="Edit"
>
<Pencil className="w-3.5 h-3.5" />
</button>
{confirmDel === s.id ? (
<div className="flex items-center gap-1 ml-1">
<button
onClick={() => handleDelete(s.id)}
className="text-[10px] px-1.5 py-0.5 rounded bg-destructive/10 text-destructive hover:bg-destructive/20 font-medium"
>
Confirm
</button>
<button
onClick={() => setConfirmDel(null)}
className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground hover:bg-muted/80"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmDel(s.id)}
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-destructive"
title="Delete"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,719 @@
"use client"
import React, { useCallback, useEffect, useRef, useState } from "react"
import {
AlertTriangle,
ChevronDown,
ChevronRight,
Plus,
RotateCcw,
Trash2,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
AlarmThreshold,
ThresholdUpdate,
ThresholdCreate,
fetchThresholds,
updateThreshold,
createThreshold,
deleteThreshold,
resetThresholds,
} from "@/lib/api"
// ── Labels ────────────────────────────────────────────────────────────────────
const SENSOR_TYPE_LABELS: Record<string, string> = {
temperature: "Temperature (°C)",
humidity: "Humidity (%)",
power_kw: "Rack Power (kW)",
pdu_imbalance: "Phase Imbalance (%)",
ups_charge: "UPS Battery Charge (%)",
ups_load: "UPS Load (%)",
ups_runtime: "UPS Runtime (min)",
gen_fuel_pct: "Generator Fuel (%)",
gen_load_pct: "Generator Load (%)",
gen_coolant_c: "Generator Coolant (°C)",
gen_oil_press: "Generator Oil Pressure (bar)",
cooling_cap_pct: "CRAC Capacity (%)",
cooling_cop: "CRAC COP",
cooling_comp_load: "Compressor Load (%)",
cooling_high_press: "High-Side Pressure (bar)",
cooling_low_press: "Low-Side Pressure (bar)",
cooling_superheat: "Discharge Superheat (°C)",
cooling_filter_dp: "Filter Delta-P (Pa)",
cooling_return: "Return Air Temp (°C)",
net_pkt_loss_pct: "Packet Loss (%)",
net_temp_c: "Switch Temperature (°C)",
ats_ua_v: "Utility A Voltage (V)",
chiller_cop: "Chiller COP",
}
// ── Groups ────────────────────────────────────────────────────────────────────
type GroupIcon = "Thermometer" | "Zap" | "Battery" | "Fuel" | "Wind" | "Network"
interface Group {
label: string
icon: GroupIcon
types: string[]
}
const GROUPS: Group[] = [
{ label: "Temperature & Humidity", icon: "Thermometer", types: ["temperature", "humidity"] },
{ label: "Rack Power", icon: "Zap", types: ["power_kw", "pdu_imbalance"] },
{ label: "UPS", icon: "Battery", types: ["ups_charge", "ups_load", "ups_runtime"] },
{ label: "Generator", icon: "Fuel", types: ["gen_fuel_pct", "gen_load_pct", "gen_coolant_c", "gen_oil_press"] },
{ label: "Cooling / CRAC", icon: "Wind", types: ["cooling_cap_pct", "cooling_cop", "cooling_comp_load", "cooling_high_press", "cooling_low_press", "cooling_superheat", "cooling_filter_dp", "cooling_return"] },
{ label: "Network", icon: "Network", types: ["net_pkt_loss_pct", "net_temp_c", "ats_ua_v", "chiller_cop"] },
]
// ── Group icon renderer ───────────────────────────────────────────────────────
function GroupIconEl({ icon }: { icon: GroupIcon }) {
const cls = "size-4 shrink-0"
switch (icon) {
case "Thermometer":
return (
<svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z" />
</svg>
)
case "Zap":
return (
<svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
)
case "Battery":
return (
<svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<rect x="1" y="6" width="18" height="12" rx="2" ry="2" />
<line x1="23" y1="13" x2="23" y2="11" />
</svg>
)
case "Fuel":
return (
<svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M3 22V8l6-6h6l6 6v14H3z" />
<rect x="8" y="13" width="8" height="5" />
<path d="M8 5h8" />
</svg>
)
case "Wind":
return (
<svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M9.59 4.59A2 2 0 1 1 11 8H2m10.59 11.41A2 2 0 1 0 14 16H2m15.73-8.27A2.5 2.5 0 1 1 19.5 12H2" />
</svg>
)
case "Network":
return (
<svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<rect x="9" y="2" width="6" height="4" rx="1" />
<rect x="1" y="18" width="6" height="4" rx="1" />
<rect x="17" y="18" width="6" height="4" rx="1" />
<path d="M12 6v4M4 18v-4h16v4M12 10h8v4M12 10H4v4" />
</svg>
)
}
}
// ── Status toast ──────────────────────────────────────────────────────────────
interface Toast {
id: number
message: string
type: "success" | "error"
}
// ── Save indicator per row ────────────────────────────────────────────────────
type SaveState = "idle" | "saving" | "saved" | "error"
// ── Row component ─────────────────────────────────────────────────────────────
interface RowProps {
threshold: AlarmThreshold
onUpdate: (id: number, patch: ThresholdUpdate) => Promise<void>
onDelete: (id: number) => void
}
function ThresholdRow({ threshold, onUpdate, onDelete }: RowProps) {
const [localValue, setLocalValue] = useState(String(threshold.threshold_value))
const [saveState, setSaveState] = useState<SaveState>("idle")
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Keep local value in sync if parent updates (e.g. after reset)
useEffect(() => {
setLocalValue(String(threshold.threshold_value))
}, [threshold.threshold_value])
const signalSave = async (patch: ThresholdUpdate) => {
setSaveState("saving")
try {
await onUpdate(threshold.id, patch)
setSaveState("saved")
} catch {
setSaveState("error")
} finally {
if (saveTimer.current) clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => setSaveState("idle"), 1800)
}
}
const handleValueBlur = () => {
const parsed = parseFloat(localValue)
if (isNaN(parsed)) {
setLocalValue(String(threshold.threshold_value))
return
}
if (parsed !== threshold.threshold_value) {
signalSave({ threshold_value: parsed })
}
}
const handleSeverityToggle = () => {
const next = threshold.severity === "warning" ? "critical" : "warning"
signalSave({ severity: next })
}
const handleEnabledToggle = () => {
signalSave({ enabled: !threshold.enabled })
}
const directionBadge =
threshold.direction === "above" ? (
<span className="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs font-semibold bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300">
Above
</span>
) : (
<span className="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs font-semibold bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
Below
</span>
)
const severityBadge =
threshold.severity === "critical" ? (
<button
onClick={handleSeverityToggle}
title="Click to toggle severity"
className="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs font-semibold bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 hover:opacity-80 transition-opacity cursor-pointer"
>
Critical
</button>
) : (
<button
onClick={handleSeverityToggle}
title="Click to toggle severity"
className="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs font-semibold bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300 hover:opacity-80 transition-opacity cursor-pointer"
>
Warning
</button>
)
const saveIndicator =
saveState === "saving" ? (
<span className="text-xs text-muted-foreground animate-pulse">Saving</span>
) : saveState === "saved" ? (
<span className="text-xs text-emerald-600 dark:text-emerald-400">Saved</span>
) : saveState === "error" ? (
<span className="text-xs text-red-500">Error</span>
) : null
return (
<tr
className={cn(
"border-b border-border/50 last:border-0 transition-colors",
!threshold.enabled && "opacity-50"
)}
>
{/* Sensor type */}
<td className="py-2 pl-4 pr-3 text-sm font-medium text-foreground whitespace-nowrap">
{SENSOR_TYPE_LABELS[threshold.sensor_type] ?? threshold.sensor_type}
</td>
{/* Direction */}
<td className="py-2 px-3 text-sm">
{directionBadge}
</td>
{/* Severity */}
<td className="py-2 px-3 text-sm">
{severityBadge}
</td>
{/* Value */}
<td className="py-2 px-3">
<div className="flex items-center gap-2">
<input
type="number"
step="any"
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
onBlur={handleValueBlur}
disabled={threshold.locked}
className={cn(
"w-24 rounded-md border border-input bg-background px-2 py-1 text-sm text-right tabular-nums",
"focus:outline-none focus:ring-2 focus:ring-ring/50",
"disabled:cursor-not-allowed disabled:opacity-50"
)}
/>
<span className="w-12 text-xs">{saveIndicator}</span>
</div>
</td>
{/* Enabled toggle */}
<td className="py-2 px-3 text-center">
<button
onClick={handleEnabledToggle}
role="switch"
aria-checked={threshold.enabled}
title={threshold.enabled ? "Disable threshold" : "Enable threshold"}
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-ring/50",
threshold.enabled
? "bg-primary"
: "bg-muted"
)}
>
<span
className={cn(
"pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition-transform",
threshold.enabled ? "translate-x-4" : "translate-x-0"
)}
/>
</button>
</td>
{/* Delete */}
<td className="py-2 pl-3 pr-4 text-right">
{!threshold.locked && (
<button
onClick={() => onDelete(threshold.id)}
title="Delete threshold"
className="text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="size-4" />
</button>
)}
</td>
</tr>
)
}
// ── Group section ─────────────────────────────────────────────────────────────
interface GroupSectionProps {
group: Group
thresholds: AlarmThreshold[]
onUpdate: (id: number, patch: ThresholdUpdate) => Promise<void>
onDelete: (id: number) => void
}
function GroupSection({ group, thresholds, onUpdate, onDelete }: GroupSectionProps) {
const [expanded, setExpanded] = useState(true)
const rows = thresholds.filter((t) => group.types.includes(t.sensor_type))
return (
<div className="rounded-lg border border-border bg-card overflow-hidden">
{/* Header */}
<button
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center justify-between px-4 py-3 bg-muted/40 hover:bg-muted/60 transition-colors text-left"
>
<div className="flex items-center gap-2 font-semibold text-sm text-foreground">
<GroupIconEl icon={group.icon} />
{group.label}
<span className="ml-1 text-xs font-normal text-muted-foreground">
({rows.length} rule{rows.length !== 1 ? "s" : ""})
</span>
</div>
{expanded ? (
<ChevronDown className="size-4 text-muted-foreground" />
) : (
<ChevronRight className="size-4 text-muted-foreground" />
)}
</button>
{/* Table */}
{expanded && (
<div className="overflow-x-auto">
{rows.length === 0 ? (
<p className="px-4 py-4 text-sm text-muted-foreground italic">
No rules configured for this group.
</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50 text-xs text-muted-foreground">
<th className="py-2 pl-4 pr-3 text-left font-medium">Sensor Type</th>
<th className="py-2 px-3 text-left font-medium">Direction</th>
<th className="py-2 px-3 text-left font-medium">Severity</th>
<th className="py-2 px-3 text-left font-medium">Value</th>
<th className="py-2 px-3 text-center font-medium">Enabled</th>
<th className="py-2 pl-3 pr-4 text-right font-medium">Delete</th>
</tr>
</thead>
<tbody>
{rows.map((t) => (
<ThresholdRow
key={t.id}
threshold={t}
onUpdate={onUpdate}
onDelete={onDelete}
/>
))}
</tbody>
</table>
)}
</div>
)}
</div>
)
}
// ── Add custom rule form ──────────────────────────────────────────────────────
interface AddRuleFormProps {
siteId: string
onCreated: (t: AlarmThreshold) => void
onError: (msg: string) => void
}
const EMPTY_FORM: ThresholdCreate = {
sensor_type: "",
threshold_value: 0,
direction: "above",
severity: "warning",
message_template: "",
}
function AddRuleForm({ siteId, onCreated, onError }: AddRuleFormProps) {
const [form, setForm] = useState<ThresholdCreate>(EMPTY_FORM)
const [busy, setBusy] = useState(false)
const set = <K extends keyof ThresholdCreate>(k: K, v: ThresholdCreate[K]) =>
setForm((f) => ({ ...f, [k]: v }))
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.sensor_type.trim()) return
setBusy(true)
try {
const created = await createThreshold(siteId, form)
onCreated(created)
setForm(EMPTY_FORM)
} catch {
onError("Failed to create threshold rule.")
} finally {
setBusy(false)
}
}
return (
<div className="rounded-lg border border-dashed border-border bg-card p-4">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground mb-3">
<Plus className="size-4" />
Add Custom Rule
</h3>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{/* Sensor type */}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">Sensor Type</label>
<input
type="text"
placeholder="e.g. temperature"
value={form.sensor_type}
onChange={(e) => set("sensor_type", e.target.value)}
className="rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring/50"
required
/>
</div>
{/* Direction */}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">Direction</label>
<select
value={form.direction}
onChange={(e) => set("direction", e.target.value)}
className="rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring/50"
>
<option value="above">Above</option>
<option value="below">Below</option>
</select>
</div>
{/* Threshold value */}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">Threshold Value</label>
<input
type="number"
step="any"
value={form.threshold_value}
onChange={(e) => set("threshold_value", parseFloat(e.target.value) || 0)}
className="rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring/50"
required
/>
</div>
{/* Severity */}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">Severity</label>
<select
value={form.severity}
onChange={(e) => set("severity", e.target.value)}
className="rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring/50"
>
<option value="warning">Warning</option>
<option value="critical">Critical</option>
</select>
</div>
{/* Message template */}
<div className="flex flex-col gap-1 sm:col-span-2 lg:col-span-2">
<label className="text-xs font-medium text-muted-foreground">
Message Template{" "}
<span className="font-normal opacity-70">
use <code className="text-xs">{"{sensor_id}"}</code> and <code className="text-xs">{"{value:.1f}"}</code>
</span>
</label>
<input
type="text"
placeholder="{sensor_id} value {value:.1f} exceeded threshold"
value={form.message_template}
onChange={(e) => set("message_template", e.target.value)}
className="rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring/50"
/>
</div>
</div>
<div className="mt-3 flex justify-end">
<Button type="submit" size="sm" disabled={busy || !form.sensor_type.trim()}>
<Plus className="size-4" />
{busy ? "Adding…" : "Add Rule"}
</Button>
</div>
</form>
</div>
)
}
// ── Main component ────────────────────────────────────────────────────────────
interface Props {
siteId: string
}
export function ThresholdEditor({ siteId }: Props) {
const [thresholds, setThresholds] = useState<AlarmThreshold[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [confirmReset, setConfirmReset] = useState(false)
const [resetting, setResetting] = useState(false)
const [toasts, setToasts] = useState<Toast[]>([])
const toastId = useRef(0)
// ── Toast helpers ───────────────────────────────────────────────────────────
const pushToast = useCallback((message: string, type: Toast["type"]) => {
const id = ++toastId.current
setToasts((prev) => [...prev, { id, message, type }])
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000)
}, [])
// ── Data loading ────────────────────────────────────────────────────────────
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await fetchThresholds(siteId)
setThresholds(data)
} catch {
setError("Failed to load alarm thresholds.")
} finally {
setLoading(false)
}
}, [siteId])
useEffect(() => { load() }, [load])
// ── Update handler ──────────────────────────────────────────────────────────
const handleUpdate = useCallback(async (id: number, patch: ThresholdUpdate) => {
// Optimistic update
setThresholds((prev) =>
prev.map((t) =>
t.id === id
? {
...t,
...(patch.threshold_value !== undefined && { threshold_value: patch.threshold_value }),
...(patch.severity !== undefined && { severity: patch.severity as AlarmThreshold["severity"] }),
...(patch.enabled !== undefined && { enabled: patch.enabled }),
}
: t
)
)
await updateThreshold(id, patch)
}, [])
// ── Delete handler ──────────────────────────────────────────────────────────
const handleDelete = useCallback(async (id: number) => {
setThresholds((prev) => prev.filter((t) => t.id !== id))
try {
await deleteThreshold(id)
pushToast("Threshold deleted.", "success")
} catch {
pushToast("Failed to delete threshold.", "error")
// Reload to restore
load()
}
}, [load, pushToast])
// ── Create handler ──────────────────────────────────────────────────────────
const handleCreated = useCallback((t: AlarmThreshold) => {
setThresholds((prev) => [...prev, t])
pushToast("Rule added successfully.", "success")
}, [pushToast])
// ── Reset handler ───────────────────────────────────────────────────────────
const handleReset = async () => {
setResetting(true)
try {
await resetThresholds(siteId)
setConfirmReset(false)
pushToast("Thresholds reset to defaults.", "success")
await load()
} catch {
pushToast("Failed to reset thresholds.", "error")
} finally {
setResetting(false)
}
}
// ── Render ──────────────────────────────────────────────────────────────────
return (
<div className="relative space-y-4">
{/* Toast container */}
{toasts.length > 0 && (
<div className="fixed bottom-6 right-6 z-50 flex flex-col gap-2 pointer-events-none">
{toasts.map((toast) => (
<div
key={toast.id}
className={cn(
"rounded-lg px-4 py-2.5 text-sm font-medium shadow-lg animate-in fade-in slide-in-from-bottom-2",
toast.type === "success"
? "bg-emerald-600 text-white"
: "bg-destructive text-white"
)}
>
{toast.message}
</div>
))}
</div>
)}
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
<AlertTriangle className="size-5 text-amber-500" />
Alarm Thresholds
</h2>
<p className="text-sm text-muted-foreground mt-0.5">
Configure threshold values that trigger alarms. Click a severity badge to toggle it; blur the value field to save.
</p>
</div>
{/* Reset controls */}
<div className="flex items-center gap-2 shrink-0">
{confirmReset ? (
<>
<span className="text-sm text-muted-foreground">Are you sure?</span>
<Button
size="sm"
variant="destructive"
onClick={handleReset}
disabled={resetting}
>
<RotateCcw className="size-4" />
{resetting ? "Resetting…" : "Confirm Reset"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setConfirmReset(false)}
disabled={resetting}
>
Cancel
</Button>
</>
) : (
<Button
size="sm"
variant="outline"
onClick={() => setConfirmReset(true)}
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<RotateCcw className="size-4" />
Reset to Defaults
</Button>
)}
</div>
</div>
{/* Loading / Error */}
{loading && (
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground">
<svg className="animate-spin size-5 mr-2 text-muted-foreground" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
Loading thresholds
</div>
)}
{!loading && error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive flex items-center gap-2">
<AlertTriangle className="size-4 shrink-0" />
{error}
<Button size="xs" variant="outline" onClick={load} className="ml-auto">
Retry
</Button>
</div>
)}
{/* Groups */}
{!loading && !error && (
<div className="space-y-3">
{GROUPS.map((group) => (
<GroupSection
key={group.label}
group={group}
thresholds={thresholds}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
))}
</div>
)}
{/* Add custom rule */}
{!loading && !error && (
<AddRuleForm
siteId={siteId}
onCreated={handleCreated}
onError={(msg) => pushToast(msg, "error")}
/>
)}
</div>
)
}