- Remove unused imports: Mock, VariableDef, within, Badge, deviceGroupsApi, devicesApi - Fix Unexpected any in AlertRulesPage catch block (use unknown + type assertion) - Suppress react-refresh/only-export-components for getPasswordScore helper - Add Link mock to LoginPage test and useAuth.getState() stub for navigation test - Fix DeviceList tests to use data-testid selectors and correct empty state text (component renders dual mobile/desktop views causing multiple-element errors) - Remove unused container destructuring from TemplatePushWizard test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
396 lines
14 KiB
TypeScript
396 lines
14 KiB
TypeScript
/**
|
|
* AlertsPage — Active alerts and alert history with filtering, acknowledge, and silence.
|
|
*/
|
|
|
|
import { useState } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { Link } from '@tanstack/react-router'
|
|
import {
|
|
Bell,
|
|
BellOff,
|
|
BellRing,
|
|
Building2,
|
|
CheckCircle,
|
|
AlertTriangle,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
} from 'lucide-react'
|
|
import { alertsApi, type AlertEvent, type AlertsFilterParams } from '@/lib/alertsApi'
|
|
import { useAuth, isSuperAdmin } from '@/lib/auth'
|
|
import { useUIStore } from '@/lib/store'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { toast } from '@/components/ui/toast'
|
|
import { cn, formatDateTime } from '@/lib/utils'
|
|
import { TableSkeleton } from '@/components/ui/page-skeleton'
|
|
import { EmptyState } from '@/components/ui/empty-state'
|
|
|
|
function SeverityBadge({ severity }: { severity: string }) {
|
|
const config: Record<string, string> = {
|
|
critical: 'bg-error/20 text-error border-error/40',
|
|
warning: 'bg-warning/20 text-warning border-warning/40',
|
|
info: 'bg-info/20 text-info border-info/40',
|
|
}
|
|
return (
|
|
<span
|
|
className={cn(
|
|
'text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border',
|
|
config[severity] ?? config.info,
|
|
)}
|
|
>
|
|
{severity}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function StatusIcon({ status }: { status: string }) {
|
|
if (status === 'firing') return <BellRing className="h-4 w-4 text-error" />
|
|
if (status === 'resolved') return <CheckCircle className="h-4 w-4 text-success" />
|
|
if (status === 'flapping') return <AlertTriangle className="h-4 w-4 text-warning" />
|
|
return <Bell className="h-4 w-4 text-text-muted" />
|
|
}
|
|
|
|
function timeAgo(dateStr: string): string {
|
|
const diff = Date.now() - new Date(dateStr).getTime()
|
|
const mins = Math.floor(diff / 60000)
|
|
if (mins < 1) return 'just now'
|
|
if (mins < 60) return `${mins}m ago`
|
|
const hours = Math.floor(mins / 60)
|
|
if (hours < 24) return `${hours}h ago`
|
|
const days = Math.floor(hours / 24)
|
|
return `${days}d ago`
|
|
}
|
|
|
|
function AlertRow({
|
|
alert,
|
|
tenantId,
|
|
onAcknowledge,
|
|
onSilence,
|
|
}: {
|
|
alert: AlertEvent
|
|
tenantId: string
|
|
onAcknowledge: (alertId: string) => void
|
|
onSilence: (alertId: string, minutes: number) => void
|
|
}) {
|
|
const isSilenced =
|
|
alert.silenced_until && new Date(alert.silenced_until) > new Date()
|
|
|
|
return (
|
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-border/50 hover:bg-surface transition-colors">
|
|
<StatusIcon status={alert.status} />
|
|
<SeverityBadge severity={alert.severity} />
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-text-primary truncate">
|
|
{alert.message ?? `${alert.metric} ${alert.value ?? ''}`}
|
|
</span>
|
|
{alert.is_flapping && (
|
|
<span className="text-[10px] text-warning/80 border border-warning/40 rounded px-1">
|
|
flapping
|
|
</span>
|
|
)}
|
|
{isSilenced && <BellOff className="h-3 w-3 text-text-muted" />}
|
|
</div>
|
|
<div className="flex items-center gap-3 text-xs text-text-muted mt-0.5">
|
|
<Link
|
|
to="/tenants/$tenantId/devices/$deviceId"
|
|
params={{ tenantId, deviceId: alert.device_id }}
|
|
className="hover:text-text-primary"
|
|
>
|
|
{alert.device_hostname ?? alert.device_id.slice(0, 8)}
|
|
</Link>
|
|
{alert.rule_name && <span>{alert.rule_name}</span>}
|
|
{alert.threshold != null && (
|
|
<span>
|
|
{alert.value != null ? alert.value.toFixed(1) : '?'} / {alert.threshold}
|
|
</span>
|
|
)}
|
|
<span>{timeAgo(alert.fired_at)}</span>
|
|
{alert.resolved_at && (
|
|
<span className="text-success/60">
|
|
resolved {timeAgo(alert.resolved_at)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{alert.status === 'firing' && !alert.acknowledged_at && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 text-xs"
|
|
onClick={() => onAcknowledge(alert.id)}
|
|
>
|
|
Acknowledge
|
|
</Button>
|
|
)}
|
|
|
|
{alert.status === 'firing' && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="h-7 text-xs">
|
|
<BellOff className="h-3 w-3 mr-1" />
|
|
Silence
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
<DropdownMenuItem onClick={() => onSilence(alert.id, 15)}>15 min</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => onSilence(alert.id, 60)}>1 hour</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => onSilence(alert.id, 240)}>4 hours</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => onSilence(alert.id, 480)}>8 hours</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => onSilence(alert.id, 1440)}>24 hours</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function AlertsPage() {
|
|
const { user } = useAuth()
|
|
const queryClient = useQueryClient()
|
|
const [tab, setTab] = useState('active')
|
|
const [severity, setSeverity] = useState<string>('')
|
|
const [page, setPage] = useState(1)
|
|
|
|
// For super_admin, use global org context; for normal users, use their tenant
|
|
const { selectedTenantId } = useUIStore()
|
|
|
|
const tenantId = isSuperAdmin(user) ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
|
|
|
|
// Build filter params
|
|
const params: AlertsFilterParams = {
|
|
page,
|
|
per_page: 50,
|
|
}
|
|
if (tab === 'active') {
|
|
params.status = 'firing'
|
|
}
|
|
if (severity) {
|
|
params.severity = severity
|
|
}
|
|
|
|
const { data: alertsData, isLoading } = useQuery({
|
|
queryKey: ['alerts', tenantId, tab, severity, page],
|
|
queryFn: () => alertsApi.getAlerts(tenantId, params),
|
|
enabled: !!tenantId,
|
|
refetchInterval: tab === 'active' ? 30_000 : undefined,
|
|
})
|
|
|
|
const acknowledgeMutation = useMutation({
|
|
mutationFn: (alertId: string) => alertsApi.acknowledgeAlert(tenantId, alertId),
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({ queryKey: ['alerts'] })
|
|
void queryClient.invalidateQueries({ queryKey: ['alert-active-count'] })
|
|
toast({ title: 'Alert acknowledged' })
|
|
},
|
|
onError: () => toast({ title: 'Failed to acknowledge', variant: 'destructive' }),
|
|
})
|
|
|
|
const silenceMutation = useMutation({
|
|
mutationFn: ({ alertId, minutes }: { alertId: string; minutes: number }) =>
|
|
alertsApi.silenceAlert(tenantId, alertId, minutes),
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({ queryKey: ['alerts'] })
|
|
void queryClient.invalidateQueries({ queryKey: ['alert-active-count'] })
|
|
toast({ title: 'Alert silenced' })
|
|
},
|
|
onError: () => toast({ title: 'Failed to silence', variant: 'destructive' }),
|
|
})
|
|
|
|
const alerts = alertsData?.items ?? []
|
|
const total = alertsData?.total ?? 0
|
|
const totalPages = Math.ceil(total / 50)
|
|
|
|
return (
|
|
<div className="space-y-4 max-w-4xl">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Bell className="h-5 w-5 text-text-muted" />
|
|
<h1 className="text-lg font-semibold">Alerts</h1>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex items-center gap-3">
|
|
<Select
|
|
value={severity}
|
|
onValueChange={(v) => {
|
|
setSeverity(v === 'all' ? '' : v)
|
|
setPage(1)
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-36 h-8 text-xs">
|
|
<SelectValue placeholder="All severities" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All severities</SelectItem>
|
|
<SelectItem value="critical">Critical</SelectItem>
|
|
<SelectItem value="warning">Warning</SelectItem>
|
|
<SelectItem value="info">Info</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<Tabs value={tab} onValueChange={(v) => { setTab(v); setPage(1) }}>
|
|
<TabsList>
|
|
<TabsTrigger value="active">
|
|
Active
|
|
{tab === 'active' && total > 0 && (
|
|
<span className="ml-2 bg-error/20 text-error text-xs px-1.5 rounded-full">
|
|
{total}
|
|
</span>
|
|
)}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="history">History</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="active" className="mt-3">
|
|
{!tenantId ? (
|
|
<div className="flex flex-col items-center gap-2 py-12 text-text-muted">
|
|
<Building2 className="h-8 w-8" />
|
|
<p className="text-sm">Select an organization from the header to view alerts.</p>
|
|
</div>
|
|
) : isLoading ? (
|
|
<TableSkeleton />
|
|
) : alerts.length === 0 ? (
|
|
<EmptyState
|
|
icon={BellOff}
|
|
title="No active alerts"
|
|
description="All clear! No alerts have been triggered."
|
|
/>
|
|
) : (
|
|
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
|
{alerts.map((alert) => (
|
|
<AlertRow
|
|
key={alert.id}
|
|
alert={alert}
|
|
tenantId={tenantId}
|
|
onAcknowledge={(id) => acknowledgeMutation.mutate(id)}
|
|
onSilence={(id, mins) =>
|
|
silenceMutation.mutate({ alertId: id, minutes: mins })
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="history" className="mt-3">
|
|
{!tenantId ? (
|
|
<div className="flex flex-col items-center gap-2 py-12 text-text-muted">
|
|
<Building2 className="h-8 w-8" />
|
|
<p className="text-sm">Select an organization from the header to view alerts.</p>
|
|
</div>
|
|
) : isLoading ? (
|
|
<TableSkeleton />
|
|
) : alerts.length === 0 ? (
|
|
<EmptyState
|
|
icon={BellOff}
|
|
title="No alert history"
|
|
description="Alert events will appear here as they are triggered and resolved."
|
|
/>
|
|
) : (
|
|
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
|
{/* Table header */}
|
|
<div className="flex items-center gap-3 px-4 py-2 border-b border-border text-xs text-text-muted font-medium">
|
|
<span className="w-5" />
|
|
<span className="w-16">Severity</span>
|
|
<span className="w-16">Status</span>
|
|
<span className="flex-1">Details</span>
|
|
<span className="w-24">Fired</span>
|
|
<span className="w-24">Resolved</span>
|
|
</div>
|
|
{alerts.map((alert) => (
|
|
<div
|
|
key={alert.id}
|
|
className="flex items-center gap-3 px-4 py-2.5 border-b border-border/50 hover:bg-surface text-sm"
|
|
>
|
|
<StatusIcon status={alert.status} />
|
|
<span className="w-16">
|
|
<SeverityBadge severity={alert.severity} />
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
'w-16 text-xs',
|
|
alert.status === 'firing'
|
|
? 'text-error'
|
|
: alert.status === 'resolved'
|
|
? 'text-success'
|
|
: 'text-warning',
|
|
)}
|
|
>
|
|
{alert.status}
|
|
</span>
|
|
<div className="flex-1 min-w-0">
|
|
<span className="text-text-primary truncate block">
|
|
{alert.message ?? alert.metric ?? 'System alert'}
|
|
</span>
|
|
<span className="text-xs text-text-muted">
|
|
{alert.device_hostname ?? alert.device_id.slice(0, 8)}
|
|
{alert.rule_name && ` — ${alert.rule_name}`}
|
|
</span>
|
|
</div>
|
|
<span className="w-24 text-xs text-text-muted">
|
|
{formatDateTime(alert.fired_at)}
|
|
</span>
|
|
<span className="w-24 text-xs text-text-muted">
|
|
{alert.resolved_at ? formatDateTime(alert.resolved_at) : '—'}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between pt-2">
|
|
<span className="text-xs text-text-muted">
|
|
{total} alert{total !== 1 ? 's' : ''} total
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7"
|
|
disabled={page <= 1}
|
|
onClick={() => setPage((p) => p - 1)}
|
|
>
|
|
<ChevronLeft className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<span className="text-xs text-text-secondary">
|
|
{page} / {totalPages}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7"
|
|
disabled={page >= totalPages}
|
|
onClick={() => setPage((p) => p + 1)}
|
|
>
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|