fix(alerts): add AGENT_LIFECYCLE to condition_kind_enum + readable error toasts
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

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>
This commit is contained in:
hsiegeln
2026-04-21 20:23:14 +02:00
parent 181a479037
commit b7d201d743
13 changed files with 81 additions and 17 deletions

24
ui/src/api/errors.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* Turn whatever a fetch/mutation threw into a user-readable string.
*
* openapi-fetch rethrows the parsed response body as-is (a plain object like
* `{ error, message, path, status, timestamp }` for Spring errors), so
* `String(e)` renders "[object Object]" in a toast. This helper prefers an
* Error.message, falls back to a `message` field on plain objects, then
* Spring's `error` field, and finally JSON-stringifies the rest.
*/
export function describeApiError(e: unknown): string {
if (e instanceof Error) return e.message;
if (typeof e === 'string') return e;
if (typeof e === 'object' && e !== null) {
const obj = e as Record<string, unknown>;
if (typeof obj.message === 'string' && obj.message) return obj.message;
if (typeof obj.error === 'string' && obj.error) return obj.error;
try {
return JSON.stringify(e);
} catch {
return String(e);
}
}
return String(e);
}

View File

@@ -16,6 +16,7 @@ import {
type TrustMode,
} from '../../api/queries/admin/outboundConnections';
import { useEnvironments } from '../../api/queries/admin/environments';
import { describeApiError } from '../../api/errors';
import sectionStyles from '../../styles/section-card.module.css';
// ── Form state ──────────────────────────────────────────────────────────
@@ -145,12 +146,12 @@ export default function OutboundConnectionEditor() {
toast({ title: 'Created', description: form.name, variant: 'success' });
navigate('/admin/outbound-connections');
},
onError: (e) => toast({ title: 'Create failed', description: String(e), variant: 'error' }),
onError: (e) => toast({ title: 'Create failed', description: describeApiError(e), variant: 'error' }),
});
} else {
updateMut.mutate(payload, {
onSuccess: () => toast({ title: 'Updated', description: form.name, variant: 'success' }),
onError: (e) => toast({ title: 'Update failed', description: String(e), variant: 'error' }),
onError: (e) => toast({ title: 'Update failed', description: describeApiError(e), variant: 'error' }),
});
}
};

View File

@@ -7,6 +7,7 @@ import {
type OutboundConnectionDto,
type TrustMode,
} from '../../api/queries/admin/outboundConnections';
import { describeApiError } from '../../api/errors';
import sectionStyles from '../../styles/section-card.module.css';
export default function OutboundConnectionsPage() {
@@ -23,7 +24,7 @@ export default function OutboundConnectionsPage() {
if (!confirm(`Delete outbound connection "${c.name}"?`)) return;
deleteMut.mutate(c.id, {
onSuccess: () => toast({ title: 'Deleted', description: c.name, variant: 'success' }),
onError: (e) => toast({ title: 'Delete failed', description: String(e), variant: 'error' }),
onError: (e) => toast({ title: 'Delete failed', description: describeApiError(e), variant: 'error' }),
});
};

View File

@@ -14,6 +14,7 @@ import {
type AlertDto,
} from '../../api/queries/alerts';
import { useCreateSilence } from '../../api/queries/alertSilences';
import { describeApiError } from '../../api/errors';
import { useAuthStore } from '../../auth/auth-store';
import { SilenceRuleMenu } from './SilenceRuleMenu';
import { severityToAccent } from './severity-utils';
@@ -178,7 +179,7 @@ export default function InboxPage() {
await ack.mutateAsync(id);
toast({ title: 'Acknowledged', description: title, variant: 'success' });
} catch (e) {
toast({ title: 'Ack failed', description: String(e), variant: 'error' });
toast({ title: 'Ack failed', description: describeApiError(e), variant: 'error' });
}
};
@@ -187,7 +188,7 @@ export default function InboxPage() {
await markRead.mutateAsync(id);
toast({ title: 'Marked as read', variant: 'success' });
} catch (e) {
toast({ title: 'Mark read failed', description: String(e), variant: 'error' });
toast({ title: 'Mark read failed', description: describeApiError(e), variant: 'error' });
}
};
@@ -210,7 +211,7 @@ export default function InboxPage() {
.mutateAsync(id)
.then(
() => toast({ title: 'Restored', variant: 'success' }),
(e: unknown) => toast({ title: 'Undo failed', description: String(e), variant: 'error' }),
(e: unknown) => toast({ title: 'Undo failed', description: describeApiError(e), variant: 'error' }),
)
}
>
@@ -219,7 +220,7 @@ export default function InboxPage() {
) as unknown as string; // DS description accepts ReactNode at runtime
toast({ title: 'Deleted', description: undoNode, variant: 'success', duration: 5000 });
} catch (e) {
toast({ title: 'Delete failed', description: String(e), variant: 'error' });
toast({ title: 'Delete failed', description: describeApiError(e), variant: 'error' });
}
};
@@ -230,7 +231,7 @@ export default function InboxPage() {
setSelected(new Set());
toast({ title: `Acknowledged ${ids.length} alert${ids.length === 1 ? '' : 's'}`, variant: 'success' });
} catch (e) {
toast({ title: 'Bulk ack failed', description: String(e), variant: 'error' });
toast({ title: 'Bulk ack failed', description: describeApiError(e), variant: 'error' });
}
};
@@ -241,7 +242,7 @@ export default function InboxPage() {
setSelected(new Set());
toast({ title: `Marked ${ids.length} as read`, variant: 'success' });
} catch (e) {
toast({ title: 'Bulk read failed', description: String(e), variant: 'error' });
toast({ title: 'Bulk read failed', description: describeApiError(e), variant: 'error' });
}
};

View File

@@ -5,6 +5,7 @@ import { useUsers, useGroups, useRoles } from '../../../api/queries/admin/rbac';
import { useOutboundConnections } from '../../../api/queries/admin/outboundConnections';
import { useSelectedEnv } from '../../../api/queries/alertMeta';
import { useRenderPreview } from '../../../api/queries/alertRules';
import { describeApiError } from '../../../api/errors';
import type { FormState } from './form-state';
type TargetKind = FormState['targets'][number]['kind'];
@@ -41,7 +42,7 @@ export function NotifyStep({
const res = await preview.mutateAsync({ id: ruleId, req: {} });
setLastPreview(`TITLE:\n${res.title ?? ''}\n\nMESSAGE:\n${res.message ?? ''}`);
} catch (e) {
toast({ title: 'Preview failed', description: String(e), variant: 'error' });
toast({ title: 'Preview failed', description: describeApiError(e), variant: 'error' });
}
};

View File

@@ -24,6 +24,7 @@ import { prefillFromPromotion, type PrefillWarning } from './promotion-prefill';
import { useCatalog } from '../../../api/queries/catalog';
import { useOutboundConnections } from '../../../api/queries/admin/outboundConnections';
import { useSelectedEnv } from '../../../api/queries/alertMeta';
import { describeApiError } from '../../../api/errors';
import sectionStyles from '../../../styles/section-card.module.css';
import css from './wizard.module.css';
@@ -128,7 +129,7 @@ export default function RuleEditorWizard() {
}
navigate('/alerts/rules');
} catch (e) {
toast({ title: 'Save failed', description: String(e), variant: 'error' });
toast({ title: 'Save failed', description: describeApiError(e), variant: 'error' });
}
};

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Button, FormField, Input, useToast } from '@cameleer/design-system';
import { useTestEvaluate } from '../../../api/queries/alertRules';
import { describeApiError } from '../../../api/errors';
import type { FormState } from './form-state';
export function TriggerStep({
@@ -25,7 +26,7 @@ export function TriggerStep({
const result = await testEvaluate.mutateAsync({ id: ruleId, req: {} });
setLastResult(JSON.stringify(result, null, 2));
} catch (e) {
toast({ title: 'Test-evaluate failed', description: String(e), variant: 'error' });
toast({ title: 'Test-evaluate failed', description: describeApiError(e), variant: 'error' });
}
};

View File

@@ -16,6 +16,7 @@ import {
} from '../../api/queries/alertRules';
import { useEnvironments } from '../../api/queries/admin/environments';
import { useSelectedEnv } from '../../api/queries/alertMeta';
import { describeApiError } from '../../api/errors';
import tableStyles from '../../styles/table-section.module.css';
import css from './alerts-page.module.css';
@@ -41,7 +42,7 @@ export default function RulesListPage() {
await setEnabled.mutateAsync({ id: r.id!, enabled: !r.enabled });
toast({ title: r.enabled ? 'Disabled' : 'Enabled', description: r.name, variant: 'success' });
} catch (e) {
toast({ title: 'Toggle failed', description: String(e), variant: 'error' });
toast({ title: 'Toggle failed', description: describeApiError(e), variant: 'error' });
}
};
@@ -51,7 +52,7 @@ export default function RulesListPage() {
await deleteRule.mutateAsync(pendingDelete.id!);
toast({ title: 'Deleted', description: pendingDelete.name, variant: 'success' });
} catch (e) {
toast({ title: 'Delete failed', description: String(e), variant: 'error' });
toast({ title: 'Delete failed', description: describeApiError(e), variant: 'error' });
} finally {
setPendingDelete(null);
}

View File

@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router';
import { Button, Dropdown, useToast } from '@cameleer/design-system';
import type { DropdownItem } from '@cameleer/design-system';
import { useCreateSilence } from '../../api/queries/alertSilences';
import { describeApiError } from '../../api/errors';
interface Props {
ruleId: string;
@@ -37,7 +38,7 @@ export function SilenceRuleMenu({ ruleId, ruleTitle, onDone, variant = 'row' }:
toast({ title: `Silenced for ${hours}h`, variant: 'success' });
onDone?.();
} catch (e) {
toast({ title: 'Silence failed', description: String(e), variant: 'error' });
toast({ title: 'Silence failed', description: describeApiError(e), variant: 'error' });
}
};

View File

@@ -13,6 +13,7 @@ import {
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';
@@ -63,7 +64,7 @@ export default function SilencesPage() {
setHours(1);
toast({ title: 'Silence created', variant: 'success' });
} catch (e) {
toast({ title: 'Create failed', description: String(e), variant: 'error' });
toast({ title: 'Create failed', description: describeApiError(e), variant: 'error' });
}
};
@@ -73,7 +74,7 @@ export default function SilencesPage() {
await remove.mutateAsync(pendingEnd.id!);
toast({ title: 'Silence removed', variant: 'success' });
} catch (e) {
toast({ title: 'Remove failed', description: String(e), variant: 'error' });
toast({ title: 'Remove failed', description: describeApiError(e), variant: 'error' });
} finally {
setPendingEnd(null);
}