first commit
This commit is contained in:
commit
4b98219bf7
144 changed files with 31561 additions and 0 deletions
124
frontend/components/dashboard/alarm-feed.tsx
Normal file
124
frontend/components/dashboard/alarm-feed.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue