Files
cameleer-server/ui/src/pages/Alerts/SilencesPage.tsx
hsiegeln b7d201d743
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m5s
CI / docker (push) Successful in 1m19s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 37s
fix(alerts): add AGENT_LIFECYCLE to condition_kind_enum + readable error toasts
Backend
 - V18 migration adds AGENT_LIFECYCLE to condition_kind_enum. Java
   ConditionKind enum shipped with this value but no Postgres migration
   extended the type, so any AGENT_LIFECYCLE rule insert failed with
   "invalid input value for enum condition_kind_enum".
 - ALTER TYPE ... ADD VALUE lives alone in its migration per Postgres
   constraint that the new value cannot be referenced in the same tx.
 - V18MigrationIT asserts the enum now contains all 7 kinds.

Frontend
 - Add describeApiError(e) helper to unwrap openapi-fetch error bodies
   (Spring error JSON) into readable strings. String(e) on a plain
   object rendered "[object Object]" in toasts — the actual failure
   reason was hidden from the user.
 - Replace String(e) in all 13 toast descriptions across the alerting
   and outbound-connection mutation paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:23:14 +02:00

180 lines
5.9 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router';
import { BellOff } from 'lucide-react';
import {
Button, FormField, Input, useToast, DataTable,
EmptyState, ConfirmDialog, MonoText,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader';
import {
useAlertSilences,
useCreateSilence,
useDeleteSilence,
type AlertSilenceResponse,
} from '../../api/queries/alertSilences';
import { describeApiError } from '../../api/errors';
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() {
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);
const [pendingEnd, setPendingEnd] = useState<AlertSilenceResponse | null>(null);
const [searchParams] = useSearchParams();
useEffect(() => {
const r = searchParams.get('ruleId');
if (r) setMatcherRuleId(r);
}, [searchParams]);
if (isLoading) return <PageLoader />;
if (error) return <div className={css.page}>Failed to load silences: {String(error)}</div>;
const rows = data ?? [];
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: describeApiError(e), variant: 'error' });
}
};
const confirmEnd = async () => {
if (!pendingEnd) return;
try {
await remove.mutateAsync(pendingEnd.id!);
toast({ title: 'Silence removed', variant: 'success' });
} catch (e) {
toast({ title: 'Remove failed', description: describeApiError(e), variant: 'error' });
} finally {
setPendingEnd(null);
}
};
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 early
</Button>
),
},
];
return (
<div className={css.page}>
<header className={css.pageHeader}>
<div className={css.pageTitleGroup}>
<h2 className={css.pageTitle}>Alert silences</h2>
<span className={css.pageSubtitle}>
{rows.length === 0
? 'Nothing silenced right now'
: `${rows.length} active silence${rows.length === 1 ? '' : 's'}`}
</span>
</div>
</header>
<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)} />
</FormField>
<FormField label="App slug" hint="App slug (optional)">
<Input value={matcherAppSlug} onChange={(e) => setMatcherAppSlug(e.target.value)} />
</FormField>
<FormField label="Duration" hint="Hours">
<Input
type="number"
min={1}
value={hours}
onChange={(e) => setHours(Number(e.target.value))}
/>
</FormField>
<FormField label="Reason" hint="Context for operators">
<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>
</section>
{rows.length === 0 ? (
<EmptyState
icon={<BellOff size={32} />}
title="No silences"
description="Nothing is currently silenced in this environment."
/>
) : (
<div className={`${tableStyles.tableSection} ${css.tableWrap}`}>
<DataTable<AlertSilenceResponse & { id: string }>
columns={columns}
data={rows.map((s) => ({ ...s, id: s.id ?? '' }))}
flush
fillHeight
/>
</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>
);
}