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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user