BMS/frontend/app/(dashboard)/alarms/page.tsx
2026-03-19 11:32:17 +00:00

753 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {
fetchAlarms, fetchAlarmStats, acknowledgeAlarm, resolveAlarm,
type Alarm, type AlarmStats,
} from "@/lib/api";
import { RackDetailSheet } from "@/components/dashboard/rack-detail-sheet";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
AlertTriangle, CheckCircle2, Clock, XCircle, Bell,
ChevronsUpDown, ChevronUp, ChevronDown, Activity,
} from "lucide-react";
import {
BarChart, Bar, XAxis, Tooltip, ResponsiveContainer, Cell,
} from "recharts";
import { cn } from "@/lib/utils";
const SITE_ID = "sg-01";
const PAGE_SIZE = 25;
type StateFilter = "active" | "acknowledged" | "resolved" | "all";
type SeverityFilter = "all" | "critical" | "warning" | "info";
type SortKey = "severity" | "triggered_at" | "state";
type SortDir = "asc" | "desc";
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return "just now";
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
return `${Math.floor(h / 24)}d`;
}
function useNow(intervalMs = 30_000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return now;
}
function escalationMinutes(triggeredAt: string, now: number): number {
return Math.floor((now - new Date(triggeredAt).getTime()) / 60_000);
}
function EscalationTimer({ triggeredAt, now }: { triggeredAt: string; now: number }) {
const mins = escalationMinutes(triggeredAt, now);
const h = Math.floor(mins / 60);
const m = mins % 60;
const label = h > 0 ? `${h}h ${m}m` : `${m}m`;
const colorClass =
mins >= 60 ? "text-destructive" :
mins >= 15 ? "text-amber-400" :
mins >= 5 ? "text-amber-300" :
"text-muted-foreground";
const pulse = mins >= 60;
return (
<span className={cn(
"inline-flex items-center gap-1 text-xs font-mono font-semibold tabular-nums",
colorClass,
pulse && "animate-pulse",
)}>
<Clock className="w-3 h-3 shrink-0" />
{label}
</span>
);
}
function alarmCategory(sensorId: string | null | undefined): { label: string; className: string } {
if (!sensorId) return { label: "System", className: "bg-muted/50 text-muted-foreground" };
const s = sensorId.toLowerCase();
if (s.includes("cooling") || s.includes("crac") || s.includes("refrigerant") || s.includes("cop"))
return { label: "Refrigerant", className: "bg-cyan-500/10 text-cyan-400" };
if (s.includes("temp") || s.includes("thermal") || s.includes("humidity") || s.includes("hum"))
return { label: "Thermal", className: "bg-orange-500/10 text-orange-400" };
if (s.includes("power") || s.includes("ups") || s.includes("pdu") || s.includes("kw") || s.includes("watt"))
return { label: "Power", className: "bg-yellow-500/10 text-yellow-400" };
if (s.includes("leak") || s.includes("water") || s.includes("flood"))
return { label: "Leak", className: "bg-blue-500/10 text-blue-400" };
return { label: "System", className: "bg-muted/50 text-muted-foreground" };
}
const severityConfig: Record<string, { label: string; bg: string; dot: string }> = {
critical: { label: "Critical", bg: "bg-destructive/15 text-destructive border-destructive/30", dot: "bg-destructive" },
warning: { label: "Warning", bg: "bg-amber-500/15 text-amber-400 border-amber-500/30", dot: "bg-amber-500" },
info: { label: "Info", bg: "bg-blue-500/15 text-blue-400 border-blue-500/30", dot: "bg-blue-500" },
};
const stateConfig: Record<string, { label: string; className: string }> = {
active: { label: "Active", className: "bg-destructive/10 text-destructive" },
acknowledged: { label: "Acknowledged", className: "bg-amber-500/10 text-amber-400" },
resolved: { label: "Resolved", className: "bg-green-500/10 text-green-400" },
};
function SeverityBadge({ severity }: { severity: string }) {
const c = severityConfig[severity] ?? severityConfig.info;
return (
<span className={cn("inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wide border", c.bg)}>
<span className={cn("w-1.5 h-1.5 rounded-full", c.dot)} />
{c.label}
</span>
);
}
function StatCard({ label, value, icon: Icon, highlight }: { label: string; value: number; icon: React.ElementType; highlight?: boolean }) {
return (
<Card>
<CardContent className="p-4 flex items-center gap-3">
<div className={cn("p-2 rounded-lg", highlight && value > 0 ? "bg-destructive/10" : "bg-muted")}>
<Icon className={cn("w-4 h-4", highlight && value > 0 ? "text-destructive" : "text-muted-foreground")} />
</div>
<div>
<p className={cn("text-2xl font-bold", highlight && value > 0 ? "text-destructive" : "")}>{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</CardContent>
</Card>
);
}
function AvgAgeCard({ alarms }: { alarms: Alarm[] }) {
const activeAlarms = alarms.filter(a => a.state === "active");
const avgMins = useMemo(() => {
if (activeAlarms.length === 0) return 0;
const now = Date.now();
const totalMins = activeAlarms.reduce((sum, a) => {
return sum + Math.floor((now - new Date(a.triggered_at).getTime()) / 60_000);
}, 0);
return Math.round(totalMins / activeAlarms.length);
}, [activeAlarms]);
const label = avgMins >= 60
? `${Math.floor(avgMins / 60)}h ${avgMins % 60}m`
: `${avgMins}m`;
const colorClass = avgMins > 60 ? "text-destructive"
: avgMins > 15 ? "text-amber-400"
: "text-green-400";
const iconColor = avgMins > 60 ? "text-destructive"
: avgMins > 15 ? "text-amber-400"
: "text-muted-foreground";
const bgColor = avgMins > 60 ? "bg-destructive/10"
: avgMins > 15 ? "bg-amber-500/10"
: "bg-muted";
return (
<Card>
<CardContent className="p-4 flex items-center gap-3">
<div className={cn("p-2 rounded-lg", bgColor)}>
<Clock className={cn("w-4 h-4", iconColor)} />
</div>
<div>
<p className={cn("text-2xl font-bold", activeAlarms.length > 0 ? colorClass : "")}>{activeAlarms.length > 0 ? label : "—"}</p>
<p className="text-xs text-muted-foreground">Avg Age</p>
</div>
</CardContent>
</Card>
);
}
type Correlation = {
id: string
title: string
severity: "critical" | "warning"
description: string
alarmIds: number[]
}
function correlateAlarms(alarms: Alarm[]): Correlation[] {
const active = alarms.filter(a => a.state === "active");
const results: Correlation[] = [];
// Rule 1: ≥2 thermal alarms in the same room → probable CRAC issue
const thermalByRoom = new Map<string, Alarm[]>();
for (const a of active) {
const isThermal = a.sensor_id
? /temp|thermal|humidity|hum/i.test(a.sensor_id)
: /temp|thermal|hot|cool/i.test(a.message);
const room = a.room_id;
if (isThermal && room) {
if (!thermalByRoom.has(room)) thermalByRoom.set(room, []);
thermalByRoom.get(room)!.push(a);
}
}
for (const [room, roomAlarms] of thermalByRoom.entries()) {
if (roomAlarms.length >= 2) {
results.push({
id: `thermal-${room}`,
title: `Thermal event — ${room.replace("hall-", "Hall ")}`,
severity: roomAlarms.some(a => a.severity === "critical") ? "critical" : "warning",
description: `${roomAlarms.length} thermal alarms in the same room. Probable cause: CRAC cooling degradation or containment breach.`,
alarmIds: roomAlarms.map(a => a.id),
});
}
}
// Rule 2: ≥3 power alarms across different racks → PDU or UPS path issue
const powerAlarms = active.filter(a =>
a.sensor_id ? /power|pdu|ups|kw|watt/i.test(a.sensor_id) : /power|overload|circuit/i.test(a.message)
);
const powerRacks = new Set(powerAlarms.map(a => a.rack_id).filter(Boolean));
if (powerRacks.size >= 2) {
results.push({
id: "power-multi-rack",
title: "Multi-rack power event",
severity: powerAlarms.some(a => a.severity === "critical") ? "critical" : "warning",
description: `Power alarms on ${powerRacks.size} racks simultaneously. Probable cause: upstream PDU, busway tap, or UPS transfer.`,
alarmIds: powerAlarms.map(a => a.id),
});
}
// Rule 3: Generator + ATS alarms together → power path / utility failure
const genAlarm = active.find(a => a.sensor_id ? /gen/i.test(a.sensor_id) : /generator/i.test(a.message));
const atsAlarm = active.find(a => a.sensor_id ? /ats/i.test(a.sensor_id) : /transfer|utility/i.test(a.message));
if (genAlarm && atsAlarm) {
results.push({
id: "gen-ats-event",
title: "Power path event — generator + ATS",
severity: "critical",
description: "Generator and ATS alarms are co-active. Possible utility failure with generator transfer in progress.",
alarmIds: [genAlarm.id, atsAlarm.id],
});
}
// Rule 4: ≥2 leak alarms → site-wide leak / pipe burst
const leakAlarms = active.filter(a =>
a.sensor_id ? /leak|water|flood/i.test(a.sensor_id) : /leak|water/i.test(a.message)
);
if (leakAlarms.length >= 2) {
results.push({
id: "multi-leak",
title: "Multiple leak sensors triggered",
severity: "critical",
description: `${leakAlarms.length} leak sensors active. Probable cause: pipe burst, chilled water leak, or CRAC drain overflow.`,
alarmIds: leakAlarms.map(a => a.id),
});
}
// Rule 5: VESDA + high temp in same room → fire / smoke event
const vesdaAlarm = active.find(a => a.sensor_id ? /vesda|fire/i.test(a.sensor_id) : /fire|smoke|vesda/i.test(a.message));
const hotRooms = new Set(active.filter(a => a.severity === "critical" && a.room_id && /temp/i.test(a.message + (a.sensor_id ?? ""))).map(a => a.room_id));
if (vesdaAlarm && hotRooms.size > 0) {
results.push({
id: "fire-temp-event",
title: "Fire / smoke event suspected",
severity: "critical",
description: "VESDA alarm co-active with critical temperature alarms. Possible fire or smoke event — check fire safety systems immediately.",
alarmIds: active.filter(a => hotRooms.has(a.room_id)).map(a => a.id).concat(vesdaAlarm.id),
});
}
return results;
}
function RootCausePanel({ alarms }: { alarms: Alarm[] }) {
const correlations = correlateAlarms(alarms);
if (correlations.length === 0) return null;
return (
<Card className="border-amber-500/30 bg-amber-500/5">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Activity className="w-4 h-4 text-amber-400" />
Root Cause Analysis
<span className="text-[10px] font-normal text-muted-foreground ml-1">
{correlations.length} pattern{correlations.length > 1 ? "s" : ""} detected
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{correlations.map(c => (
<div key={c.id} className={cn(
"flex items-start gap-3 rounded-lg px-3 py-2.5 border",
c.severity === "critical"
? "bg-destructive/10 border-destructive/20"
: "bg-amber-500/10 border-amber-500/20",
)}>
<AlertTriangle className={cn(
"w-3.5 h-3.5 shrink-0 mt-0.5",
c.severity === "critical" ? "text-destructive" : "text-amber-400",
)} />
<div className="space-y-0.5 min-w-0">
<p className={cn(
"text-xs font-semibold",
c.severity === "critical" ? "text-destructive" : "text-amber-400",
)}>
{c.title}
<span className="text-muted-foreground font-normal ml-2">
({c.alarmIds.length} alarm{c.alarmIds.length !== 1 ? "s" : ""})
</span>
</p>
<p className="text-[11px] text-muted-foreground">{c.description}</p>
</div>
</div>
))}
</CardContent>
</Card>
);
}
export default function AlarmsPage() {
const router = useRouter();
const now = useNow(30_000);
const [alarms, setAlarms] = useState<Alarm[]>([]);
const [allAlarms, setAllAlarms] = useState<Alarm[]>([]);
const [stats, setStats] = useState<AlarmStats | null>(null);
const [stateFilter, setStateFilter] = useState<StateFilter>("active");
const [sevFilter, setSevFilter] = useState<SeverityFilter>("all");
const [sortKey, setSortKey] = useState<SortKey>("triggered_at");
const [sortDir, setSortDir] = useState<SortDir>("desc");
const [loading, setLoading] = useState(true);
const [acting, setActing] = useState<number | null>(null);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [bulkActing, setBulkActing] = useState(false);
const [selectedRack, setSelectedRack] = useState<string | null>(null);
const [assignments, setAssignments] = useState<Record<number, string>>({});
const [page, setPage] = useState(1);
useEffect(() => {
try {
setAssignments(JSON.parse(localStorage.getItem("alarm-assignments") ?? "{}"));
} catch {}
}, []);
function setAssignment(id: number, assignee: string) {
const next = { ...assignments, [id]: assignee };
setAssignments(next);
localStorage.setItem("alarm-assignments", JSON.stringify(next));
}
const load = useCallback(async () => {
try {
const [a, s, all] = await Promise.all([
fetchAlarms(SITE_ID, stateFilter),
fetchAlarmStats(SITE_ID),
fetchAlarms(SITE_ID, "all", 200),
]);
setAlarms(a);
setStats(s);
setAllAlarms(all);
} catch {
toast.error("Failed to load alarms");
} finally {
setLoading(false);
}
}, [stateFilter]);
useEffect(() => {
setLoading(true);
load();
const id = setInterval(load, 15_000);
return () => clearInterval(id);
}, [load]);
// Reset page when filters change
useEffect(() => {
setPage(1);
}, [stateFilter, sevFilter]);
async function handleAcknowledge(id: number) {
setActing(id);
try { await acknowledgeAlarm(id); toast.success("Alarm acknowledged"); await load(); } finally { setActing(null); }
}
async function handleResolve(id: number) {
setActing(id);
try { await resolveAlarm(id); toast.success("Alarm resolved"); await load(); } finally { setActing(null); }
}
async function handleBulkResolve() {
setBulkActing(true);
const count = selected.size;
try {
await Promise.all(Array.from(selected).map((id) => resolveAlarm(id)));
toast.success(`${count} alarm${count !== 1 ? "s" : ""} resolved`);
setSelected(new Set());
await load();
} finally { setBulkActing(false); }
}
function toggleSelect(id: number) {
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function toggleSelectAll() {
const resolvable = visible.filter((a) => a.state !== "resolved").map((a) => a.id);
if (resolvable.every((id) => selected.has(id))) {
setSelected(new Set());
} else {
setSelected(new Set(resolvable));
}
}
const sevOrder: Record<string, number> = { critical: 0, warning: 1, info: 2 };
const stateOrder: Record<string, number> = { active: 0, acknowledged: 1, resolved: 2 };
function toggleSort(key: SortKey) {
if (sortKey === key) setSortDir((d) => d === "asc" ? "desc" : "asc");
else { setSortKey(key); setSortDir("desc"); }
}
function SortIcon({ col }: { col: SortKey }) {
if (sortKey !== col) return <ChevronsUpDown className="w-3 h-3 opacity-40" />;
return sortDir === "asc" ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />;
}
const visible = (sevFilter === "all" ? alarms : alarms.filter((a) => a.severity === sevFilter))
.slice()
.sort((a, b) => {
let cmp = 0;
if (sortKey === "severity") cmp = (sevOrder[a.severity] ?? 9) - (sevOrder[b.severity] ?? 9);
if (sortKey === "triggered_at") cmp = new Date(a.triggered_at).getTime() - new Date(b.triggered_at).getTime();
if (sortKey === "state") cmp = (stateOrder[a.state] ?? 9) - (stateOrder[b.state] ?? 9);
return sortDir === "asc" ? cmp : -cmp;
});
const pageCount = Math.ceil(visible.length / PAGE_SIZE);
const paginated = visible.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return (
<div className="p-6 space-y-6">
<RackDetailSheet siteId={SITE_ID} rackId={selectedRack} onClose={() => setSelectedRack(null)} />
<div>
<h1 className="text-xl font-semibold">Alarms &amp; Events</h1>
<p className="text-sm text-muted-foreground">Singapore DC01 refreshes every 15s</p>
</div>
{/* Escalation banner — longest unacknowledged critical */}
{(() => {
const critActive = alarms.filter(a => a.severity === "critical" && a.state === "active");
if (critActive.length === 0) return null;
const oldest = critActive.reduce((a, b) =>
new Date(a.triggered_at) < new Date(b.triggered_at) ? a : b
);
const mins = escalationMinutes(oldest.triggered_at, now);
const urgency = mins >= 60 ? "bg-destructive/10 border-destructive/30 text-destructive"
: mins >= 15 ? "bg-amber-500/10 border-amber-500/30 text-amber-400"
: "bg-amber-500/5 border-amber-500/20 text-amber-300";
return (
<div className={cn("flex items-center gap-3 rounded-lg border px-4 py-2.5 text-xs", urgency)}>
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
<span>
<strong>{critActive.length} critical alarm{critActive.length > 1 ? "s" : ""}</strong> unacknowledged
{" — "}longest open for <strong><EscalationTimer triggeredAt={oldest.triggered_at} now={now} /></strong>
</span>
</div>
);
})()}
{/* Root cause correlation panel */}
{!loading && <RootCausePanel alarms={allAlarms} />}
{/* Stat cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{stats ? (
<>
<StatCard label="Active" value={stats.active} icon={Bell} highlight />
<AvgAgeCard alarms={allAlarms} />
<StatCard label="Acknowledged" value={stats.acknowledged} icon={Clock} />
<StatCard label="Resolved" value={stats.resolved} icon={CheckCircle2} />
</>
) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20" />)
)}
</div>
{/* Sticky filter bar */}
<div className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm -mx-6 px-6 py-3 border-b border-border/30">
<div className="flex flex-wrap items-center gap-3">
<Tabs value={stateFilter} onValueChange={(v) => setStateFilter(v as StateFilter)}>
<TabsList>
<TabsTrigger value="active">Active</TabsTrigger>
<TabsTrigger value="acknowledged">Acknowledged</TabsTrigger>
<TabsTrigger value="resolved">Resolved</TabsTrigger>
<TabsTrigger value="all">All</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex items-center gap-1">
{(["all", "critical", "warning", "info"] as SeverityFilter[]).map((s) => (
<button
key={s}
onClick={() => setSevFilter(s)}
className={cn(
"px-2.5 py-1 rounded-md text-xs font-medium capitalize transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
sevFilter === s
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
{s}
</button>
))}
</div>
{/* Bulk actions inline in filter row */}
{selected.size > 0 && (
<div className="flex items-center gap-3 ml-auto">
<span className="text-xs text-muted-foreground">{selected.size} selected</span>
<Button
size="sm"
variant="outline"
className="h-7 text-xs text-green-400 border-green-500/30 hover:bg-green-500/10"
disabled={bulkActing}
onClick={handleBulkResolve}
>
<XCircle className="w-3 h-3 mr-1" />
Resolve selected ({selected.size})
</Button>
<button
onClick={() => setSelected(new Set())}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear
</button>
</div>
)}
</div>
</div>
{/* Row count */}
{!loading && (
<p className="text-xs text-muted-foreground">
{visible.length} alarm{visible.length !== 1 ? "s" : ""} matching filter
</p>
)}
{/* Table */}
<Card>
<CardContent className="p-0">
{loading ? (
<div className="p-4 space-y-3">
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : visible.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
<CheckCircle2 className="w-8 h-8 text-green-500/50" />
<p className="text-sm">No alarms matching this filter</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground uppercase tracking-wide">
<th className="px-4 py-3 w-8">
<input
type="checkbox"
className="rounded"
checked={visible.filter((a) => a.state !== "resolved").every((a) => selected.has(a.id))}
onChange={toggleSelectAll}
/>
</th>
<th className="text-left px-4 py-3 font-medium">
<button onClick={() => toggleSort("severity")} className="flex items-center gap-1 hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded">
Severity <SortIcon col="severity" />
</button>
</th>
<th className="text-left px-4 py-3 font-medium">Message</th>
<th className="text-left px-4 py-3 font-medium">Location</th>
<th className="text-left px-4 py-3 font-medium hidden xl:table-cell">Sensor</th>
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Category</th>
<th className="text-left px-4 py-3 font-medium">
<button onClick={() => toggleSort("state")} className="flex items-center gap-1 hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded">
State <SortIcon col="state" />
</button>
</th>
<th className="text-left px-4 py-3 font-medium">
<button onClick={() => toggleSort("triggered_at")} className="flex items-center gap-1 hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded">
Age <SortIcon col="triggered_at" />
</button>
</th>
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Escalation</th>
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Assigned</th>
<th className="text-right px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{paginated.map((alarm) => {
const sc = stateConfig[alarm.state] ?? stateConfig.active;
const cat = alarmCategory(alarm.sensor_id);
return (
<tr key={alarm.id} className={cn("hover:bg-muted/30 transition-colors", selected.has(alarm.id) && "bg-muted/20")}>
<td className="px-4 py-3 w-8">
{alarm.state !== "resolved" && (
<input
type="checkbox"
className="rounded"
checked={selected.has(alarm.id)}
onChange={() => toggleSelect(alarm.id)}
/>
)}
</td>
<td className="px-4 py-3">
<SeverityBadge severity={alarm.severity} />
</td>
<td className="px-4 py-3 max-w-xs">
<span className="line-clamp-2">{alarm.message}</span>
</td>
<td className="px-4 py-3 text-xs">
{(alarm.room_id || alarm.rack_id) ? (
<div className="flex items-center gap-1 flex-wrap">
{alarm.room_id && (
<button
onClick={() => router.push("/environmental")}
className="text-muted-foreground hover:text-primary transition-colors underline-offset-2 hover:underline"
>
{alarm.room_id}
</button>
)}
{alarm.room_id && alarm.rack_id && <span className="text-muted-foreground/40">/</span>}
{alarm.rack_id && (
<button
onClick={() => setSelectedRack(alarm.rack_id)}
className="text-muted-foreground hover:text-primary transition-colors underline-offset-2 hover:underline"
>
{alarm.rack_id}
</button>
)}
</div>
) : <span className="text-muted-foreground"></span>}
</td>
<td className="px-4 py-3 hidden xl:table-cell">
{alarm.sensor_id ? (
<span className="font-mono text-[10px] text-muted-foreground bg-muted/40 px-1.5 py-0.5 rounded">
{alarm.sensor_id.split("/").slice(-1)[0]}
</span>
) : <span className="text-muted-foreground text-xs"></span>}
</td>
<td className="px-4 py-3 hidden md:table-cell">
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", cat.className)}>
{cat.label}
</span>
</td>
<td className="px-4 py-3">
<Badge className={cn("text-[10px] font-semibold border-0", sc.className)}>
{sc.label}
</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground text-xs whitespace-nowrap tabular-nums">
{timeAgo(alarm.triggered_at)}
</td>
<td className="px-4 py-3 hidden md:table-cell">
{alarm.state !== "resolved" && alarm.severity === "critical" ? (
<EscalationTimer triggeredAt={alarm.triggered_at} now={now} />
) : (
<span className="text-muted-foreground/30 text-xs"></span>
)}
</td>
<td className="px-4 py-3 hidden md:table-cell">
<select
value={assignments[alarm.id] ?? ""}
onChange={(e) => setAssignment(alarm.id, e.target.value)}
className="text-[10px] bg-muted/30 border border-border rounded px-1.5 py-0.5 text-foreground/80 focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<option value=""> Unassigned</option>
<option value="Alice T.">Alice T.</option>
<option value="Bob K.">Bob K.</option>
<option value="Charlie L.">Charlie L.</option>
<option value="Dave M.">Dave M.</option>
</select>
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
{alarm.state === "active" && (
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
disabled={acting === alarm.id}
onClick={() => handleAcknowledge(alarm.id)}
>
<Clock className="w-3 h-3 mr-1" />
Ack
</Button>
)}
{(alarm.state === "active" || alarm.state === "acknowledged") && (
<Button
size="sm"
variant="outline"
className="h-7 text-xs text-green-400 border-green-500/30 hover:bg-green-500/10"
disabled={acting === alarm.id}
onClick={() => handleResolve(alarm.id)}
>
<XCircle className="w-3 h-3 mr-1" />
Resolve
</Button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Pagination bar */}
{!loading && visible.length > PAGE_SIZE && (
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
Showing {(page - 1) * PAGE_SIZE + 1}{Math.min(page * PAGE_SIZE, visible.length)} of {visible.length} alarms
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
disabled={page <= 1}
onClick={() => setPage(p => p - 1)}
>
Previous
</Button>
<span className="px-2">{page} / {pageCount}</span>
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
disabled={page >= pageCount}
onClick={() => setPage(p => p + 1)}
>
Next
</Button>
</div>
</div>
)}
</div>
);
}