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>
|
||||
)
|
||||
}
|
||||
574
frontend/components/settings/SensorSheet.tsx
Normal file
574
frontend/components/settings/SensorSheet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
243
frontend/components/settings/SensorTable.tsx
Normal file
243
frontend/components/settings/SensorTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
719
frontend/components/settings/ThresholdEditor.tsx
Normal file
719
frontend/components/settings/ThresholdEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue