refactor(ui/alerts): derive option lists + form-state types from schema.d.ts
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m8s
CI / docker (push) Successful in 1m15s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s

Closes item 5 on the Plan 03 cleanup triage. The option arrays
("METRICS", "COMPARATORS", KIND_OPTIONS, SEVERITY_OPTIONS, FIRE_MODES)
scattered across RouteMetricForm / JvmMetricForm / ExchangeMatchForm /
ConditionStep / ScopeStep were hand-typed string literals. They drifted
silently — P95_LATENCY_MS appeared in a dropdown without a backend
counterpart (caught at runtime in bcde6678); JvmMetric.LATEST and
Comparator.EQ existed on the backend but were missing from the UI all
along.

Fix: new `ui/src/api/alerting-enums.ts` derives every enum from
schema.d.ts and pairs each with a `Record<T, string>` label map.
TypeScript enforces exhaustiveness — adding or removing a backend
value fails the build of this file until the label map is updated.
Every consumer imports the generated `*_OPTIONS` array.

Covered (schema-derived):
  - ConditionKind            → CONDITION_KIND_OPTIONS
  - Severity                 → SEVERITY_OPTIONS
  - RouteMetric              → ROUTE_METRIC_OPTIONS
  - Comparator               → COMPARATOR_OPTIONS (adds EQ that was missing)
  - JvmAggregation           → JVM_AGGREGATION_OPTIONS (adds LATEST that was missing)
  - ExchangeMatch.fireMode   → EXCHANGE_FIRE_MODE_OPTIONS
  - AlertRuleTarget.kind     → TARGET_KIND_OPTIONS

form-state.ts: `severity: 'CRITICAL' | 'WARNING' | 'INFO'` and
`kind: 'USER' | 'GROUP' | 'ROLE'` literal unions swapped for the
derived `Severity` / `TargetKind` aliases.

Not covered, backend types them as `String` (no `@Schema(allowableValues)`
annotation yet):
  - AgentStateCondition.state
  - DeploymentStateCondition.states
  - LogPatternCondition.level
  - ExchangeFilter.status
  - JvmMetricCondition.metric

These stay hand-typed with a pointer-comment. Follow-up: add
`@Schema(allowableValues = …)` to the Java record components so the
enums land in schema.d.ts; then fold them into alerting-enums.ts.

Plus: gitnexus index-stats refresh in AGENTS.md/CLAUDE.md from the
post-deploy reindex.

Verified: ui build green, 49/49 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-20 19:02:52 +02:00
parent f8c1ba4988
commit 83837ada8f
9 changed files with 129 additions and 56 deletions

View File

@@ -6,15 +6,7 @@ import { AgentStateForm } from './condition-forms/AgentStateForm';
import { DeploymentStateForm } from './condition-forms/DeploymentStateForm';
import { LogPatternForm } from './condition-forms/LogPatternForm';
import { JvmMetricForm } from './condition-forms/JvmMetricForm';
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)' },
];
import { CONDITION_KIND_OPTIONS } from '../../../api/alerting-enums';
export function ConditionStep({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
const onKindChange = (v: string) => {
@@ -35,7 +27,7 @@ export function ConditionStep({ form, setForm }: { form: FormState; setForm: (f:
<Select
value={form.conditionKind}
onChange={(e) => onKindChange(e.target.value)}
options={KIND_OPTIONS}
options={CONDITION_KIND_OPTIONS}
/>
</FormField>
{form.conditionKind === 'ROUTE_METRIC' && <RouteMetricForm form={form} setForm={setForm} />}

View File

@@ -3,12 +3,7 @@ import { useCatalog } from '../../../api/queries/catalog';
import { useAgents } from '../../../api/queries/agents';
import { useSelectedEnv } from '../../../api/queries/alertMeta';
import type { FormState } from './form-state';
const SEVERITY_OPTIONS = [
{ value: 'CRITICAL', label: 'Critical' },
{ value: 'WARNING', label: 'Warning' },
{ value: 'INFO', label: 'Info' },
];
import { SEVERITY_OPTIONS } from '../../../api/alerting-enums';
const SCOPE_OPTIONS = [
{ value: 'env', label: 'Environment-wide' },

View File

@@ -1,11 +1,10 @@
import { FormField, Input, Select } from '@cameleer/design-system';
import type { FormState } from '../form-state';
import { EXCHANGE_FIRE_MODE_OPTIONS } from '../../../../api/alerting-enums';
const FIRE_MODES = [
{ value: 'PER_EXCHANGE', label: 'One alert per matching exchange' },
{ value: 'COUNT_IN_WINDOW', label: 'Threshold: N matches in window' },
];
// ExchangeFilter.status is typed as `String` on the backend (no @Schema
// allowableValues yet) so options stay hand-typed. Follow-up: annotate the
// record component to fold this into alerting-enums.ts.
const STATUSES = [
{ value: '', label: '(any)' },
{ value: 'COMPLETED', label: 'COMPLETED' },
@@ -25,7 +24,7 @@ export function ExchangeMatchForm({ form, setForm }: { form: FormState; setForm:
<Select
value={(c.fireMode as string) ?? 'PER_EXCHANGE'}
onChange={(e) => patch({ fireMode: e.target.value })}
options={FIRE_MODES}
options={EXCHANGE_FIRE_MODE_OPTIONS}
/>
</FormField>
<FormField label="Status filter">

View File

@@ -1,5 +1,6 @@
import { FormField, Input, Select } from '@cameleer/design-system';
import type { FormState } from '../form-state';
import { JVM_AGGREGATION_OPTIONS, COMPARATOR_OPTIONS } from '../../../../api/alerting-enums';
export function JvmMetricForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
const c = form.condition as Record<string, unknown>;
@@ -19,23 +20,14 @@ export function JvmMetricForm({ form, setForm }: { form: FormState; setForm: (f:
<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' },
]}
options={JVM_AGGREGATION_OPTIONS}
/>
</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' },
]}
options={COMPARATOR_OPTIONS}
/>
</FormField>
<FormField label="Threshold">

View File

@@ -1,21 +1,6 @@
import { FormField, Input, Select } from '@cameleer/design-system';
import type { FormState } from '../form-state';
// Mirrors cameleer-server-core RouteMetric enum — keep in sync.
const METRICS = [
{ value: 'ERROR_RATE', label: 'Error rate' },
{ 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' },
];
import { ROUTE_METRIC_OPTIONS, COMPARATOR_OPTIONS } from '../../../../api/alerting-enums';
export function RouteMetricForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
const c = form.condition as Record<string, unknown>;
@@ -28,14 +13,14 @@ export function RouteMetricForm({ form, setForm }: { form: FormState; setForm: (
<Select
value={(c.metric as string) ?? ''}
onChange={(e) => patch({ metric: e.target.value })}
options={METRICS}
options={ROUTE_METRIC_OPTIONS}
/>
</FormField>
<FormField label="Comparator">
<Select
value={(c.comparator as string) ?? 'GT'}
onChange={(e) => patch({ comparator: e.target.value })}
options={COMPARATORS}
options={COMPARATOR_OPTIONS}
/>
</FormField>
<FormField label="Threshold">

View File

@@ -4,6 +4,7 @@ import type {
ConditionKind,
AlertCondition,
} from '../../../api/queries/alertRules';
import type { Severity, TargetKind } from '../../../api/alerting-enums';
export type WizardStep = 'scope' | 'condition' | 'trigger' | 'notify' | 'review';
export const WIZARD_STEPS: WizardStep[] = ['scope', 'condition', 'trigger', 'notify', 'review'];
@@ -11,7 +12,7 @@ export const WIZARD_STEPS: WizardStep[] = ['scope', 'condition', 'trigger', 'not
export interface FormState {
name: string;
description: string;
severity: 'CRITICAL' | 'WARNING' | 'INFO';
severity: Severity;
enabled: boolean;
// Scope (radio: env-wide | app | route | agent)
@@ -36,7 +37,7 @@ export interface FormState {
headerOverrides: Array<{ key: string; value: string }>;
}>;
targets: Array<{ kind: 'USER' | 'GROUP' | 'ROLE'; targetId: string }>;
targets: Array<{ kind: TargetKind; targetId: string }>;
}
export function initialForm(existing?: AlertRuleResponse): FormState {
@@ -102,7 +103,7 @@ export function initialForm(existing?: AlertRuleResponse): FormState {
.map(([key, value]) => ({ key, value })),
})),
targets: (existing.targets ?? []).map((t) => ({
kind: (t.kind ?? 'USER') as 'USER' | 'GROUP' | 'ROLE',
kind: (t.kind ?? 'USER') as TargetKind,
targetId: t.targetId ?? '',
})),
};