refactor(ui/alerts): address code-review findings on alerting-enums
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m3s
CI / docker (push) Successful in 1m22s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s

Follow-up to 83837ada addressing the critical-review feedback:

- Duplicate ConditionKind type consolidated: the one in
  api/queries/alertRules.ts (which was nullable — wrong) is gone;
  single source of truth lives in this module.

- Module moved out of api/ into pages/Alerts/ where it belongs.
  api/ is the data layer; labels + hide lists are view-layer concerns.

- Hidden values formalised: Comparator.EQ and JvmAggregation.LATEST
  are intentionally not surfaced in dropdowns (noisy / wrong feature
  boundary, see in-file comments). They remain in the type unions so
  rules that carry those values save/load correctly — we just don't
  advertise them in the UI.

- JvmAggregation declaration order restored to MAX/AVG/MIN (matches
  what users saw before 83837ada). LATEST declared last; hidden.

- Snapshot tests for every visible *_OPTIONS array — reviewer signal
  in future PRs when a backend enum change or hide-list edit
  silently reshapes the dropdown.

- `toOptions` gains a JSDoc noting that label-map declaration order
  is load-bearing (ES2015 Object.keys insertion-order guarantee).

- **Honest about the springdoc schema quirk**: the generated
  polymorphic condition types resolve to `never` at the TypeScript
  level (two conflicting `kind` discriminators — the class-name
  literal and the Jackson enum — intersect to never), which silently
  defeated `Record<T, string>` exhaustiveness. The previous commit's
  "schema-derived enums" claim was accurate only for the flat-field
  enums (ConditionKind, Severity, TargetKind); condition-specific
  enums (RouteMetric, Comparator, JvmAggregation, ExchangeFireMode)
  were silently `never`. Those are now declared as hand-written
  string-literal unions with a top-of-file comment spelling out the
  issue and the regen-and-compare workflow. Real upstream fix is a
  backend-side adjustment to how springdoc emits polymorphic
  `@JsonSubTypes` — out of scope for this phase.

Verified: ui build green, 56/56 vitest pass (49 pre-existing + 7
new enum snapshots).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-20 19:26:16 +02:00
parent 83837ada8f
commit e590682f8f
12 changed files with 244 additions and 119 deletions

View File

@@ -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<T, string>` — 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<AlertRuleRequest['conditionKind']>;
export type Severity = NonNullable<AlertRuleRequest['severity']>;
export type RouteMetric = NonNullable<RouteMetricCondition['metric']>;
export type Comparator = NonNullable<RouteMetricCondition['comparator']>;
export type JvmAggregation = NonNullable<JvmMetricCondition['aggregation']>;
export type ExchangeFireMode = NonNullable<ExchangeMatchCondition['fireMode']>;
export type TargetKind = NonNullable<AlertRuleTarget['kind']>;
export interface Option<T extends string> { value: T; label: string }
function toOptions<T extends string>(labels: Record<T, string>): Option<T>[] {
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<ConditionKind, string> = {
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<Severity, string> = {
CRITICAL: 'Critical',
WARNING: 'Warning',
INFO: 'Info',
};
const ROUTE_METRIC_LABELS: Record<RouteMetric, string> = {
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<Comparator, string> = {
GT: '>',
GTE: '\u2265',
LT: '<',
LTE: '\u2264',
EQ: '=',
};
const JVM_AGGREGATION_LABELS: Record<JvmAggregation, string> = {
MAX: 'MAX',
MIN: 'MIN',
AVG: 'AVG',
LATEST: 'LATEST',
};
const EXCHANGE_FIRE_MODE_LABELS: Record<ExchangeFireMode, string> = {
PER_EXCHANGE: 'One alert per matching exchange',
COUNT_IN_WINDOW: 'Threshold: N matches in window',
};
const TARGET_KIND_LABELS: Record<TargetKind, string> = {
USER: 'User',
GROUP: 'Group',
ROLE: 'Role',
};
// ---------------------------------------------------------------------------
// Exported option arrays (in label-map declaration order).
// ---------------------------------------------------------------------------
export const CONDITION_KIND_OPTIONS: Option<ConditionKind>[] = toOptions(CONDITION_KIND_LABELS);
export const SEVERITY_OPTIONS: Option<Severity>[] = toOptions(SEVERITY_LABELS);
export const ROUTE_METRIC_OPTIONS: Option<RouteMetric>[] = toOptions(ROUTE_METRIC_LABELS);
export const COMPARATOR_OPTIONS: Option<Comparator>[] = toOptions(COMPARATOR_LABELS);
export const JVM_AGGREGATION_OPTIONS: Option<JvmAggregation>[] = toOptions(JVM_AGGREGATION_LABELS);
export const EXCHANGE_FIRE_MODE_OPTIONS:Option<ExchangeFireMode>[] = toOptions(EXCHANGE_FIRE_MODE_LABELS);
export const TARGET_KIND_OPTIONS: Option<TargetKind>[] = toOptions(TARGET_KIND_LABELS);

View File

@@ -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