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:
@@ -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 — 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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user