719 lines
27 KiB
TypeScript
719 lines
27 KiB
TypeScript
"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>
|
|
)
|
|
}
|