feat(ui/alerts): ConditionStep with 6 kind-specific forms

Each condition kind (ROUTE_METRIC, EXCHANGE_MATCH, AGENT_STATE,
DEPLOYMENT_STATE, LOG_PATTERN, JVM_METRIC) renders its own payload-shape
form. Changing the kind resets the condition payload to {kind, scope} so
stale fields from a previous kind don't leak into the save request.

Deviation: DS Select uses native event-based onChange. Plan draft showed
a value-based signature (onChange(v) => ...).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-20 13:59:55 +02:00
parent f48fc750f2
commit ef8c60c2b5
7 changed files with 333 additions and 2 deletions

View File

@@ -1,5 +1,49 @@
import { FormField, Select } from '@cameleer/design-system';
import type { FormState } from './form-state'; 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 }) { const KIND_OPTIONS = [
return <div>Condition step &mdash; TODO Task 21</div>; { 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<string, unknown>;
setForm({
...form,
conditionKind: kind,
condition: { kind, scope: prev.scope } as FormState['condition'],
});
};
return (
<div style={{ display: 'grid', gap: 16, maxWidth: 720 }}>
<FormField label="Condition kind">
<Select
value={form.conditionKind}
onChange={(e) => onKindChange(e.target.value)}
options={KIND_OPTIONS}
/>
</FormField>
{form.conditionKind === 'ROUTE_METRIC' && <RouteMetricForm form={form} setForm={setForm} />}
{form.conditionKind === 'EXCHANGE_MATCH' && <ExchangeMatchForm form={form} setForm={setForm} />}
{form.conditionKind === 'AGENT_STATE' && <AgentStateForm form={form} setForm={setForm} />}
{form.conditionKind === 'DEPLOYMENT_STATE' && <DeploymentStateForm form={form} setForm={setForm} />}
{form.conditionKind === 'LOG_PATTERN' && <LogPatternForm form={form} setForm={setForm} />}
{form.conditionKind === 'JVM_METRIC' && <JvmMetricForm form={form} setForm={setForm} />}
</div>
);
} }

View File

@@ -0,0 +1,30 @@
import { FormField, Input, Select } from '@cameleer/design-system';
import type { FormState } from '../form-state';
export function AgentStateForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
const c = form.condition as Record<string, unknown>;
const patch = (p: Record<string, unknown>) =>
setForm({ ...form, condition: { ...(form.condition as Record<string, unknown>), ...p } as FormState['condition'] });
return (
<>
<FormField label="Agent state">
<Select
value={(c.state as string) ?? 'DEAD'}
onChange={(e) => patch({ state: e.target.value })}
options={[
{ value: 'DEAD', label: 'DEAD' },
{ value: 'STALE', label: 'STALE' },
]}
/>
</FormField>
<FormField label="For duration (seconds)">
<Input
type="number"
value={(c.forSeconds as number | undefined) ?? 60}
onChange={(e) => patch({ forSeconds: Number(e.target.value) })}
/>
</FormField>
</>
);
}

View File

@@ -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<string, unknown>;
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<string, unknown>), states: next } as FormState['condition'] });
};
return (
<FormField label="Fire when deployment is in states">
<div style={{ display: 'flex', gap: 12 }}>
{OPTIONS.map((s) => (
<label key={s} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input type="checkbox" checked={states.includes(s)} onChange={() => toggle(s)} />
{s}
</label>
))}
</div>
</FormField>
);
}

View File

@@ -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<string, unknown>;
const filter = (c.filter as Record<string, unknown> | undefined) ?? {};
const patch = (p: Record<string, unknown>) =>
setForm({ ...form, condition: { ...(form.condition as Record<string, unknown>), ...p } as FormState['condition'] });
return (
<>
<FormField label="Fire mode">
<Select
value={(c.fireMode as string) ?? 'PER_EXCHANGE'}
onChange={(e) => patch({ fireMode: e.target.value })}
options={FIRE_MODES}
/>
</FormField>
<FormField label="Status filter">
<Select
value={(filter.status as string) ?? ''}
onChange={(e) => patch({ filter: { ...filter, status: e.target.value || undefined } })}
options={STATUSES}
/>
</FormField>
{c.fireMode === 'PER_EXCHANGE' && (
<FormField label="Linger seconds (default 300)">
<Input
type="number"
value={(c.perExchangeLingerSeconds as number | undefined) ?? 300}
onChange={(e) => patch({ perExchangeLingerSeconds: Number(e.target.value) })}
/>
</FormField>
)}
{c.fireMode === 'COUNT_IN_WINDOW' && (
<>
<FormField label="Threshold (matches)">
<Input
type="number"
value={(c.threshold as number | undefined) ?? ''}
onChange={(e) => patch({ threshold: Number(e.target.value) })}
/>
</FormField>
<FormField label="Window (seconds)">
<Input
type="number"
value={(c.windowSeconds as number | undefined) ?? 900}
onChange={(e) => patch({ windowSeconds: Number(e.target.value) })}
/>
</FormField>
</>
)}
</>
);
}

View File

@@ -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<string, unknown>;
const patch = (p: Record<string, unknown>) =>
setForm({ ...form, condition: { ...(form.condition as Record<string, unknown>), ...p } as FormState['condition'] });
return (
<>
<FormField label="Metric name">
<Input
value={(c.metric as string | undefined) ?? ''}
onChange={(e) => patch({ metric: e.target.value })}
placeholder="heap_used_percent"
/>
</FormField>
<FormField label="Aggregation">
<Select
value={(c.aggregation as string) ?? 'MAX'}
onChange={(e) => patch({ aggregation: e.target.value })}
options={[
{ value: 'MAX', label: 'MAX' },
{ value: 'AVG', label: 'AVG' },
{ value: 'MIN', label: 'MIN' },
]}
/>
</FormField>
<FormField label="Comparator">
<Select
value={(c.comparator as string) ?? 'GT'}
onChange={(e) => patch({ comparator: e.target.value })}
options={[
{ value: 'GT', label: '>' },
{ value: 'GTE', label: '\u2265' },
{ value: 'LT', label: '<' },
{ value: 'LTE', label: '\u2264' },
]}
/>
</FormField>
<FormField label="Threshold">
<Input
type="number"
value={(c.threshold as number | undefined) ?? ''}
onChange={(e) => patch({ threshold: Number(e.target.value) })}
/>
</FormField>
<FormField label="Window (seconds)">
<Input
type="number"
value={(c.windowSeconds as number | undefined) ?? 300}
onChange={(e) => patch({ windowSeconds: Number(e.target.value) })}
/>
</FormField>
</>
);
}

View File

@@ -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<string, unknown>;
const patch = (p: Record<string, unknown>) =>
setForm({ ...form, condition: { ...(form.condition as Record<string, unknown>), ...p } as FormState['condition'] });
return (
<>
<FormField label="Level">
<Select
value={(c.level as string) ?? 'ERROR'}
onChange={(e) => patch({ level: e.target.value })}
options={[
{ value: 'ERROR', label: 'ERROR' },
{ value: 'WARN', label: 'WARN' },
{ value: 'INFO', label: 'INFO' },
]}
/>
</FormField>
<FormField label="Logger (substring, optional)">
<Input
value={(c.logger as string | undefined) ?? ''}
onChange={(e) => patch({ logger: e.target.value || undefined })}
/>
</FormField>
<FormField label="Pattern (regex)">
<Input
value={(c.pattern as string | undefined) ?? ''}
onChange={(e) => patch({ pattern: e.target.value })}
/>
</FormField>
<FormField label="Threshold (matches)">
<Input
type="number"
value={(c.threshold as number | undefined) ?? ''}
onChange={(e) => patch({ threshold: Number(e.target.value) })}
/>
</FormField>
<FormField label="Window (seconds)">
<Input
type="number"
value={(c.windowSeconds as number | undefined) ?? 900}
onChange={(e) => patch({ windowSeconds: Number(e.target.value) })}
/>
</FormField>
</>
);
}

View File

@@ -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<string, unknown>;
const patch = (p: Record<string, unknown>) =>
setForm({ ...form, condition: { ...(form.condition as Record<string, unknown>), ...p } as FormState['condition'] });
return (
<>
<FormField label="Metric">
<Select
value={(c.metric as string) ?? ''}
onChange={(e) => patch({ metric: e.target.value })}
options={METRICS}
/>
</FormField>
<FormField label="Comparator">
<Select
value={(c.comparator as string) ?? 'GT'}
onChange={(e) => patch({ comparator: e.target.value })}
options={COMPARATORS}
/>
</FormField>
<FormField label="Threshold">
<Input
type="number"
value={(c.threshold as number | undefined) ?? ''}
onChange={(e) => patch({ threshold: Number(e.target.value) })}
/>
</FormField>
<FormField label="Window (seconds)">
<Input
type="number"
value={(c.windowSeconds as number | undefined) ?? 300}
onChange={(e) => patch({ windowSeconds: Number(e.target.value) })}
/>
</FormField>
</>
);
}