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:
@@ -1,5 +1,11 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router';
|
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 { PageLoader } from '../../components/PageLoader';
|
||||||
import { SeverityBadge } from '../../components/SeverityBadge';
|
import { SeverityBadge } from '../../components/SeverityBadge';
|
||||||
import {
|
import {
|
||||||
@@ -10,7 +16,8 @@ import {
|
|||||||
} from '../../api/queries/alertRules';
|
} from '../../api/queries/alertRules';
|
||||||
import { useEnvironments } from '../../api/queries/admin/environments';
|
import { useEnvironments } from '../../api/queries/admin/environments';
|
||||||
import { useSelectedEnv } from '../../api/queries/alertMeta';
|
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() {
|
export default function RulesListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -21,28 +28,32 @@ export default function RulesListPage() {
|
|||||||
const deleteRule = useDeleteAlertRule();
|
const deleteRule = useDeleteAlertRule();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<AlertRuleResponse | null>(null);
|
||||||
|
|
||||||
if (isLoading) return <PageLoader />;
|
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 rows = rules ?? [];
|
||||||
const otherEnvs = (envs ?? []).filter((e) => e.slug !== env);
|
const otherEnvs = (envs ?? []).filter((e) => e.slug !== env);
|
||||||
|
|
||||||
const onToggle = async (r: AlertRuleResponse) => {
|
const onToggle = async (r: AlertRuleResponse) => {
|
||||||
try {
|
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' });
|
toast({ title: r.enabled ? 'Disabled' : 'Enabled', description: r.name, variant: 'success' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({ title: 'Toggle failed', description: String(e), variant: 'error' });
|
toast({ title: 'Toggle failed', description: String(e), variant: 'error' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDelete = async (r: AlertRuleResponse) => {
|
const confirmDelete = async () => {
|
||||||
if (!confirm(`Delete rule "${r.name}"? Fired alerts are preserved via rule_snapshot.`)) return;
|
if (!pendingDelete) return;
|
||||||
try {
|
try {
|
||||||
await deleteRule.mutateAsync(r.id);
|
await deleteRule.mutateAsync(pendingDelete.id!);
|
||||||
toast({ title: 'Deleted', description: r.name, variant: 'success' });
|
toast({ title: 'Deleted', description: pendingDelete.name, variant: 'success' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({ title: 'Delete failed', description: String(e), variant: 'error' });
|
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}`);
|
navigate(`/alerts/rules/new?promoteFrom=${env}&ruleId=${r.id}&targetEnv=${targetEnvSlug}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const columns: Column<AlertRuleResponse & { id: string }>[] = [
|
||||||
<div style={{ padding: 16 }}>
|
{
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
key: 'name', header: 'Name',
|
||||||
<SectionHeader>Alert rules</SectionHeader>
|
render: (_, r) => <Link to={`/alerts/rules/${r.id}`}>{r.name}</Link>,
|
||||||
<Link to="/alerts/rules/new">
|
},
|
||||||
<Button variant="primary">New rule</Button>
|
{
|
||||||
</Link>
|
key: 'conditionKind', header: 'Kind', width: '160px',
|
||||||
</div>
|
render: (_, r) => <Badge label={r.conditionKind ?? ''} color="auto" variant="outlined" />,
|
||||||
<div className={sectionStyles.section}>
|
},
|
||||||
{rows.length === 0 ? (
|
{
|
||||||
<p>No rules yet. Create one to start evaluating alerts for this environment.</p>
|
key: 'severity', header: 'Severity', width: '110px',
|
||||||
) : (
|
render: (_, r) => <SeverityBadge severity={r.severity!} />,
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
},
|
||||||
<thead>
|
{
|
||||||
<tr>
|
key: 'enabled', header: 'Enabled', width: '90px',
|
||||||
<th style={{ textAlign: 'left' }}>Name</th>
|
render: (_, r) => (
|
||||||
<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
|
<Toggle
|
||||||
checked={r.enabled}
|
checked={!!r.enabled}
|
||||||
onChange={() => onToggle(r)}
|
onChange={() => onToggle(r)}
|
||||||
disabled={setEnabled.isPending}
|
disabled={setEnabled.isPending}
|
||||||
/>
|
/>
|
||||||
</td>
|
),
|
||||||
<td>{r.targets.length}</td>
|
},
|
||||||
<td style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
{
|
||||||
|
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 && (
|
{otherEnvs.length > 0 && (
|
||||||
<select
|
<Dropdown
|
||||||
value=""
|
trigger={<Button variant="ghost" size="sm">Promote to ▾</Button>}
|
||||||
onChange={(e) => { if (e.target.value) onPromote(r, e.target.value); }}
|
items={otherEnvs.map((e) => ({
|
||||||
aria-label={`Promote ${r.name} to another env`}
|
label: e.slug,
|
||||||
>
|
onClick: () => onPromote(r, e.slug),
|
||||||
<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}>
|
<Button variant="ghost" size="sm" onClick={() => setPendingDelete(r)} disabled={deleteRule.isPending}>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user