feat(ui/alerts): SilencesPage with matcher-based create + end-early action
Matcher accepts ruleId and/or appSlug. Server enforces endsAt > startsAt (V12 CHECK constraint) and matcher_matches() at dispatch time (spec §7).
This commit is contained in:
@@ -1,3 +1,130 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, FormField, Input, SectionHeader, useToast } from '@cameleer/design-system';
|
||||||
|
import { PageLoader } from '../../components/PageLoader';
|
||||||
|
import {
|
||||||
|
useAlertSilences,
|
||||||
|
useCreateSilence,
|
||||||
|
useDeleteSilence,
|
||||||
|
type AlertSilenceResponse,
|
||||||
|
} from '../../api/queries/alertSilences';
|
||||||
|
import sectionStyles from '../../styles/section-card.module.css';
|
||||||
|
|
||||||
export default function SilencesPage() {
|
export default function SilencesPage() {
|
||||||
return <div>SilencesPage — coming soon</div>;
|
const { data, isLoading, error } = useAlertSilences();
|
||||||
|
const create = useCreateSilence();
|
||||||
|
const remove = useDeleteSilence();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
const [matcherRuleId, setMatcherRuleId] = useState('');
|
||||||
|
const [matcherAppSlug, setMatcherAppSlug] = useState('');
|
||||||
|
const [hours, setHours] = useState(1);
|
||||||
|
|
||||||
|
if (isLoading) return <PageLoader />;
|
||||||
|
if (error) return <div>Failed to load silences: {String(error)}</div>;
|
||||||
|
|
||||||
|
const onCreate = async () => {
|
||||||
|
const now = new Date();
|
||||||
|
const endsAt = new Date(now.getTime() + hours * 3600_000);
|
||||||
|
const matcher: Record<string, string> = {};
|
||||||
|
if (matcherRuleId) matcher.ruleId = matcherRuleId;
|
||||||
|
if (matcherAppSlug) matcher.appSlug = matcherAppSlug;
|
||||||
|
if (Object.keys(matcher).length === 0) {
|
||||||
|
toast({ title: 'Silence needs at least one matcher field', variant: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await create.mutateAsync({
|
||||||
|
matcher,
|
||||||
|
reason: reason || undefined,
|
||||||
|
startsAt: now.toISOString(),
|
||||||
|
endsAt: endsAt.toISOString(),
|
||||||
|
});
|
||||||
|
setReason('');
|
||||||
|
setMatcherRuleId('');
|
||||||
|
setMatcherAppSlug('');
|
||||||
|
setHours(1);
|
||||||
|
toast({ title: 'Silence created', variant: 'success' });
|
||||||
|
} catch (e) {
|
||||||
|
toast({ title: 'Create failed', description: String(e), variant: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRemove = async (s: AlertSilenceResponse) => {
|
||||||
|
if (!confirm(`End silence early?`)) return;
|
||||||
|
try {
|
||||||
|
await remove.mutateAsync(s.id!);
|
||||||
|
toast({ title: 'Silence removed', variant: 'success' });
|
||||||
|
} catch (e) {
|
||||||
|
toast({ title: 'Remove failed', description: String(e), variant: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<SectionHeader>Alert silences</SectionHeader>
|
||||||
|
<div className={sectionStyles.section} style={{ marginTop: 12 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr) auto', gap: 8, alignItems: 'end' }}>
|
||||||
|
<FormField label="Rule ID (optional)">
|
||||||
|
<Input value={matcherRuleId} onChange={(e) => setMatcherRuleId(e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="App slug (optional)">
|
||||||
|
<Input value={matcherAppSlug} onChange={(e) => setMatcherAppSlug(e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Duration (hours)">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={hours}
|
||||||
|
onChange={(e) => setHours(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Reason">
|
||||||
|
<Input
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder="Maintenance window"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<Button variant="primary" size="sm" onClick={onCreate} disabled={create.isPending}>
|
||||||
|
Create silence
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={sectionStyles.section} style={{ marginTop: 16 }}>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p>No active or scheduled silences.</p>
|
||||||
|
) : (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ textAlign: 'left' }}>Matcher</th>
|
||||||
|
<th style={{ textAlign: 'left' }}>Reason</th>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user