753 lines
32 KiB
TypeScript
753 lines
32 KiB
TypeScript
"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 & 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>
|
||
);
|
||
}
|