first commit
This commit is contained in:
commit
4b98219bf7
144 changed files with 31561 additions and 0 deletions
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