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,753 @@
"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>
);
}