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 { 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<AlertDto['severity']>;
type AlertSeverity = NonNullable<AlertDto['severity']>;
type AlertState = NonNullable<AlertDto['state']>;
// ── 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<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() {
// Filter state — defaults: FIRING selected, hide-acked on, hide-read on
const [severitySel, setSeveritySel] = useState<Set<string>>(new Set());
const severityValues: Severity[] | undefined = severitySel.size === 0
? undefined
: [...severitySel] as Severity[];
const [stateSel, setStateSel] = useState<Set<string>>(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<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 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 = (
<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[]) => {
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<AlertDto>[] = [
{
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 (
<div className={`${css.titleCell} ${unread ? css.titleCellUnread : ''}`}>
<Link to={`/alerts/inbox/${row.id}`} onClick={() => markRead.mutate(row.id)}>
@@ -149,31 +294,68 @@ export default function InboxPage() {
) : '—',
},
{
key: 'ack', header: '', width: '120px',
render: (_, row) =>
row.state === 'FIRING' ? (
<Button size="sm" variant="secondary" onClick={() => onAck(row.id, row.title ?? undefined)}>
Acknowledge
</Button>
) : null,
key: 'rowActions', header: '', width: '220px',
render: (_, row) => (
<div style={{ display: 'flex', gap: 'var(--space-xs)', justifyContent: 'flex-end' }}>
{row.ackedAt == null && (
<Button
size="sm"
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 (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 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 (
<div className={css.page}>
{/* ── Header ─────────────────────────────────────────────────────── */}
<header className={css.pageHeader}>
<div className={css.pageTitleGroup}>
<h2 className={css.pageTitle}>Inbox</h2>
@@ -185,9 +367,25 @@ export default function InboxPage() {
value={severitySel}
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>
</header>
{/* ── Filter / bulk toolbar ───────────────────────────────────────── */}
<div className={css.filterBar}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
<input
@@ -200,42 +398,54 @@ export default function InboxPage() {
{allSelected ? 'Deselect all' : `Select all${rows.length ? ` (${rows.length})` : ''}`}
</label>
<span style={{ flex: 1 }} />
{selectedIds.length > 0 ? (
/* ── Bulk actions ─────────────────────────────────────────────── */
<>
<Button
variant="primary"
size="sm"
onClick={() => onBulkAck(selectedFiringIds)}
disabled={selectedFiringIds.length === 0 || ack.isPending}
onClick={() => onBulkAck(unackedSel)}
disabled={unackedSel.length === 0 || bulkAck.isPending}
>
{selectedFiringIds.length > 0
? `Acknowledge ${selectedFiringIds.length}`
: 'Acknowledge selected'}
Acknowledge {unackedSel.length}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onBulkRead(selectedIds)}
disabled={bulkRead.isPending}
onClick={() => onBulkRead(unreadSel)}
disabled={unreadSel.length === 0 || bulkRead.isPending}
>
Mark {selectedIds.length} read
Mark {unreadSel.length} read
</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
variant="primary"
size="sm"
onClick={() => onBulkAck(firingIds)}
disabled={firingIds.length === 0 || ack.isPending}
onClick={() => onBulkAck(firingUnackedIds)}
disabled={firingUnackedIds.length === 0 || bulkAck.isPending}
>
Acknowledge all firing
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onBulkRead(unreadIds)}
disabled={unreadIds.length === 0 || bulkRead.isPending}
onClick={() => onBulkRead(allUnreadIds)}
disabled={allUnreadIds.length === 0 || bulkRead.isPending}
>
Mark all read
</Button>
@@ -243,11 +453,12 @@ export default function InboxPage() {
)}
</div>
{/* ── Table / empty ───────────────────────────────────────────────── */}
{rows.length === 0 ? (
<EmptyState
icon={<Inbox size={32} />}
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}`}>
@@ -263,6 +474,24 @@ export default function InboxPage() {
/>
</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>
);
}