refactor(alerts/ui): rewrite Silences with DataTable + FormField + ConfirmDialog

Replaces raw <table> with DataTable, inline-styled form with proper
FormField hints, and native confirm() end-early with ConfirmDialog
(warning variant). Adds DS EmptyState for no-silences case.

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

View File

@@ -1,5 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button, FormField, Input, SectionHeader, useToast } from '@cameleer/design-system'; import { BellOff } from 'lucide-react';
import {
Button, FormField, Input, SectionHeader, useToast, DataTable,
EmptyState, ConfirmDialog, MonoText,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader'; import { PageLoader } from '../../components/PageLoader';
import { import {
useAlertSilences, useAlertSilences,
@@ -8,6 +13,8 @@ import {
type AlertSilenceResponse, type AlertSilenceResponse,
} from '../../api/queries/alertSilences'; } from '../../api/queries/alertSilences';
import sectionStyles from '../../styles/section-card.module.css'; 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 SilencesPage() { export default function SilencesPage() {
const { data, isLoading, error } = useAlertSilences(); const { data, isLoading, error } = useAlertSilences();
@@ -19,9 +26,12 @@ export default function SilencesPage() {
const [matcherRuleId, setMatcherRuleId] = useState(''); const [matcherRuleId, setMatcherRuleId] = useState('');
const [matcherAppSlug, setMatcherAppSlug] = useState(''); const [matcherAppSlug, setMatcherAppSlug] = useState('');
const [hours, setHours] = useState(1); const [hours, setHours] = useState(1);
const [pendingEnd, setPendingEnd] = useState<AlertSilenceResponse | null>(null);
if (isLoading) return <PageLoader />; if (isLoading) return <PageLoader />;
if (error) return <div>Failed to load silences: {String(error)}</div>; if (error) return <div className={css.page}>Failed to load silences: {String(error)}</div>;
const rows = data ?? [];
const onCreate = async () => { const onCreate = async () => {
const now = new Date(); const now = new Date();
@@ -50,30 +60,58 @@ export default function SilencesPage() {
} }
}; };
const onRemove = async (s: AlertSilenceResponse) => { const confirmEnd = async () => {
if (!confirm(`End silence early?`)) return; if (!pendingEnd) return;
try { try {
await remove.mutateAsync(s.id!); await remove.mutateAsync(pendingEnd.id!);
toast({ title: 'Silence removed', variant: 'success' }); toast({ title: 'Silence removed', variant: 'success' });
} catch (e) { } catch (e) {
toast({ title: 'Remove failed', description: String(e), variant: 'error' }); toast({ title: 'Remove failed', description: String(e), variant: 'error' });
} finally {
setPendingEnd(null);
} }
}; };
const rows = data ?? []; const columns: Column<AlertSilenceResponse & { id: string }>[] = [
{
key: 'matcher', header: 'Matcher',
render: (_, s) => <MonoText size="xs">{JSON.stringify(s.matcher)}</MonoText>,
},
{ key: 'reason', header: 'Reason', render: (_, s) => s.reason ?? '—' },
{ key: 'startsAt', header: 'Starts', width: '200px' },
{ key: 'endsAt', header: 'Ends', width: '200px' },
{
key: 'actions', header: '', width: '90px',
render: (_, s) => (
<Button variant="ghost" size="sm" onClick={() => setPendingEnd(s)}>
End
</Button>
),
},
];
return ( return (
<div style={{ padding: 16 }}> <div className={css.page}>
<div className={css.toolbar}>
<SectionHeader>Alert silences</SectionHeader> <SectionHeader>Alert silences</SectionHeader>
<div className={sectionStyles.section} style={{ marginTop: 12 }}> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr) auto', gap: 8, alignItems: 'end' }}>
<FormField label="Rule ID (optional)"> <section className={sectionStyles.section}>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, minmax(0, 1fr)) auto',
gap: 'var(--space-sm)',
alignItems: 'end',
}}
>
<FormField label="Rule ID" hint="Exact rule id (optional)">
<Input value={matcherRuleId} onChange={(e) => setMatcherRuleId(e.target.value)} /> <Input value={matcherRuleId} onChange={(e) => setMatcherRuleId(e.target.value)} />
</FormField> </FormField>
<FormField label="App slug (optional)"> <FormField label="App slug" hint="App slug (optional)">
<Input value={matcherAppSlug} onChange={(e) => setMatcherAppSlug(e.target.value)} /> <Input value={matcherAppSlug} onChange={(e) => setMatcherAppSlug(e.target.value)} />
</FormField> </FormField>
<FormField label="Duration (hours)"> <FormField label="Duration" hint="Hours">
<Input <Input
type="number" type="number"
min={1} min={1}
@@ -81,7 +119,7 @@ export default function SilencesPage() {
onChange={(e) => setHours(Number(e.target.value))} onChange={(e) => setHours(Number(e.target.value))}
/> />
</FormField> </FormField>
<FormField label="Reason"> <FormField label="Reason" hint="Context for operators">
<Input <Input
value={reason} value={reason}
onChange={(e) => setReason(e.target.value)} onChange={(e) => setReason(e.target.value)}
@@ -92,39 +130,34 @@ export default function SilencesPage() {
Create silence Create silence
</Button> </Button>
</div> </div>
</div> </section>
<div className={sectionStyles.section} style={{ marginTop: 16 }}>
{rows.length === 0 ? ( {rows.length === 0 ? (
<p>No active or scheduled silences.</p> <EmptyState
icon={<BellOff size={32} />}
title="No silences"
description="Nothing is currently silenced in this environment."
/>
) : ( ) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}> <div className={tableStyles.tableSection}>
<thead> <DataTable<AlertSilenceResponse & { id: string }>
<tr> columns={columns}
<th style={{ textAlign: 'left' }}>Matcher</th> data={rows.map((s) => ({ ...s, id: s.id ?? '' }))}
<th style={{ textAlign: 'left' }}>Reason</th> flush
<th style={{ textAlign: 'left' }}>Starts</th> />
<th style={{ textAlign: 'left' }}>Ends</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((s) => (
<tr key={s.id}>
<td><code>{JSON.stringify(s.matcher)}</code></td>
<td>{s.reason ?? '—'}</td>
<td>{s.startsAt}</td>
<td>{s.endsAt}</td>
<td>
<Button variant="secondary" size="sm" onClick={() => onRemove(s)}>
End
</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div> </div>
)}
<ConfirmDialog
open={!!pendingEnd}
onClose={() => setPendingEnd(null)}
onConfirm={confirmEnd}
title="End silence?"
message="End this silence early? Affected rules will resume firing."
confirmText="End silence"
variant="warning"
loading={remove.isPending}
/>
</div> </div>
); );
} }