124 lines
5 KiB
TypeScript
124 lines
5 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { cn } from "@/lib/utils";
|
|
import { acknowledgeAlarm, type Alarm } from "@/lib/api";
|
|
import { useState } from "react";
|
|
import { ChevronRight } from "lucide-react";
|
|
|
|
interface Props {
|
|
alarms: Alarm[];
|
|
loading?: boolean;
|
|
onAcknowledge?: () => void;
|
|
onAlarmClick?: (alarm: Alarm) => void;
|
|
}
|
|
|
|
const severityStyles: Record<string, { badge: string; dot: string }> = {
|
|
critical: { badge: "bg-destructive/20 text-destructive border-destructive/30", dot: "bg-destructive" },
|
|
warning: { badge: "bg-amber-500/20 text-amber-400 border-amber-500/30", dot: "bg-amber-400" },
|
|
info: { badge: "bg-primary/20 text-primary border-primary/30", dot: "bg-primary" },
|
|
};
|
|
|
|
function timeAgo(iso: string) {
|
|
const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
|
if (secs < 60) return `${secs}s ago`;
|
|
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
|
|
return `${Math.floor(secs / 3600)}h ago`;
|
|
}
|
|
|
|
export function AlarmFeed({ alarms, loading, onAcknowledge, onAlarmClick }: Props) {
|
|
const [acking, setAcking] = useState<number | null>(null);
|
|
|
|
async function handleAck(id: number) {
|
|
setAcking(id);
|
|
try {
|
|
await acknowledgeAlarm(id);
|
|
onAcknowledge?.();
|
|
} catch { /* ignore */ }
|
|
finally { setAcking(null); }
|
|
}
|
|
|
|
const activeCount = alarms.filter((a) => a.state === "active").length;
|
|
|
|
return (
|
|
<Card className="flex flex-col">
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm font-semibold">Active Alarms</CardTitle>
|
|
<div className="flex items-center gap-2">
|
|
{!loading && (
|
|
<Badge variant={activeCount > 0 ? "destructive" : "outline"} className="text-[10px] h-4 px-1.5">
|
|
{activeCount > 0 ? `${activeCount} active` : "All clear"}
|
|
</Badge>
|
|
)}
|
|
<Link href="/alarms" className="text-[10px] text-muted-foreground hover:text-primary transition-colors">
|
|
View all →
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="flex-1 space-y-3 overflow-y-auto max-h-72">
|
|
{loading ? (
|
|
<Skeleton className="h-80 w-full" />
|
|
) : alarms.length === 0 ? (
|
|
<div className="flex items-center justify-center h-24 text-sm text-muted-foreground">
|
|
No active alarms
|
|
</div>
|
|
) : (
|
|
alarms.map((alarm) => {
|
|
const style = severityStyles[alarm.severity] ?? severityStyles.info;
|
|
const clickable = !!onAlarmClick;
|
|
const locationLabel = [alarm.rack_id, alarm.room_id].find(Boolean);
|
|
return (
|
|
<div
|
|
key={alarm.id}
|
|
onClick={() => onAlarmClick?.(alarm)}
|
|
className={cn(
|
|
"flex gap-2.5 group rounded-md px-1 py-1 -mx-1 transition-colors",
|
|
clickable && "cursor-pointer hover:bg-muted/40"
|
|
)}
|
|
>
|
|
<div className="mt-1.5 shrink-0">
|
|
<span className={cn("block w-2 h-2 rounded-full", style.dot)} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-xs leading-snug text-foreground">{alarm.message}</p>
|
|
<div className="mt-0.5 flex items-center gap-1.5">
|
|
{locationLabel && (
|
|
<span className="text-[10px] text-muted-foreground font-medium">{locationLabel}</span>
|
|
)}
|
|
{locationLabel && <span className="text-[10px] text-muted-foreground/50">·</span>}
|
|
<span className="text-[10px] text-muted-foreground">{timeAgo(alarm.triggered_at)}</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-1 shrink-0">
|
|
<Badge variant="outline" className={cn("text-[9px] h-4 px-1 uppercase tracking-wide", style.badge)}>
|
|
{alarm.severity}
|
|
</Badge>
|
|
{alarm.state === "active" && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-4 px-1 text-[9px] opacity-0 group-hover:opacity-100 transition-opacity"
|
|
disabled={acking === alarm.id}
|
|
onClick={(e) => { e.stopPropagation(); handleAck(alarm.id); }}
|
|
>
|
|
Ack
|
|
</Button>
|
|
)}
|
|
{clickable && (
|
|
<ChevronRight className="w-3 h-3 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors mt-auto" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|