first commit

This commit is contained in:
mega 2026-03-19 11:32:17 +00:00
commit 4b98219bf7
144 changed files with 31561 additions and 0 deletions

View file

@ -0,0 +1,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>
)
}