diff --git a/ui/src/pages/Alerts/InboxPage.tsx b/ui/src/pages/Alerts/InboxPage.tsx index b7760bdf..38b26fb3 100644 --- a/ui/src/pages/Alerts/InboxPage.tsx +++ b/ui/src/pages/Alerts/InboxPage.tsx @@ -1,75 +1,183 @@ import { useMemo, useState } from 'react'; -import { Link } from 'react-router'; -import { Inbox } from 'lucide-react'; +import { Link, useNavigate } from 'react-router'; +import { Inbox, Trash2 } from 'lucide-react'; import { - Button, ButtonGroup, DataTable, EmptyState, useToast, + Button, ButtonGroup, ConfirmDialog, DataTable, EmptyState, Toggle, useToast, } from '@cameleer/design-system'; import type { ButtonGroupItem, Column } from '@cameleer/design-system'; import { PageLoader } from '../../components/PageLoader'; import { SeverityBadge } from '../../components/SeverityBadge'; import { AlertStateChip } from '../../components/AlertStateChip'; import { - useAlerts, useAckAlert, useBulkReadAlerts, useMarkAlertRead, + useAlerts, useAckAlert, useBulkAckAlerts, useBulkReadAlerts, useMarkAlertRead, + useDeleteAlert, useBulkDeleteAlerts, useRestoreAlert, type AlertDto, } from '../../api/queries/alerts'; +import { useCreateSilence } from '../../api/queries/alertSilences'; +import { useAuthStore } from '../../auth/auth-store'; +import { SilenceRuleMenu } from './SilenceRuleMenu'; import { severityToAccent } from './severity-utils'; import { formatRelativeTime } from './time-utils'; import { renderAlertExpanded } from './alert-expanded'; import css from './alerts-page.module.css'; import tableStyles from '../../styles/table-section.module.css'; -type Severity = NonNullable; +type AlertSeverity = NonNullable; +type AlertState = NonNullable; + +// ── Filter bar items ──────────────────────────────────────────────────────── const SEVERITY_ITEMS: ButtonGroupItem[] = [ - { value: 'CRITICAL', label: 'Critical', color: 'var(--error)' }, - { value: 'WARNING', label: 'Warning', color: 'var(--warning)' }, - { value: 'INFO', label: 'Info', color: 'var(--text-muted)' }, + { value: 'CRITICAL', label: 'Critical', color: 'var(--error)' }, + { value: 'WARNING', label: 'Warning', color: 'var(--warning)' }, + { value: 'INFO', label: 'Info', color: 'var(--text-muted)' }, ]; +const STATE_ITEMS: ButtonGroupItem[] = [ + { value: 'PENDING', label: 'Pending' }, + { value: 'FIRING', label: 'Firing' }, + { value: 'RESOLVED', label: 'Resolved' }, +]; + +// ── Bulk silence helper ───────────────────────────────────────────────────── + +const SILENCE_PRESETS: Array<{ label: string; hours: number }> = [ + { label: '1 hour', hours: 1 }, + { label: '8 hours', hours: 8 }, + { label: '24 hours', hours: 24 }, +]; + +interface SilenceRulesForSelectionProps { + selected: Set; + rows: AlertDto[]; +} + +function SilenceRulesForSelection({ selected, rows }: SilenceRulesForSelectionProps) { + const navigate = useNavigate(); + const { toast } = useToast(); + const createSilence = useCreateSilence(); + + const ruleIds = useMemo(() => { + const ids = new Set(); + for (const id of selected) { + const row = rows.find((r) => r.id === id); + if (row?.ruleId) ids.add(row.ruleId); + } + return [...ids]; + }, [selected, rows]); + + if (ruleIds.length === 0) return null; + + const handlePreset = (hours: number) => async () => { + const now = new Date(); + const results = await Promise.allSettled( + ruleIds.map((ruleId) => + createSilence.mutateAsync({ + matcher: { ruleId }, + reason: 'Silenced from inbox (bulk)', + startsAt: now.toISOString(), + endsAt: new Date(now.getTime() + hours * 3_600_000).toISOString(), + }), + ), + ); + const failed = results.filter((r) => r.status === 'rejected').length; + if (failed === 0) { + toast({ title: `Silenced ${ruleIds.length} rule${ruleIds.length === 1 ? '' : 's'} for ${hours}h`, variant: 'success' }); + } else { + toast({ title: `Silenced ${ruleIds.length - failed}/${ruleIds.length} rules`, description: `${failed} failed`, variant: 'warning' }); + } + }; + + const handleCustom = () => navigate('/alerts/silences'); + + // Render ONE SilenceRuleMenu that uses the first ruleId as its anchor but + // overrides the click handlers to fire against all selected rule IDs. + // We use a Dropdown-equivalent by wiring SilenceRuleMenu with the first + // ruleId; for bulk we drive our own mutation loop above. + // Since SilenceRuleMenu is self-contained, we render a parallel Button set + // for the bulk path to keep it clean. + return ( +
+ {SILENCE_PRESETS.map(({ label, hours }) => ( + + ))} + +
+ ); +} + +// ── InboxPage ─────────────────────────────────────────────────────────────── + export default function InboxPage() { + // Filter state — defaults: FIRING selected, hide-acked on, hide-read on const [severitySel, setSeveritySel] = useState>(new Set()); - const severityValues: Severity[] | undefined = severitySel.size === 0 - ? undefined - : [...severitySel] as Severity[]; + const [stateSel, setStateSel] = useState>(new Set(['FIRING'])); + const [hideAcked, setHideAcked] = useState(true); + const [hideRead, setHideRead] = useState(true); const { data, isLoading, error } = useAlerts({ - state: ['FIRING', 'ACKNOWLEDGED'], - severity: severityValues, + severity: severitySel.size ? ([...severitySel] as AlertSeverity[]) : undefined, + state: stateSel.size ? ([...stateSel] as AlertState[]) : undefined, + acked: hideAcked ? false : undefined, + read: hideRead ? false : undefined, limit: 200, }); - const bulkRead = useBulkReadAlerts(); - const markRead = useMarkAlertRead(); - const ack = useAckAlert(); - const { toast } = useToast(); - const [selected, setSelected] = useState>(new Set()); + // Mutations + const ack = useAckAlert(); + const bulkAck = useBulkAckAlerts(); + const markRead = useMarkAlertRead(); + const bulkRead = useBulkReadAlerts(); + const del = useDeleteAlert(); + const bulkDelete = useBulkDeleteAlerts(); + const restore = useRestoreAlert(); + const { toast } = useToast(); + + // Selection + const [selected, setSelected] = useState>(new Set()); + const [deletePending, setDeletePending] = useState(null); + + // RBAC + const roles = useAuthStore((s) => s.roles); + const canDelete = roles.includes('OPERATOR') || roles.includes('ADMIN'); + const rows = data ?? []; - const unreadIds = useMemo( - () => rows.filter((a) => a.state === 'FIRING').map((a) => a.id), - [rows], - ); - - const firingIds = unreadIds; // FIRING alerts are the ones that can be ack'd - - const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.id)); + const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.id)); const someSelected = selected.size > 0 && !allSelected; - const toggleSelected = (id: string) => { + const toggleSelected = (id: string) => setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); - }; - const toggleSelectAll = () => { - if (allSelected) { - setSelected(new Set()); - } else { - setSelected(new Set(rows.map((r) => r.id))); - } - }; + const toggleSelectAll = () => + setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id))); + + // Derived counts for bulk-toolbar labels + const selectedRows = rows.filter((r) => selected.has(r.id)); + const unackedSel = selectedRows.filter((r) => r.ackedAt == null).map((r) => r.id); + const unreadSel = selectedRows.filter((r) => r.readAt == null).map((r) => r.id); + + // "Acknowledge all firing" target (no-selection state) + const firingUnackedIds = rows + .filter((r) => r.state === 'FIRING' && r.ackedAt == null) + .map((r) => r.id); + const allUnreadIds = rows.filter((r) => r.readAt == null).map((r) => r.id); + + // ── handlers ─────────────────────────────────────────────────────────────── const onAck = async (id: string, title?: string) => { try { @@ -80,10 +188,45 @@ export default function InboxPage() { } }; + const onMarkRead = async (id: string) => { + try { + await markRead.mutateAsync(id); + toast({ title: 'Marked as read', variant: 'success' }); + } catch (e) { + toast({ title: 'Mark read failed', description: String(e), variant: 'error' }); + } + }; + + const onDeleteOne = async (id: string) => { + try { + await del.mutateAsync(id); + // No built-in action slot in DS toast — render Undo as a Button node + const undoNode = ( + + ) as unknown as string; // DS description accepts ReactNode at runtime + toast({ title: 'Deleted', description: undoNode, variant: 'success', duration: 5000 }); + } catch (e) { + toast({ title: 'Delete failed', description: String(e), variant: 'error' }); + } + }; + const onBulkAck = async (ids: string[]) => { if (ids.length === 0) return; try { - await Promise.all(ids.map((id) => ack.mutateAsync(id))); + await bulkAck.mutateAsync(ids); setSelected(new Set()); toast({ title: `Acknowledged ${ids.length} alert${ids.length === 1 ? '' : 's'}`, variant: 'success' }); } catch (e) { @@ -102,6 +245,8 @@ export default function InboxPage() { } }; + // ── columns ──────────────────────────────────────────────────────────────── + const columns: Column[] = [ { key: 'select', header: '', width: '40px', @@ -128,7 +273,7 @@ export default function InboxPage() { { key: 'title', header: 'Title', render: (_, row) => { - const unread = row.state === 'FIRING'; + const unread = row.readAt == null; return (
markRead.mutate(row.id)}> @@ -149,31 +294,68 @@ export default function InboxPage() { ) : '—', }, { - key: 'ack', header: '', width: '120px', - render: (_, row) => - row.state === 'FIRING' ? ( - - ) : null, + key: 'rowActions', header: '', width: '220px', + render: (_, row) => ( +
+ {row.ackedAt == null && ( + + )} + {row.readAt == null && ( + + )} + {row.ruleId && ( + + )} + {canDelete && ( + + )} +
+ ), }, ]; + // ── render ───────────────────────────────────────────────────────────────── + if (isLoading) return ; - if (error) return
Failed to load alerts: {String(error)}
; + if (error) return
Failed to load alerts: {String(error)}
; const selectedIds = Array.from(selected); - const selectedFiringIds = rows - .filter((r) => selected.has(r.id) && r.state === 'FIRING') - .map((r) => r.id); + const needsAttention = rows.filter((r) => r.readAt == null || r.ackedAt == null).length; const subtitle = selectedIds.length > 0 ? `${selectedIds.length} selected` - : `${unreadIds.length} need attention · ${rows.length} total in inbox`; + : `${needsAttention} need attention · ${rows.length} total`; return (
+ {/* ── Header ─────────────────────────────────────────────────────── */}

Inbox

@@ -185,9 +367,25 @@ export default function InboxPage() { value={severitySel} onChange={setSeveritySel} /> + + setHideAcked(e.currentTarget.checked)} + /> + setHideRead(e.currentTarget.checked)} + />
+ {/* ── Filter / bulk toolbar ───────────────────────────────────────── */}
+ {/* ── Table / empty ───────────────────────────────────────────────── */} {rows.length === 0 ? ( } title="All clear" - description="No open alerts for you in this environment." + description="No alerts match the current filters." /> ) : (
@@ -263,6 +474,24 @@ export default function InboxPage() { />
)} + + {/* ── Bulk delete confirmation ─────────────────────────────────────── */} + setDeletePending(null)} + onConfirm={async () => { + if (!deletePending) return; + await bulkDelete.mutateAsync(deletePending); + toast({ title: `Deleted ${deletePending.length}`, variant: 'success' }); + setDeletePending(null); + setSelected(new Set()); + }} + title="Delete alerts?" + message={`Delete ${deletePending?.length ?? 0} alerts? This affects all users.`} + confirmText="Delete" + variant="danger" + loading={bulkDelete.isPending} + />
); }