refactor(alerts/ui): rewrite Rules list with DataTable + Dropdown + ConfirmDialog

Replaces raw <table> with DataTable, raw <select> promote control with
DS Dropdown, and native confirm() delete with ConfirmDialog. Adds DS
EmptyState with CTA for the no-rules case. Uses SectionHeader's
action slot instead of ad-hoc flex wrapper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-21 10:12:25 +02:00
parent 436a0e4d4c
commit 23f3c3990c

View File

@@ -1,5 +1,11 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { Button, SectionHeader, Toggle, useToast, Badge } from '@cameleer/design-system';
import { FilePlus } from 'lucide-react';
import {
Button, SectionHeader, Toggle, useToast, Badge, DataTable,
EmptyState, Dropdown, ConfirmDialog,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader';
import { SeverityBadge } from '../../components/SeverityBadge';
import {
@@ -10,7 +16,8 @@ import {
} from '../../api/queries/alertRules';
import { useEnvironments } from '../../api/queries/admin/environments';
import { useSelectedEnv } from '../../api/queries/alertMeta';
import sectionStyles from '../../styles/section-card.module.css';
import tableStyles from '../../styles/table-section.module.css';
import css from './alerts-page.module.css';
export default function RulesListPage() {
const navigate = useNavigate();
@@ -21,28 +28,32 @@ export default function RulesListPage() {
const deleteRule = useDeleteAlertRule();
const { toast } = useToast();
const [pendingDelete, setPendingDelete] = useState<AlertRuleResponse | null>(null);
if (isLoading) return <PageLoader />;
if (error) return <div>Failed to load rules: {String(error)}</div>;
if (error) return <div className={css.page}>Failed to load rules: {String(error)}</div>;
const rows = rules ?? [];
const otherEnvs = (envs ?? []).filter((e) => e.slug !== env);
const onToggle = async (r: AlertRuleResponse) => {
try {
await setEnabled.mutateAsync({ id: r.id, enabled: !r.enabled });
await setEnabled.mutateAsync({ id: r.id!, enabled: !r.enabled });
toast({ title: r.enabled ? 'Disabled' : 'Enabled', description: r.name, variant: 'success' });
} catch (e) {
toast({ title: 'Toggle failed', description: String(e), variant: 'error' });
}
};
const onDelete = async (r: AlertRuleResponse) => {
if (!confirm(`Delete rule "${r.name}"? Fired alerts are preserved via rule_snapshot.`)) return;
const confirmDelete = async () => {
if (!pendingDelete) return;
try {
await deleteRule.mutateAsync(r.id);
toast({ title: 'Deleted', description: r.name, variant: 'success' });
await deleteRule.mutateAsync(pendingDelete.id!);
toast({ title: 'Deleted', description: pendingDelete.name, variant: 'success' });
} catch (e) {
toast({ title: 'Delete failed', description: String(e), variant: 'error' });
} finally {
setPendingDelete(null);
}
};
@@ -50,66 +61,103 @@ export default function RulesListPage() {
navigate(`/alerts/rules/new?promoteFrom=${env}&ruleId=${r.id}&targetEnv=${targetEnvSlug}`);
};
const columns: Column<AlertRuleResponse & { id: string }>[] = [
{
key: 'name', header: 'Name',
render: (_, r) => <Link to={`/alerts/rules/${r.id}`}>{r.name}</Link>,
},
{
key: 'conditionKind', header: 'Kind', width: '160px',
render: (_, r) => <Badge label={r.conditionKind ?? ''} color="auto" variant="outlined" />,
},
{
key: 'severity', header: 'Severity', width: '110px',
render: (_, r) => <SeverityBadge severity={r.severity!} />,
},
{
key: 'enabled', header: 'Enabled', width: '90px',
render: (_, r) => (
<Toggle
checked={!!r.enabled}
onChange={() => onToggle(r)}
disabled={setEnabled.isPending}
/>
),
},
{
key: 'targets', header: 'Targets', width: '90px',
render: (_, r) => String(r.targets?.length ?? 0),
},
{
key: 'actions', header: '', width: '220px',
render: (_, r) => (
<div style={{ display: 'flex', gap: 'var(--space-sm)', justifyContent: 'flex-end' }}>
{otherEnvs.length > 0 && (
<Dropdown
trigger={<Button variant="ghost" size="sm">Promote to </Button>}
items={otherEnvs.map((e) => ({
label: e.slug,
onClick: () => onPromote(r, e.slug),
}))}
/>
)}
<Button variant="ghost" size="sm" onClick={() => setPendingDelete(r)} disabled={deleteRule.isPending}>
Delete
</Button>
</div>
),
},
];
return (
<div style={{ padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<SectionHeader>Alert rules</SectionHeader>
<Link to="/alerts/rules/new">
<Button variant="primary">New rule</Button>
</Link>
</div>
<div className={sectionStyles.section}>
{rows.length === 0 ? (
<p>No rules yet. Create one to start evaluating alerts for this environment.</p>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ textAlign: 'left' }}>Name</th>
<th style={{ textAlign: 'left' }}>Kind</th>
<th style={{ textAlign: 'left' }}>Severity</th>
<th style={{ textAlign: 'left' }}>Enabled</th>
<th style={{ textAlign: 'left' }}>Targets</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td><Link to={`/alerts/rules/${r.id}`}>{r.name}</Link></td>
<td><Badge label={r.conditionKind} color="auto" variant="outlined" /></td>
<td><SeverityBadge severity={r.severity} /></td>
<td>
<Toggle
checked={r.enabled}
onChange={() => onToggle(r)}
disabled={setEnabled.isPending}
/>
</td>
<td>{r.targets.length}</td>
<td style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
{otherEnvs.length > 0 && (
<select
value=""
onChange={(e) => { if (e.target.value) onPromote(r, e.target.value); }}
aria-label={`Promote ${r.name} to another env`}
>
<option value="">Promote to </option>
{otherEnvs.map((e) => (
<option key={e.slug} value={e.slug}>{e.slug}</option>
))}
</select>
)}
<Button variant="secondary" onClick={() => onDelete(r)} disabled={deleteRule.isPending}>
Delete
</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
<div className={css.page}>
<div className={css.toolbar}>
<SectionHeader
action={
<Link to="/alerts/rules/new">
<Button variant="primary">New rule</Button>
</Link>
}
>
Alert rules
</SectionHeader>
</div>
{rows.length === 0 ? (
<EmptyState
icon={<FilePlus size={32} />}
title="No alert rules"
description="Create one to start evaluating alerts for this environment."
action={
<Link to="/alerts/rules/new">
<Button variant="primary">Create rule</Button>
</Link>
}
/>
) : (
<div className={tableStyles.tableSection}>
<DataTable<AlertRuleResponse & { id: string }>
columns={columns}
data={rows as (AlertRuleResponse & { id: string })[]}
flush
/>
</div>
)}
<ConfirmDialog
open={!!pendingDelete}
onClose={() => setPendingDelete(null)}
onConfirm={confirmDelete}
title="Delete alert rule?"
message={
pendingDelete
? `Delete rule "${pendingDelete.name}"? Fired alerts are preserved via rule_snapshot.`
: ''
}
confirmText="Delete"
variant="danger"
loading={deleteRule.isPending}
/>
</div>
);
}