refactor(ui/alerts): derive option lists + form-state types from schema.d.ts
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:
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **cameleer-server** (8524 symbols, 22174 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **cameleer-server** (8527 symbols, 22174 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **cameleer-server** (8524 symbols, 22174 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **cameleer-server** (8527 symbols, 22174 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
109
ui/src/api/alerting-enums.ts
Normal file
109
ui/src/api/alerting-enums.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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);
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ?? '',
|
||||
})),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user