diff --git a/CLAUDE.md b/CLAUDE.md index ccdb66e6..19232aac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,7 @@ PostgreSQL (Flyway): `cameleer-server-app/src/main/resources/db/migration/` - V15 — Discriminate open-instance uniqueness by `context->'exchange'->>'id'` so EXCHANGE_MATCH/PER_EXCHANGE emits one alert_instance per matching exchange; scalar kinds resolve to `''` and keep one-open-per-rule. - V16 — Generalise the V15 discriminator to prefer `context->>'_subjectFingerprint'` (falls back to the V15 `exchange.id` expression for legacy rows). Enables AGENT_LIFECYCLE to emit one alert_instance per `(agent, eventType, timestamp)` via a canonical fingerprint in the evaluator firing's context. - V17 — Alerts inbox redesign: drop `ACKNOWLEDGED` from `alert_state_enum` (ack is now orthogonal via `acked_at`), add `read_at` + `deleted_at` timestamp columns (global, no per-user tracking), drop `alert_reads` table entirely, rework the V13/V15/V16 open-rule unique index predicate to `state IN ('PENDING','FIRING') AND deleted_at IS NULL` so ack doesn't close the slot and soft-delete frees it. +- V18 — Add `AGENT_LIFECYCLE` to `condition_kind_enum`. The Java `ConditionKind` enum had shipped with this value since the alerting branch, but no migration ever extended the Postgres type — any insert of a rule with `conditionKind=AGENT_LIFECYCLE` failed with `invalid input value for enum condition_kind_enum`. Single-statement migration because `ALTER TYPE ... ADD VALUE` can't coexist with usage of the new value in the same transaction. ClickHouse: `cameleer-server-app/src/main/resources/clickhouse/init.sql` (run idempotently on startup) diff --git a/cameleer-server-app/src/main/resources/db/migration/V18__condition_kind_add_agent_lifecycle.sql b/cameleer-server-app/src/main/resources/db/migration/V18__condition_kind_add_agent_lifecycle.sql new file mode 100644 index 00000000..b9c208a4 --- /dev/null +++ b/cameleer-server-app/src/main/resources/db/migration/V18__condition_kind_add_agent_lifecycle.sql @@ -0,0 +1,10 @@ +-- V18 — Add AGENT_LIFECYCLE to condition_kind_enum. +-- +-- The Java ConditionKind enum shipped AGENT_LIFECYCLE with the alerting +-- branch, but no migration ever extended the Postgres type. Inserting a +-- rule with conditionKind=AGENT_LIFECYCLE failed with +-- ERROR: invalid input value for enum condition_kind_enum: "AGENT_LIFECYCLE" +-- ALTER TYPE ... ADD VALUE must live alone in its migration — Postgres won't +-- allow the new value to be referenced in the same transaction that adds it. + +ALTER TYPE condition_kind_enum ADD VALUE IF NOT EXISTS 'AGENT_LIFECYCLE' AFTER 'AGENT_STATE'; diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V18MigrationIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V18MigrationIT.java new file mode 100644 index 00000000..1551143d --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V18MigrationIT.java @@ -0,0 +1,20 @@ +package com.cameleer.server.app.alerting.storage; + +import com.cameleer.server.app.AbstractPostgresIT; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class V18MigrationIT extends AbstractPostgresIT { + + @Test + void condition_kind_enum_includes_agent_lifecycle() { + var values = jdbcTemplate.queryForList(""" + SELECT unnest(enum_range(NULL::condition_kind_enum))::text AS v + """, String.class); + assertThat(values).contains("AGENT_LIFECYCLE"); + assertThat(values).containsExactlyInAnyOrder( + "ROUTE_METRIC", "EXCHANGE_MATCH", "AGENT_STATE", "AGENT_LIFECYCLE", + "DEPLOYMENT_STATE", "LOG_PATTERN", "JVM_METRIC"); + } +} diff --git a/ui/src/api/errors.ts b/ui/src/api/errors.ts new file mode 100644 index 00000000..bffec813 --- /dev/null +++ b/ui/src/api/errors.ts @@ -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; + 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); +} diff --git a/ui/src/pages/Admin/OutboundConnectionEditor.tsx b/ui/src/pages/Admin/OutboundConnectionEditor.tsx index 92ebd3f2..d6ad4f3f 100644 --- a/ui/src/pages/Admin/OutboundConnectionEditor.tsx +++ b/ui/src/pages/Admin/OutboundConnectionEditor.tsx @@ -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' }), }); } }; diff --git a/ui/src/pages/Admin/OutboundConnectionsPage.tsx b/ui/src/pages/Admin/OutboundConnectionsPage.tsx index 03ec73f7..de4c3429 100644 --- a/ui/src/pages/Admin/OutboundConnectionsPage.tsx +++ b/ui/src/pages/Admin/OutboundConnectionsPage.tsx @@ -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' }), }); }; diff --git a/ui/src/pages/Alerts/InboxPage.tsx b/ui/src/pages/Alerts/InboxPage.tsx index e548018e..3c158532 100644 --- a/ui/src/pages/Alerts/InboxPage.tsx +++ b/ui/src/pages/Alerts/InboxPage.tsx @@ -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' }); } }; diff --git a/ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx b/ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx index 7aa81068..a137edfe 100644 --- a/ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx +++ b/ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx @@ -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' }); } }; diff --git a/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx b/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx index 71f12450..62c502cf 100644 --- a/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx +++ b/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx @@ -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' }); } }; diff --git a/ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx b/ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx index 3ed2cf68..61572e9a 100644 --- a/ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx +++ b/ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx @@ -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' }); } }; diff --git a/ui/src/pages/Alerts/RulesListPage.tsx b/ui/src/pages/Alerts/RulesListPage.tsx index 890a087a..7bfe127a 100644 --- a/ui/src/pages/Alerts/RulesListPage.tsx +++ b/ui/src/pages/Alerts/RulesListPage.tsx @@ -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); } diff --git a/ui/src/pages/Alerts/SilenceRuleMenu.tsx b/ui/src/pages/Alerts/SilenceRuleMenu.tsx index d4530835..53dfaa96 100644 --- a/ui/src/pages/Alerts/SilenceRuleMenu.tsx +++ b/ui/src/pages/Alerts/SilenceRuleMenu.tsx @@ -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' }); } }; diff --git a/ui/src/pages/Alerts/SilencesPage.tsx b/ui/src/pages/Alerts/SilencesPage.tsx index 41b649c9..75a3819a 100644 --- a/ui/src/pages/Alerts/SilencesPage.tsx +++ b/ui/src/pages/Alerts/SilencesPage.tsx @@ -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); }