diff --git a/ui/src/api/alerting-enums.ts b/ui/src/api/alerting-enums.ts deleted file mode 100644 index cbd5a344..00000000 --- a/ui/src/api/alerting-enums.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Alerting option lists derived from the OpenAPI schema. - * - * Why this module exists: option arrays in condition-form components used to - * be hand-typed string literals, and they drifted silently from the backend - * enums (e.g. P95_LATENCY_MS appeared in the dropdown without a matching - * backend value; LATEST was exposed by the backend but never surfaced in the - * UI). Every dropdown value here is derived from a schema.d.ts union type and - * every label is required via `Record` — TypeScript will refuse to - * compile if the backend adds or removes a value and this file isn't updated. - * - * Workflow when an alerting enum changes on the backend: - * 1. `cd ui && npm run generate-api:live` (or `generate-api` after deploy). - * 2. The `Record<…, string>` maps below fail to type-check for any new or - * removed value. Fix the map. - * 3. Every consumer (`METRIC_OPTIONS`, …) regenerates automatically. - * - * Fields whose backend type is `String` rather than a typed enum (agent state, - * log level, deployment states, exchange filter status, JVM metric names) - * cannot be derived here today — springdoc emits them as open-ended strings. - * Follow-up: add `@Schema(allowableValues = …)` on the relevant Java record - * components so they land in schema.d.ts as unions, then fold them in here. - */ -import type { components } from './schema'; - -type AlertRuleRequest = components['schemas']['AlertRuleRequest']; -type RouteMetricCondition = components['schemas']['RouteMetricCondition']; -type JvmMetricCondition = components['schemas']['JvmMetricCondition']; -type ExchangeMatchCondition = components['schemas']['ExchangeMatchCondition']; -type AlertRuleTarget = components['schemas']['AlertRuleTarget']; - -export type ConditionKind = NonNullable; -export type Severity = NonNullable; -export type RouteMetric = NonNullable; -export type Comparator = NonNullable; -export type JvmAggregation = NonNullable; -export type ExchangeFireMode = NonNullable; -export type TargetKind = NonNullable; - -export interface Option { value: T; label: string } - -function toOptions(labels: Record): Option[] { - return (Object.keys(labels) as T[]).map((value) => ({ value, label: labels[value] })); -} - -// --------------------------------------------------------------------------- -// Label maps — one entry per backend value, TypeScript enforces exhaustiveness. -// --------------------------------------------------------------------------- - -const CONDITION_KIND_LABELS: Record = { - ROUTE_METRIC: 'Route metric (error rate, latency, throughput)', - EXCHANGE_MATCH: 'Exchange match (specific failures)', - AGENT_STATE: 'Agent state (DEAD / STALE)', - DEPLOYMENT_STATE: 'Deployment state (FAILED / DEGRADED)', - LOG_PATTERN: 'Log pattern (count of matching logs)', - JVM_METRIC: 'JVM metric (heap, GC, inflight)', -}; - -const SEVERITY_LABELS: Record = { - CRITICAL: 'Critical', - WARNING: 'Warning', - INFO: 'Info', -}; - -const ROUTE_METRIC_LABELS: Record = { - ERROR_RATE: 'Error rate', - P99_LATENCY_MS: 'P99 latency (ms)', - AVG_DURATION_MS: 'Avg duration (ms)', - THROUGHPUT: 'Throughput (msg/s)', - ERROR_COUNT: 'Error count', -}; - -const COMPARATOR_LABELS: Record = { - GT: '>', - GTE: '\u2265', - LT: '<', - LTE: '\u2264', - EQ: '=', -}; - -const JVM_AGGREGATION_LABELS: Record = { - MAX: 'MAX', - MIN: 'MIN', - AVG: 'AVG', - LATEST: 'LATEST', -}; - -const EXCHANGE_FIRE_MODE_LABELS: Record = { - PER_EXCHANGE: 'One alert per matching exchange', - COUNT_IN_WINDOW: 'Threshold: N matches in window', -}; - -const TARGET_KIND_LABELS: Record = { - USER: 'User', - GROUP: 'Group', - ROLE: 'Role', -}; - -// --------------------------------------------------------------------------- -// Exported option arrays (in label-map declaration order). -// --------------------------------------------------------------------------- - -export const CONDITION_KIND_OPTIONS: Option[] = toOptions(CONDITION_KIND_LABELS); -export const SEVERITY_OPTIONS: Option[] = toOptions(SEVERITY_LABELS); -export const ROUTE_METRIC_OPTIONS: Option[] = toOptions(ROUTE_METRIC_LABELS); -export const COMPARATOR_OPTIONS: Option[] = toOptions(COMPARATOR_LABELS); -export const JVM_AGGREGATION_OPTIONS: Option[] = toOptions(JVM_AGGREGATION_LABELS); -export const EXCHANGE_FIRE_MODE_OPTIONS:Option[] = toOptions(EXCHANGE_FIRE_MODE_LABELS); -export const TARGET_KIND_OPTIONS: Option[] = toOptions(TARGET_KIND_LABELS); diff --git a/ui/src/api/queries/alertRules.ts b/ui/src/api/queries/alertRules.ts index 2db466c8..38035723 100644 --- a/ui/src/api/queries/alertRules.ts +++ b/ui/src/api/queries/alertRules.ts @@ -9,7 +9,8 @@ export type RenderPreviewResponse = components['schemas']['RenderPreviewResponse export type TestEvaluateRequest = components['schemas']['TestEvaluateRequest']; export type TestEvaluateResponse = components['schemas']['TestEvaluateResponse']; export type AlertCondition = AlertRuleResponse['condition']; -export type ConditionKind = AlertRuleResponse['conditionKind']; +// `ConditionKind` lives in `../../pages/Alerts/enums` alongside its label map +// and option array — single source of truth to avoid duplicate-type drift. // NOTE ON TYPES: the generated OpenAPI schema for env-scoped alert-rule endpoints // emits `path?: never` plus a `query.env: Environment` parameter because the diff --git a/ui/src/components/MustacheEditor/MustacheEditor.tsx b/ui/src/components/MustacheEditor/MustacheEditor.tsx index bf66c013..1c40b58f 100644 --- a/ui/src/components/MustacheEditor/MustacheEditor.tsx +++ b/ui/src/components/MustacheEditor/MustacheEditor.tsx @@ -7,7 +7,7 @@ import { lintKeymap, lintGutter } from '@codemirror/lint'; import { mustacheCompletionSource } from './mustache-completion'; import { mustacheLinter } from './mustache-linter'; import { availableVariables } from './alert-variables'; -import type { ConditionKind } from '../../api/queries/alertRules'; +import type { ConditionKind } from '../../pages/Alerts/enums'; import css from './MustacheEditor.module.css'; export interface MustacheEditorProps { diff --git a/ui/src/components/MustacheEditor/alert-variables.ts b/ui/src/components/MustacheEditor/alert-variables.ts index c1f1833f..5886495b 100644 --- a/ui/src/components/MustacheEditor/alert-variables.ts +++ b/ui/src/components/MustacheEditor/alert-variables.ts @@ -1,4 +1,4 @@ -import type { ConditionKind } from '../../api/queries/alertRules'; +import type { ConditionKind } from '../../pages/Alerts/enums'; export type VariableType = | 'string' diff --git a/ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx b/ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx index 50a188d1..a0c4cb1b 100644 --- a/ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx +++ b/ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx @@ -6,7 +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'; -import { CONDITION_KIND_OPTIONS } from '../../../api/alerting-enums'; +import { CONDITION_KIND_OPTIONS } from '../enums'; export function ConditionStep({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) { const onKindChange = (v: string) => { diff --git a/ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx b/ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx index 45732702..caf9f7b1 100644 --- a/ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx +++ b/ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx @@ -3,7 +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'; -import { SEVERITY_OPTIONS } from '../../../api/alerting-enums'; +import { SEVERITY_OPTIONS } from '../enums'; const SCOPE_OPTIONS = [ { value: 'env', label: 'Environment-wide' }, diff --git a/ui/src/pages/Alerts/RuleEditor/condition-forms/ExchangeMatchForm.tsx b/ui/src/pages/Alerts/RuleEditor/condition-forms/ExchangeMatchForm.tsx index ffe136e3..4927102c 100644 --- a/ui/src/pages/Alerts/RuleEditor/condition-forms/ExchangeMatchForm.tsx +++ b/ui/src/pages/Alerts/RuleEditor/condition-forms/ExchangeMatchForm.tsx @@ -1,6 +1,6 @@ import { FormField, Input, Select } from '@cameleer/design-system'; import type { FormState } from '../form-state'; -import { EXCHANGE_FIRE_MODE_OPTIONS } from '../../../../api/alerting-enums'; +import { EXCHANGE_FIRE_MODE_OPTIONS } from '../../enums'; // ExchangeFilter.status is typed as `String` on the backend (no @Schema // allowableValues yet) so options stay hand-typed. Follow-up: annotate the diff --git a/ui/src/pages/Alerts/RuleEditor/condition-forms/JvmMetricForm.tsx b/ui/src/pages/Alerts/RuleEditor/condition-forms/JvmMetricForm.tsx index 59d463da..794dbfb0 100644 --- a/ui/src/pages/Alerts/RuleEditor/condition-forms/JvmMetricForm.tsx +++ b/ui/src/pages/Alerts/RuleEditor/condition-forms/JvmMetricForm.tsx @@ -1,6 +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'; +import { JVM_AGGREGATION_OPTIONS, COMPARATOR_OPTIONS } from '../../enums'; export function JvmMetricForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) { const c = form.condition as Record; diff --git a/ui/src/pages/Alerts/RuleEditor/condition-forms/RouteMetricForm.tsx b/ui/src/pages/Alerts/RuleEditor/condition-forms/RouteMetricForm.tsx index 515e7fa0..65ab87cb 100644 --- a/ui/src/pages/Alerts/RuleEditor/condition-forms/RouteMetricForm.tsx +++ b/ui/src/pages/Alerts/RuleEditor/condition-forms/RouteMetricForm.tsx @@ -1,6 +1,6 @@ import { FormField, Input, Select } from '@cameleer/design-system'; import type { FormState } from '../form-state'; -import { ROUTE_METRIC_OPTIONS, COMPARATOR_OPTIONS } from '../../../../api/alerting-enums'; +import { ROUTE_METRIC_OPTIONS, COMPARATOR_OPTIONS } from '../../enums'; export function RouteMetricForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) { const c = form.condition as Record; diff --git a/ui/src/pages/Alerts/RuleEditor/form-state.ts b/ui/src/pages/Alerts/RuleEditor/form-state.ts index 71176fdb..3de5b9d5 100644 --- a/ui/src/pages/Alerts/RuleEditor/form-state.ts +++ b/ui/src/pages/Alerts/RuleEditor/form-state.ts @@ -1,10 +1,9 @@ import type { AlertRuleRequest, AlertRuleResponse, - ConditionKind, AlertCondition, } from '../../../api/queries/alertRules'; -import type { Severity, TargetKind } from '../../../api/alerting-enums'; +import type { ConditionKind, Severity, TargetKind } from '../enums'; export type WizardStep = 'scope' | 'condition' | 'trigger' | 'notify' | 'review'; export const WIZARD_STEPS: WizardStep[] = ['scope', 'condition', 'trigger', 'notify', 'review']; diff --git a/ui/src/pages/Alerts/enums.test.ts b/ui/src/pages/Alerts/enums.test.ts new file mode 100644 index 00000000..0e5f2584 --- /dev/null +++ b/ui/src/pages/Alerts/enums.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { + CONDITION_KIND_OPTIONS, + SEVERITY_OPTIONS, + ROUTE_METRIC_OPTIONS, + COMPARATOR_OPTIONS, + JVM_AGGREGATION_OPTIONS, + EXCHANGE_FIRE_MODE_OPTIONS, + TARGET_KIND_OPTIONS, +} from './enums'; + +/** + * Snapshot tests for the schema-derived option arrays. + * + * A backend enum change (or a hide-list change) will flip one of these + * snapshots. Reviewing the diff in a PR is the intended signal to confirm + * the change is intentional. The `Record` label maps already + * enforce exhaustiveness at compile time; these tests enforce the *visible* + * shape (values, order, hidden items) at review time. + */ + +describe('alerts/enums option arrays', () => { + it('CONDITION_KIND_OPTIONS', () => { + expect(CONDITION_KIND_OPTIONS).toEqual([ + { 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)' }, + ]); + }); + + it('SEVERITY_OPTIONS', () => { + expect(SEVERITY_OPTIONS).toEqual([ + { value: 'CRITICAL', label: 'Critical' }, + { value: 'WARNING', label: 'Warning' }, + { value: 'INFO', label: 'Info' }, + ]); + }); + + it('ROUTE_METRIC_OPTIONS', () => { + expect(ROUTE_METRIC_OPTIONS).toEqual([ + { 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' }, + ]); + }); + + it('COMPARATOR_OPTIONS (EQ is hidden — noisy for float comparisons)', () => { + expect(COMPARATOR_OPTIONS).toEqual([ + { value: 'GT', label: '>' }, + { value: 'GTE', label: '\u2265' }, + { value: 'LT', label: '<' }, + { value: 'LTE', label: '\u2264' }, + ]); + expect(COMPARATOR_OPTIONS.find((o) => o.value === 'EQ')).toBeUndefined(); + }); + + it('JVM_AGGREGATION_OPTIONS (LATEST is hidden — use a dashboard instead)', () => { + expect(JVM_AGGREGATION_OPTIONS).toEqual([ + { value: 'MAX', label: 'MAX' }, + { value: 'AVG', label: 'AVG' }, + { value: 'MIN', label: 'MIN' }, + ]); + expect(JVM_AGGREGATION_OPTIONS.find((o) => o.value === 'LATEST')).toBeUndefined(); + }); + + it('EXCHANGE_FIRE_MODE_OPTIONS', () => { + expect(EXCHANGE_FIRE_MODE_OPTIONS).toEqual([ + { value: 'PER_EXCHANGE', label: 'One alert per matching exchange' }, + { value: 'COUNT_IN_WINDOW', label: 'Threshold: N matches in window' }, + ]); + }); + + it('TARGET_KIND_OPTIONS', () => { + expect(TARGET_KIND_OPTIONS).toEqual([ + { value: 'USER', label: 'User' }, + { value: 'GROUP', label: 'Group' }, + { value: 'ROLE', label: 'Role' }, + ]); + }); +}); diff --git a/ui/src/pages/Alerts/enums.ts b/ui/src/pages/Alerts/enums.ts new file mode 100644 index 00000000..585141ae --- /dev/null +++ b/ui/src/pages/Alerts/enums.ts @@ -0,0 +1,149 @@ +/** + * Alerting option lists and enum types used by the rule editor. + * + * These are **string-literal unions mirrored by hand from the backend Java + * enums**. Why not derived from `schema.d.ts`? Springdoc emits polymorphic + * `@JsonSubTypes` conditions with two conflicting `kind` discriminators (the + * class-name literal + the Jackson enum), whose intersection is `never` — + * indexed access like `RouteMetricCondition['comparator']` resolves to + * `never` and silently breaks `Record` exhaustiveness. Until the + * OpenAPI shape is fixed upstream, we declare the unions here. + * + * How to keep this file honest: + * 1. Regenerate the schema (`npm run generate-api:live` or `generate-api`). + * 2. Search `src/api/schema.d.ts` for the enum unions on the relevant + * condition type (e.g. `comparator?: "GT" | "GTE" | …`). + * 3. Update the matching union below. + * 4. `enums.test.ts` dumps every exported `*_OPTIONS` array as an inline + * snapshot — the diff in a PR shows exactly which values / labels moved. + * + * Fields whose backend type is `String` (agent state, log level, deployment + * states, exchange filter status, JVM metric names) aren't in here at all — + * springdoc emits them as open-ended strings. Follow-up: add + * `@Schema(allowableValues = …)` on the Java record components, then migrate + * those string literals into this file too. + * + * Condition-kind-wide enums (ConditionKind, Severity, TargetKind) are + * derived from `schema.d.ts`, because they appear on flat fields that + * springdoc types correctly. + */ +import type { components } from '../../api/schema'; + +type AlertRuleRequest = components['schemas']['AlertRuleRequest']; +type AlertRuleTarget = components['schemas']['AlertRuleTarget']; + +// Derived — these schema fields are correctly typed (flat, no polymorphism). +export type ConditionKind = NonNullable; +export type Severity = NonNullable; +export type TargetKind = NonNullable; + +// Manual — schema's polymorphic condition types resolve to `never`. +// Mirrors: cameleer-server-core RouteMetric, Comparator, AggregationOp enums +// and ExchangeMatchCondition.fireMode. +export type RouteMetric = 'ERROR_RATE' | 'AVG_DURATION_MS' | 'P99_LATENCY_MS' | 'THROUGHPUT' | 'ERROR_COUNT'; +export type Comparator = 'GT' | 'GTE' | 'LT' | 'LTE' | 'EQ'; +export type JvmAggregation = 'MAX' | 'MIN' | 'AVG' | 'LATEST'; +export type ExchangeFireMode = 'PER_EXCHANGE' | 'COUNT_IN_WINDOW'; + +export interface Option { value: T; label: string } + +/** + * Build a dropdown option array from a `Record` label map. + * + * Declaration order is load-bearing: ES2015+ guarantees `Object.keys` yields + * string-keyed entries in insertion order, which is what the user sees. + * + * Hidden values stay in the `Record` map (so `Record`-exhaustiveness + * still pins labels to the union) and in the type `T` (so rules carrying a + * hidden value round-trip fine through save/load) — they're simply filtered + * out of the visible option array. + */ +function toOptions(labels: Record, hidden?: readonly T[]): Option[] { + const skip: ReadonlySet = new Set(hidden ?? []); + return (Object.keys(labels) as T[]) + .filter((value) => !skip.has(value)) + .map((value) => ({ value, label: labels[value] })); +} + +// --------------------------------------------------------------------------- +// Label maps — declaration order = dropdown order. +// --------------------------------------------------------------------------- + +const CONDITION_KIND_LABELS: Record = { + ROUTE_METRIC: 'Route metric (error rate, latency, throughput)', + EXCHANGE_MATCH: 'Exchange match (specific failures)', + AGENT_STATE: 'Agent state (DEAD / STALE)', + DEPLOYMENT_STATE: 'Deployment state (FAILED / DEGRADED)', + LOG_PATTERN: 'Log pattern (count of matching logs)', + JVM_METRIC: 'JVM metric (heap, GC, inflight)', +}; + +const SEVERITY_LABELS: Record = { + CRITICAL: 'Critical', + WARNING: 'Warning', + INFO: 'Info', +}; + +const ROUTE_METRIC_LABELS: Record = { + ERROR_RATE: 'Error rate', + P99_LATENCY_MS: 'P99 latency (ms)', + AVG_DURATION_MS: 'Avg duration (ms)', + THROUGHPUT: 'Throughput (msg/s)', + ERROR_COUNT: 'Error count', +}; + +const COMPARATOR_LABELS: Record = { + GT: '>', + GTE: '\u2265', + LT: '<', + LTE: '\u2264', + EQ: '=', +}; + +// Previous UI ordering (MAX, AVG, MIN) preserved; LATEST is declared last +// and hidden — see comment on JVM_AGGREGATION_HIDDEN below. +const JVM_AGGREGATION_LABELS: Record = { + MAX: 'MAX', + AVG: 'AVG', + MIN: 'MIN', + LATEST: 'LATEST', +}; + +const EXCHANGE_FIRE_MODE_LABELS: Record = { + PER_EXCHANGE: 'One alert per matching exchange', + COUNT_IN_WINDOW: 'Threshold: N matches in window', +}; + +const TARGET_KIND_LABELS: Record = { + USER: 'User', + GROUP: 'Group', + ROLE: 'Role', +}; + +// --------------------------------------------------------------------------- +// Hidden values — legal on the wire (saved/loaded rules round-trip fine) but +// intentionally not surfaced in dropdowns. Document *why* — silent omission +// is exactly what caused the original drift problem. +// --------------------------------------------------------------------------- + +const COMPARATOR_HIDDEN: readonly Comparator[] = [ + 'EQ', // Exact equality on floating-point metrics is rarely useful and noisy + // — users should pick GT/LT with a sensible threshold instead. +]; + +const JVM_AGGREGATION_HIDDEN: readonly JvmAggregation[] = [ + 'LATEST', // Point-in-time reads belong on a metric dashboard, not an alert + // rule — a windowed MAX/MIN/AVG is what you want for alerting. +]; + +// --------------------------------------------------------------------------- +// Exported option arrays (visible in dropdowns). +// --------------------------------------------------------------------------- + +export const CONDITION_KIND_OPTIONS: Option[] = toOptions(CONDITION_KIND_LABELS); +export const SEVERITY_OPTIONS: Option[] = toOptions(SEVERITY_LABELS); +export const ROUTE_METRIC_OPTIONS: Option[] = toOptions(ROUTE_METRIC_LABELS); +export const COMPARATOR_OPTIONS: Option[] = toOptions(COMPARATOR_LABELS, COMPARATOR_HIDDEN); +export const JVM_AGGREGATION_OPTIONS: Option[] = toOptions(JVM_AGGREGATION_LABELS, JVM_AGGREGATION_HIDDEN); +export const EXCHANGE_FIRE_MODE_OPTIONS: Option[] = toOptions(EXCHANGE_FIRE_MODE_LABELS); +export const TARGET_KIND_OPTIONS: Option[] = toOptions(TARGET_KIND_LABELS);