feat(ui/alerts): single inbox — filter bar, silence/delete row + bulk actions

Replaces the old FIRING+ACK hardcoded inbox with the single filterable
inbox:

- Filter bar: Severity · Status (PENDING/FIRING/RESOLVED, default FIRING) ·
  Hide acked (default on) · Hide read (default on).
- Row actions: Ack, Mark read, Silence rule… (quick menu), Delete
  (OPERATOR+, soft delete with undo toast wired to useRestoreAlert).
- Bulk toolbar: Ack N · Mark N read · Silence rules · Delete N
  (ConfirmDialog; OPERATOR+).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-21 19:09:22 +02:00
parent 837fcbf926
commit 2bc214e324

View File

@@ -1,75 +1,183 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Link } from 'react-router'; import { Link, useNavigate } from 'react-router';
import { Inbox } from 'lucide-react'; import { Inbox, Trash2 } from 'lucide-react';
import { import {
Button, ButtonGroup, DataTable, EmptyState, useToast, Button, ButtonGroup, ConfirmDialog, DataTable, EmptyState, Toggle, useToast,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import type { ButtonGroupItem, Column } from '@cameleer/design-system'; import type { ButtonGroupItem, Column } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader'; import { PageLoader } from '../../components/PageLoader';
import { SeverityBadge } from '../../components/SeverityBadge'; import { SeverityBadge } from '../../components/SeverityBadge';
import { AlertStateChip } from '../../components/AlertStateChip'; import { AlertStateChip } from '../../components/AlertStateChip';
import { import {
useAlerts, useAckAlert, useBulkReadAlerts, useMarkAlertRead, useAlerts, useAckAlert, useBulkAckAlerts, useBulkReadAlerts, useMarkAlertRead,
useDeleteAlert, useBulkDeleteAlerts, useRestoreAlert,
type AlertDto, type AlertDto,
} from '../../api/queries/alerts'; } 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 { severityToAccent } from './severity-utils';
import { formatRelativeTime } from './time-utils'; import { formatRelativeTime } from './time-utils';
import { renderAlertExpanded } from './alert-expanded'; import { renderAlertExpanded } from './alert-expanded';
import css from './alerts-page.module.css'; import css from './alerts-page.module.css';
import tableStyles from '../../styles/table-section.module.css'; import tableStyles from '../../styles/table-section.module.css';
type Severity = NonNullable<AlertDto['severity']>; type AlertSeverity = NonNullable<AlertDto['severity']>;
type AlertState = NonNullable<AlertDto['state']>;
// ── Filter bar items ────────────────────────────────────────────────────────
const SEVERITY_ITEMS: ButtonGroupItem[] = [ const SEVERITY_ITEMS: ButtonGroupItem[] = [
{ value: 'CRITICAL', label: 'Critical', color: 'var(--error)' }, { value: 'CRITICAL', label: 'Critical', color: 'var(--error)' },
{ value: 'WARNING', label: 'Warning', color: 'var(--warning)' }, { value: 'WARNING', label: 'Warning', color: 'var(--warning)' },
{ value: 'INFO', label: 'Info', color: 'var(--text-muted)' }, { 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<string>;
rows: AlertDto[];
}
function SilenceRulesForSelection({ selected, rows }: SilenceRulesForSelectionProps) {
const navigate = useNavigate();
const { toast } = useToast();
const createSilence = useCreateSilence();
const ruleIds = useMemo(() => {
const ids = new Set<string>();
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 (
<div style={{ display: 'flex', gap: 'var(--space-xs)' }}>
{SILENCE_PRESETS.map(({ label, hours }) => (
<Button
key={hours}
variant="secondary"
size="sm"
disabled={createSilence.isPending}
onClick={handlePreset(hours)}
>
Silence {hours}h
</Button>
))}
<Button variant="ghost" size="sm" onClick={handleCustom}>
Custom
</Button>
</div>
);
}
// ── InboxPage ───────────────────────────────────────────────────────────────
export default function InboxPage() { export default function InboxPage() {
// Filter state — defaults: FIRING selected, hide-acked on, hide-read on
const [severitySel, setSeveritySel] = useState<Set<string>>(new Set()); const [severitySel, setSeveritySel] = useState<Set<string>>(new Set());
const severityValues: Severity[] | undefined = severitySel.size === 0 const [stateSel, setStateSel] = useState<Set<string>>(new Set(['FIRING']));
? undefined const [hideAcked, setHideAcked] = useState(true);
: [...severitySel] as Severity[]; const [hideRead, setHideRead] = useState(true);
const { data, isLoading, error } = useAlerts({ const { data, isLoading, error } = useAlerts({
state: ['FIRING', 'ACKNOWLEDGED'], severity: severitySel.size ? ([...severitySel] as AlertSeverity[]) : undefined,
severity: severityValues, state: stateSel.size ? ([...stateSel] as AlertState[]) : undefined,
acked: hideAcked ? false : undefined,
read: hideRead ? false : undefined,
limit: 200, limit: 200,
}); });
const bulkRead = useBulkReadAlerts();
const markRead = useMarkAlertRead();
const ack = useAckAlert();
const { toast } = useToast();
const [selected, setSelected] = useState<Set<string>>(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<Set<string>>(new Set());
const [deletePending, setDeletePending] = useState<string[] | null>(null);
// RBAC
const roles = useAuthStore((s) => s.roles);
const canDelete = roles.includes('OPERATOR') || roles.includes('ADMIN');
const rows = data ?? []; const rows = data ?? [];
const unreadIds = useMemo( const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.id));
() => 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 someSelected = selected.size > 0 && !allSelected; const someSelected = selected.size > 0 && !allSelected;
const toggleSelected = (id: string) => { const toggleSelected = (id: string) =>
setSelected((prev) => { setSelected((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id); if (next.has(id)) next.delete(id); else next.add(id);
return next; return next;
}); });
};
const toggleSelectAll = () => { const toggleSelectAll = () =>
if (allSelected) { setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
setSelected(new Set());
} else { // Derived counts for bulk-toolbar labels
setSelected(new Set(rows.map((r) => r.id))); 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) => { const onAck = async (id: string, title?: string) => {
try { 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 = (
<Button
variant="ghost"
size="sm"
onClick={() =>
restore
.mutateAsync(id)
.then(
() => toast({ title: 'Restored', variant: 'success' }),
(e: unknown) => toast({ title: 'Undo failed', description: String(e), variant: 'error' }),
)
}
>
Undo
</Button>
) 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[]) => { const onBulkAck = async (ids: string[]) => {
if (ids.length === 0) return; if (ids.length === 0) return;
try { try {
await Promise.all(ids.map((id) => ack.mutateAsync(id))); await bulkAck.mutateAsync(ids);
setSelected(new Set()); setSelected(new Set());
toast({ title: `Acknowledged ${ids.length} alert${ids.length === 1 ? '' : 's'}`, variant: 'success' }); toast({ title: `Acknowledged ${ids.length} alert${ids.length === 1 ? '' : 's'}`, variant: 'success' });
} catch (e) { } catch (e) {
@@ -102,6 +245,8 @@ export default function InboxPage() {
} }
}; };
// ── columns ────────────────────────────────────────────────────────────────
const columns: Column<AlertDto>[] = [ const columns: Column<AlertDto>[] = [
{ {
key: 'select', header: '', width: '40px', key: 'select', header: '', width: '40px',
@@ -128,7 +273,7 @@ export default function InboxPage() {
{ {
key: 'title', header: 'Title', key: 'title', header: 'Title',
render: (_, row) => { render: (_, row) => {
const unread = row.state === 'FIRING'; const unread = row.readAt == null;
return ( return (
<div className={`${css.titleCell} ${unread ? css.titleCellUnread : ''}`}> <div className={`${css.titleCell} ${unread ? css.titleCellUnread : ''}`}>
<Link to={`/alerts/inbox/${row.id}`} onClick={() => markRead.mutate(row.id)}> <Link to={`/alerts/inbox/${row.id}`} onClick={() => markRead.mutate(row.id)}>
@@ -149,31 +294,68 @@ export default function InboxPage() {
) : '—', ) : '—',
}, },
{ {
key: 'ack', header: '', width: '120px', key: 'rowActions', header: '', width: '220px',
render: (_, row) => render: (_, row) => (
row.state === 'FIRING' ? ( <div style={{ display: 'flex', gap: 'var(--space-xs)', justifyContent: 'flex-end' }}>
<Button size="sm" variant="secondary" onClick={() => onAck(row.id, row.title ?? undefined)}> {row.ackedAt == null && (
Acknowledge <Button
</Button> size="sm"
) : null, variant="secondary"
onClick={() => onAck(row.id, row.title ?? undefined)}
disabled={ack.isPending}
>
Ack
</Button>
)}
{row.readAt == null && (
<Button
size="sm"
variant="ghost"
onClick={() => onMarkRead(row.id)}
disabled={markRead.isPending}
>
Mark read
</Button>
)}
{row.ruleId && (
<SilenceRuleMenu
ruleId={row.ruleId}
ruleTitle={row.title ?? undefined}
variant="row"
/>
)}
{canDelete && (
<Button
size="sm"
variant="ghost"
onClick={() => onDeleteOne(row.id)}
disabled={del.isPending}
aria-label="Delete alert"
>
<Trash2 size={14} />
</Button>
)}
</div>
),
}, },
]; ];
// ── render ─────────────────────────────────────────────────────────────────
if (isLoading) return <PageLoader />; if (isLoading) return <PageLoader />;
if (error) return <div className={css.page}>Failed to load alerts: {String(error)}</div>; if (error) return <div className={css.page}>Failed to load alerts: {String(error)}</div>;
const selectedIds = Array.from(selected); 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 = const subtitle =
selectedIds.length > 0 selectedIds.length > 0
? `${selectedIds.length} selected` ? `${selectedIds.length} selected`
: `${unreadIds.length} need attention · ${rows.length} total in inbox`; : `${needsAttention} need attention · ${rows.length} total`;
return ( return (
<div className={css.page}> <div className={css.page}>
{/* ── Header ─────────────────────────────────────────────────────── */}
<header className={css.pageHeader}> <header className={css.pageHeader}>
<div className={css.pageTitleGroup}> <div className={css.pageTitleGroup}>
<h2 className={css.pageTitle}>Inbox</h2> <h2 className={css.pageTitle}>Inbox</h2>
@@ -185,9 +367,25 @@ export default function InboxPage() {
value={severitySel} value={severitySel}
onChange={setSeveritySel} onChange={setSeveritySel}
/> />
<ButtonGroup
items={STATE_ITEMS}
value={stateSel}
onChange={setStateSel}
/>
<Toggle
label="Hide acked"
checked={hideAcked}
onChange={(e) => setHideAcked(e.currentTarget.checked)}
/>
<Toggle
label="Hide read"
checked={hideRead}
onChange={(e) => setHideRead(e.currentTarget.checked)}
/>
</div> </div>
</header> </header>
{/* ── Filter / bulk toolbar ───────────────────────────────────────── */}
<div className={css.filterBar}> <div className={css.filterBar}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}> <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
<input <input
@@ -200,42 +398,54 @@ export default function InboxPage() {
{allSelected ? 'Deselect all' : `Select all${rows.length ? ` (${rows.length})` : ''}`} {allSelected ? 'Deselect all' : `Select all${rows.length ? ` (${rows.length})` : ''}`}
</label> </label>
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />
{selectedIds.length > 0 ? ( {selectedIds.length > 0 ? (
/* ── Bulk actions ─────────────────────────────────────────────── */
<> <>
<Button <Button
variant="primary" variant="primary"
size="sm" size="sm"
onClick={() => onBulkAck(selectedFiringIds)} onClick={() => onBulkAck(unackedSel)}
disabled={selectedFiringIds.length === 0 || ack.isPending} disabled={unackedSel.length === 0 || bulkAck.isPending}
> >
{selectedFiringIds.length > 0 Acknowledge {unackedSel.length}
? `Acknowledge ${selectedFiringIds.length}`
: 'Acknowledge selected'}
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={() => onBulkRead(selectedIds)} onClick={() => onBulkRead(unreadSel)}
disabled={bulkRead.isPending} disabled={unreadSel.length === 0 || bulkRead.isPending}
> >
Mark {selectedIds.length} read Mark {unreadSel.length} read
</Button> </Button>
<SilenceRulesForSelection selected={selected} rows={rows} />
{canDelete && (
<Button
variant="danger"
size="sm"
onClick={() => setDeletePending(selectedIds)}
disabled={bulkDelete.isPending}
>
Delete {selectedIds.length}
</Button>
)}
</> </>
) : ( ) : (
/* ── Global actions (no selection) ───────────────────────────── */
<> <>
<Button <Button
variant="primary" variant="primary"
size="sm" size="sm"
onClick={() => onBulkAck(firingIds)} onClick={() => onBulkAck(firingUnackedIds)}
disabled={firingIds.length === 0 || ack.isPending} disabled={firingUnackedIds.length === 0 || bulkAck.isPending}
> >
Acknowledge all firing Acknowledge all firing
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={() => onBulkRead(unreadIds)} onClick={() => onBulkRead(allUnreadIds)}
disabled={unreadIds.length === 0 || bulkRead.isPending} disabled={allUnreadIds.length === 0 || bulkRead.isPending}
> >
Mark all read Mark all read
</Button> </Button>
@@ -243,11 +453,12 @@ export default function InboxPage() {
)} )}
</div> </div>
{/* ── Table / empty ───────────────────────────────────────────────── */}
{rows.length === 0 ? ( {rows.length === 0 ? (
<EmptyState <EmptyState
icon={<Inbox size={32} />} icon={<Inbox size={32} />}
title="All clear" title="All clear"
description="No open alerts for you in this environment." description="No alerts match the current filters."
/> />
) : ( ) : (
<div className={`${tableStyles.tableSection} ${css.tableWrap}`}> <div className={`${tableStyles.tableSection} ${css.tableWrap}`}>
@@ -263,6 +474,24 @@ export default function InboxPage() {
/> />
</div> </div>
)} )}
{/* ── Bulk delete confirmation ─────────────────────────────────────── */}
<ConfirmDialog
open={deletePending != null}
onClose={() => 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}
/>
</div> </div>
); );
} }