diff --git a/ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx b/ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx index a22fe75a..a3625e10 100644 --- a/ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx +++ b/ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx @@ -1,5 +1,49 @@ +import { FormField, Select } from '@cameleer/design-system'; import type { FormState } from './form-state'; +import { RouteMetricForm } from './condition-forms/RouteMetricForm'; +import { ExchangeMatchForm } from './condition-forms/ExchangeMatchForm'; +import { AgentStateForm } from './condition-forms/AgentStateForm'; +import { DeploymentStateForm } from './condition-forms/DeploymentStateForm'; +import { LogPatternForm } from './condition-forms/LogPatternForm'; +import { JvmMetricForm } from './condition-forms/JvmMetricForm'; -export function ConditionStep({ form: _form, setForm: _setForm }: { form: FormState; setForm: (f: FormState) => void }) { - return
Condition step — TODO Task 21
; +const KIND_OPTIONS = [ + { value: 'ROUTE_METRIC', label: 'Route metric (error rate, latency, throughput)' }, + { value: 'EXCHANGE_MATCH', label: 'Exchange match (specific failures)' }, + { value: 'AGENT_STATE', label: 'Agent state (DEAD / STALE)' }, + { value: 'DEPLOYMENT_STATE', label: 'Deployment state (FAILED / DEGRADED)' }, + { value: 'LOG_PATTERN', label: 'Log pattern (count of matching logs)' }, + { value: 'JVM_METRIC', label: 'JVM metric (heap, GC, inflight)' }, +]; + +export function ConditionStep({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) { + const onKindChange = (v: string) => { + const kind = v as FormState['conditionKind']; + // Reset the condition payload so stale fields from a previous kind don't leak + // into the save request. Preserve scope — it's managed on the scope step. + const prev = form.condition as Record; + setForm({ + ...form, + conditionKind: kind, + condition: { kind, scope: prev.scope } as FormState['condition'], + }); + }; + + return ( +
+ + patch({ state: e.target.value })} + options={[ + { value: 'DEAD', label: 'DEAD' }, + { value: 'STALE', label: 'STALE' }, + ]} + /> + + + patch({ forSeconds: Number(e.target.value) })} + /> + + + ); +} diff --git a/ui/src/pages/Alerts/RuleEditor/condition-forms/DeploymentStateForm.tsx b/ui/src/pages/Alerts/RuleEditor/condition-forms/DeploymentStateForm.tsx new file mode 100644 index 00000000..5497add4 --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/condition-forms/DeploymentStateForm.tsx @@ -0,0 +1,26 @@ +import { FormField } from '@cameleer/design-system'; +import type { FormState } from '../form-state'; + +const OPTIONS = ['FAILED', 'DEGRADED'] as const; + +export function DeploymentStateForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) { + const c = form.condition as Record; + const states: string[] = (c.states as string[] | undefined) ?? []; + const toggle = (s: string) => { + const next = states.includes(s) ? states.filter((x) => x !== s) : [...states, s]; + setForm({ ...form, condition: { ...(form.condition as Record), states: next } as FormState['condition'] }); + }; + + return ( + +
+ {OPTIONS.map((s) => ( + + ))} +
+
+ ); +} diff --git a/ui/src/pages/Alerts/RuleEditor/condition-forms/ExchangeMatchForm.tsx b/ui/src/pages/Alerts/RuleEditor/condition-forms/ExchangeMatchForm.tsx new file mode 100644 index 00000000..e2e360f4 --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/condition-forms/ExchangeMatchForm.tsx @@ -0,0 +1,67 @@ +import { FormField, Input, Select } from '@cameleer/design-system'; +import type { FormState } from '../form-state'; + +const FIRE_MODES = [ + { value: 'PER_EXCHANGE', label: 'One alert per matching exchange' }, + { value: 'COUNT_IN_WINDOW', label: 'Threshold: N matches in window' }, +]; + +const STATUSES = [ + { value: '', label: '(any)' }, + { value: 'COMPLETED', label: 'COMPLETED' }, + { value: 'FAILED', label: 'FAILED' }, + { value: 'RUNNING', label: 'RUNNING' }, +]; + +export function ExchangeMatchForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) { + const c = form.condition as Record; + const filter = (c.filter as Record | undefined) ?? {}; + const patch = (p: Record) => + setForm({ ...form, condition: { ...(form.condition as Record), ...p } as FormState['condition'] }); + + return ( + <> + + patch({ filter: { ...filter, status: e.target.value || undefined } })} + options={STATUSES} + /> + + {c.fireMode === 'PER_EXCHANGE' && ( + + patch({ perExchangeLingerSeconds: Number(e.target.value) })} + /> + + )} + {c.fireMode === 'COUNT_IN_WINDOW' && ( + <> + + patch({ threshold: Number(e.target.value) })} + /> + + + patch({ windowSeconds: Number(e.target.value) })} + /> + + + )} + + ); +} diff --git a/ui/src/pages/Alerts/RuleEditor/condition-forms/JvmMetricForm.tsx b/ui/src/pages/Alerts/RuleEditor/condition-forms/JvmMetricForm.tsx new file mode 100644 index 00000000..59b7bcd9 --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/condition-forms/JvmMetricForm.tsx @@ -0,0 +1,57 @@ +import { FormField, Input, Select } from '@cameleer/design-system'; +import type { FormState } from '../form-state'; + +export function JvmMetricForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) { + const c = form.condition as Record; + const patch = (p: Record) => + setForm({ ...form, condition: { ...(form.condition as Record), ...p } as FormState['condition'] }); + + return ( + <> + + patch({ metric: e.target.value })} + placeholder="heap_used_percent" + /> + + + patch({ comparator: e.target.value })} + options={[ + { value: 'GT', label: '>' }, + { value: 'GTE', label: '\u2265' }, + { value: 'LT', label: '<' }, + { value: 'LTE', label: '\u2264' }, + ]} + /> + + + patch({ threshold: Number(e.target.value) })} + /> + + + patch({ windowSeconds: Number(e.target.value) })} + /> + + + ); +} diff --git a/ui/src/pages/Alerts/RuleEditor/condition-forms/LogPatternForm.tsx b/ui/src/pages/Alerts/RuleEditor/condition-forms/LogPatternForm.tsx new file mode 100644 index 00000000..b34efba4 --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/condition-forms/LogPatternForm.tsx @@ -0,0 +1,50 @@ +import { FormField, Input, Select } from '@cameleer/design-system'; +import type { FormState } from '../form-state'; + +export function LogPatternForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) { + const c = form.condition as Record; + const patch = (p: Record) => + setForm({ ...form, condition: { ...(form.condition as Record), ...p } as FormState['condition'] }); + + return ( + <> + + patch({ logger: e.target.value || undefined })} + /> + + + patch({ pattern: e.target.value })} + /> + + + patch({ threshold: Number(e.target.value) })} + /> + + + patch({ windowSeconds: Number(e.target.value) })} + /> + + + ); +} diff --git a/ui/src/pages/Alerts/RuleEditor/condition-forms/RouteMetricForm.tsx b/ui/src/pages/Alerts/RuleEditor/condition-forms/RouteMetricForm.tsx new file mode 100644 index 00000000..e8138110 --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/condition-forms/RouteMetricForm.tsx @@ -0,0 +1,57 @@ +import { FormField, Input, Select } from '@cameleer/design-system'; +import type { FormState } from '../form-state'; + +const METRICS = [ + { value: 'ERROR_RATE', label: 'Error rate' }, + { value: 'P95_LATENCY_MS', label: 'P95 latency (ms)' }, + { value: 'P99_LATENCY_MS', label: 'P99 latency (ms)' }, + { value: 'AVG_DURATION_MS', label: 'Avg duration (ms)' }, + { value: 'THROUGHPUT', label: 'Throughput (msg/s)' }, + { value: 'ERROR_COUNT', label: 'Error count' }, +]; + +const COMPARATORS = [ + { value: 'GT', label: '>' }, + { value: 'GTE', label: '\u2265' }, + { value: 'LT', label: '<' }, + { value: 'LTE', label: '\u2264' }, +]; + +export function RouteMetricForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) { + const c = form.condition as Record; + const patch = (p: Record) => + setForm({ ...form, condition: { ...(form.condition as Record), ...p } as FormState['condition'] }); + + return ( + <> + + patch({ comparator: e.target.value })} + options={COMPARATORS} + /> + + + patch({ threshold: Number(e.target.value) })} + /> + + + patch({ windowSeconds: Number(e.target.value) })} + /> + + + ); +}