fix(ui/alerts): bell spacing, rule editor width, inbox bulk controls

Round 4 smoke feedback on /alerts:
- Bell now has consistent 12px gap from env selector and user name
  (wrap env + bell in flex container inside TopBar's environment prop)
- RuleEditorWizard constrained to max-width 840px (centered) and
  upgraded the page title from SectionHeader to h2 pattern used by
  the list pages
- Inbox: added select-all checkbox, severity SegmentedTabs filter
  (All / Critical / Warning / Info), and bulk-ack actions
  (Acknowledge selected + Acknowledge all firing) alongside the
  existing mark-read actions
This commit is contained in:
hsiegeln
2026-04-21 12:10:20 +02:00
parent c443fc606a
commit 468132d1dd
4 changed files with 111 additions and 22 deletions

View File

@@ -957,14 +957,14 @@ function LayoutContent() {
<TopBar <TopBar
breadcrumb={breadcrumb} breadcrumb={breadcrumb}
environment={ environment={
<> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<EnvironmentSelector <EnvironmentSelector
environments={environments} environments={environments}
value={selectedEnv} value={selectedEnv}
onChange={setSelectedEnv} onChange={setSelectedEnv}
/> />
<NotificationBell /> <NotificationBell />
</> </div>
} }
user={username ? { name: username } : undefined} user={username ? { name: username } : undefined}
userMenuItems={userMenuItems} userMenuItems={userMenuItems}

View File

@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { Inbox } from 'lucide-react'; import { Inbox } from 'lucide-react';
import { import {
Button, DataTable, EmptyState, useToast, Button, DataTable, EmptyState, SegmentedTabs, useToast,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader'; import { PageLoader } from '../../components/PageLoader';
@@ -18,8 +18,24 @@ 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']>;
const SEVERITY_FILTERS: Record<string, { label: string; values: Severity[] | undefined }> = {
all: { label: 'All severities', values: undefined },
critical: { label: 'Critical', values: ['CRITICAL'] },
warning: { label: 'Warning', values: ['WARNING'] },
info: { label: 'Info', values: ['INFO'] },
};
export default function InboxPage() { export default function InboxPage() {
const { data, isLoading, error } = useAlerts({ state: ['FIRING', 'ACKNOWLEDGED'], limit: 200 }); const [severityKey, setSeverityKey] = useState<string>('all');
const severityFilter = SEVERITY_FILTERS[severityKey];
const { data, isLoading, error } = useAlerts({
state: ['FIRING', 'ACKNOWLEDGED'],
severity: severityFilter.values,
limit: 200,
});
const bulkRead = useBulkReadAlerts(); const bulkRead = useBulkReadAlerts();
const markRead = useMarkAlertRead(); const markRead = useMarkAlertRead();
const ack = useAckAlert(); const ack = useAckAlert();
@@ -33,6 +49,11 @@ export default function InboxPage() {
[rows], [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 toggleSelected = (id: string) => { const toggleSelected = (id: string) => {
setSelected((prev) => { setSelected((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -41,6 +62,14 @@ export default function InboxPage() {
}); });
}; };
const toggleSelectAll = () => {
if (allSelected) {
setSelected(new Set());
} else {
setSelected(new Set(rows.map((r) => r.id)));
}
};
const onAck = async (id: string, title?: string) => { const onAck = async (id: string, title?: string) => {
try { try {
await ack.mutateAsync(id); await ack.mutateAsync(id);
@@ -50,6 +79,17 @@ export default function InboxPage() {
} }
}; };
const onBulkAck = async (ids: string[]) => {
if (ids.length === 0) return;
try {
await Promise.all(ids.map((id) => ack.mutateAsync(id)));
setSelected(new Set());
toast({ title: `Acknowledged ${ids.length} alert${ids.length === 1 ? '' : 's'}`, variant: 'success' });
} catch (e) {
toast({ title: 'Bulk ack failed', description: String(e), variant: 'error' });
}
};
const onBulkRead = async (ids: string[]) => { const onBulkRead = async (ids: string[]) => {
if (ids.length === 0) return; if (ids.length === 0) return;
try { try {
@@ -122,6 +162,9 @@ export default function InboxPage() {
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 subtitle = const subtitle =
selectedIds.length > 0 selectedIds.length > 0
@@ -136,25 +179,60 @@ export default function InboxPage() {
<span className={css.pageSubtitle}>{subtitle}</span> <span className={css.pageSubtitle}>{subtitle}</span>
</div> </div>
<div className={css.pageActions}> <div className={css.pageActions}>
<Button <SegmentedTabs
variant="secondary" tabs={Object.entries(SEVERITY_FILTERS).map(([value, f]) => ({ value, label: f.label }))}
size="sm" active={severityKey}
onClick={() => onBulkRead(selectedIds)} onChange={setSeverityKey}
disabled={selectedIds.length === 0 || bulkRead.isPending} />
>
Mark selected read
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onBulkRead(unreadIds)}
disabled={unreadIds.length === 0 || bulkRead.isPending}
>
Mark all read
</Button>
</div> </div>
</header> </header>
<div className={css.filterBar}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
<input
type="checkbox"
checked={allSelected}
ref={(el) => { if (el) el.indeterminate = someSelected; }}
onChange={toggleSelectAll}
aria-label={allSelected ? 'Deselect all' : 'Select all'}
/>
{allSelected ? 'Deselect all' : `Select all${rows.length ? ` (${rows.length})` : ''}`}
</label>
<span style={{ flex: 1 }} />
<Button
variant="secondary"
size="sm"
onClick={() => onBulkAck(selectedFiringIds)}
disabled={selectedFiringIds.length === 0 || ack.isPending}
>
Acknowledge selected
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onBulkAck(firingIds)}
disabled={firingIds.length === 0 || ack.isPending}
>
Acknowledge all firing
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onBulkRead(selectedIds)}
disabled={selectedIds.length === 0 || bulkRead.isPending}
>
Mark selected read
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onBulkRead(unreadIds)}
disabled={unreadIds.length === 0 || bulkRead.isPending}
>
Mark all read
</Button>
</div>
{rows.length === 0 ? ( {rows.length === 0 ? (
<EmptyState <EmptyState
icon={<Inbox size={32} />} icon={<Inbox size={32} />}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router'; import { useNavigate, useParams, useSearchParams } from 'react-router';
import { Alert, Button, SectionHeader, useToast } from '@cameleer/design-system'; import { Alert, Button, useToast } from '@cameleer/design-system';
import { PageLoader } from '../../../components/PageLoader'; import { PageLoader } from '../../../components/PageLoader';
import { import {
useAlertRule, useAlertRule,
@@ -148,7 +148,7 @@ export default function RuleEditorWizard() {
return ( return (
<div className={css.wizard}> <div className={css.wizard}>
<div className={css.header}> <div className={css.header}>
<SectionHeader>{isEdit ? `Edit rule: ${form.name}` : 'New alert rule'}</SectionHeader> <h2 className={css.pageTitle}>{isEdit ? `Edit rule: ${form.name}` : 'New alert rule'}</h2>
</div> </div>
{promoteFrom && ( {promoteFrom && (

View File

@@ -3,6 +3,17 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-md); gap: var(--space-md);
max-width: 840px;
margin: 0 auto;
width: 100%;
}
.pageTitle {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
letter-spacing: -0.01em;
} }
.header { .header {