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,24 +1,31 @@
|
|||||||
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)' },
|
||||||
@@ -26,50 +33,151 @@ const SEVERITY_ITEMS: ButtonGroupItem[] = [
|
|||||||
{ 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();
|
// Mutations
|
||||||
const ack = useAckAlert();
|
const ack = useAckAlert();
|
||||||
|
const bulkAck = useBulkAckAlerts();
|
||||||
|
const markRead = useMarkAlertRead();
|
||||||
|
const bulkRead = useBulkReadAlerts();
|
||||||
|
const del = useDeleteAlert();
|
||||||
|
const bulkDelete = useBulkDeleteAlerts();
|
||||||
|
const restore = useRestoreAlert();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Selection
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
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(
|
|
||||||
() => 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 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
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => onAck(row.id, row.title ?? undefined)}
|
||||||
|
disabled={ack.isPending}
|
||||||
|
>
|
||||||
|
Ack
|
||||||
</Button>
|
</Button>
|
||||||
) : null,
|
)}
|
||||||
|
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user