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:
@@ -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}>
|
||||||
<SectionHeader>Alert silences</SectionHeader>
|
<div className={css.toolbar}>
|
||||||
<div className={sectionStyles.section} style={{ marginTop: 12 }}>
|
<SectionHeader>Alert silences</SectionHeader>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr) auto', gap: 8, alignItems: 'end' }}>
|
</div>
|
||||||
<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} />}
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
title="No silences"
|
||||||
<thead>
|
description="Nothing is currently silenced in this environment."
|
||||||
<tr>
|
/>
|
||||||
<th style={{ textAlign: 'left' }}>Matcher</th>
|
) : (
|
||||||
<th style={{ textAlign: 'left' }}>Reason</th>
|
<div className={tableStyles.tableSection}>
|
||||||
<th style={{ textAlign: 'left' }}>Starts</th>
|
<DataTable<AlertSilenceResponse & { id: string }>
|
||||||
<th style={{ textAlign: 'left' }}>Ends</th>
|
columns={columns}
|
||||||
<th></th>
|
data={rows.map((s) => ({ ...s, id: s.id ?? '' }))}
|
||||||
</tr>
|
flush
|
||||||
</thead>
|
/>
|
||||||
<tbody>
|
</div>
|
||||||
{rows.map((s) => (
|
)}
|
||||||
<tr key={s.id}>
|
|
||||||
<td><code>{JSON.stringify(s.matcher)}</code></td>
|
<ConfirmDialog
|
||||||
<td>{s.reason ?? '—'}</td>
|
open={!!pendingEnd}
|
||||||
<td>{s.startsAt}</td>
|
onClose={() => setPendingEnd(null)}
|
||||||
<td>{s.endsAt}</td>
|
onConfirm={confirmEnd}
|
||||||
<td>
|
title="End silence?"
|
||||||
<Button variant="secondary" size="sm" onClick={() => onRemove(s)}>
|
message="End this silence early? Affected rules will resume firing."
|
||||||
End
|
confirmText="End silence"
|
||||||
</Button>
|
variant="warning"
|
||||||
</td>
|
loading={remove.isPending}
|
||||||
</tr>
|
/>
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user