Files
cameleer-server/ui/src/pages/Alerts/RuleEditor/form-state.ts
hsiegeln efa8390108
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m2s
CI / docker (push) Successful in 1m20s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 37s
SonarQube / sonarqube (push) Successful in 5m31s
fix(alerting): reject null fireMode on ExchangeMatchCondition + repair in-flight rows
The rule editor wizard reset the condition payload on kind-change without
seeding a fireMode default; the ExchangeMatchCondition ctor allowed null to
pass through; AlertEvaluatorJob then NPE-looped every tick on a saved rule.

- core: compact ctor now rejects null fireMode (Jackson-deser path only — all
  production callers already pass a value).
- V14: repair existing EXCHANGE_MATCH rows with fireMode=null to
  PER_EXCHANGE + perExchangeLingerSeconds=300 (default matches the wizard).
- ui: ConditionStep.onKindChange seeds EXCHANGE_MATCH defaults so the
  Select's displayed fallback ("Per exchange") is actually in form state.
- ui: validateStep('condition', ...) now enforces fireMode presence + the
  mode-specific fields before the user reaches Review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:05:55 +02:00

175 lines
6.7 KiB
TypeScript

import type {
AlertRuleRequest,
AlertRuleResponse,
AlertCondition,
} from '../../../api/queries/alertRules';
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'];
export interface FormState {
name: string;
description: string;
severity: Severity;
enabled: boolean;
// Scope (radio: env-wide | app | route | agent)
scopeKind: 'env' | 'app' | 'route' | 'agent';
appSlug: string;
routeId: string;
agentId: string;
conditionKind: ConditionKind;
condition: Partial<AlertCondition>;
evaluationIntervalSeconds: number;
forDurationSeconds: number;
reNotifyMinutes: number;
notificationTitleTmpl: string;
notificationMessageTmpl: string;
webhooks: Array<{
outboundConnectionId: string;
bodyOverride: string;
headerOverrides: Array<{ key: string; value: string }>;
}>;
targets: Array<{ kind: TargetKind; targetId: string }>;
}
export function initialForm(existing?: AlertRuleResponse): FormState {
if (!existing) {
return {
name: '',
description: '',
severity: 'WARNING',
enabled: true,
scopeKind: 'env',
appSlug: '',
routeId: '',
agentId: '',
conditionKind: 'ROUTE_METRIC',
// Pre-populate a valid ROUTE_METRIC default so a rule can be saved without
// the user needing to fill in every condition field. Values chosen to be
// sane for "error rate" alerts on almost any route.
condition: {
kind: 'ROUTE_METRIC',
scope: {},
metric: 'ERROR_RATE',
comparator: 'GT',
threshold: 0.05,
windowSeconds: 300,
} as unknown as Partial<AlertCondition>,
evaluationIntervalSeconds: 60,
forDurationSeconds: 0,
reNotifyMinutes: 60,
notificationTitleTmpl: '{{rule.name}} is firing',
notificationMessageTmpl: 'Alert {{alert.id}} fired at {{alert.firedAt}}',
webhooks: [],
targets: [],
};
}
const scope = ((existing.condition as { scope?: { appSlug?: string; routeId?: string; agentId?: string } } | undefined)?.scope) ?? {};
const scopeKind: FormState['scopeKind'] = scope.agentId
? 'agent'
: scope.routeId
? 'route'
: scope.appSlug
? 'app'
: 'env';
return {
name: existing.name ?? '',
description: existing.description ?? '',
severity: (existing.severity ?? 'WARNING') as FormState['severity'],
enabled: existing.enabled ?? true,
scopeKind,
appSlug: scope.appSlug ?? '',
routeId: scope.routeId ?? '',
agentId: scope.agentId ?? '',
conditionKind: (existing.conditionKind ?? 'ROUTE_METRIC') as ConditionKind,
condition: (existing.condition ?? { kind: existing.conditionKind }) as Partial<AlertCondition>,
evaluationIntervalSeconds: existing.evaluationIntervalSeconds ?? 60,
forDurationSeconds: existing.forDurationSeconds ?? 0,
reNotifyMinutes: existing.reNotifyMinutes ?? 60,
notificationTitleTmpl: existing.notificationTitleTmpl ?? '{{rule.name}} is firing',
notificationMessageTmpl: existing.notificationMessageTmpl ?? 'Alert {{alert.id}} fired at {{alert.firedAt}}',
webhooks: (existing.webhooks ?? []).map((w) => ({
outboundConnectionId: (w.outboundConnectionId ?? '') as string,
bodyOverride: w.bodyOverride ?? '',
headerOverrides: Object.entries((w.headerOverrides ?? {}) as Record<string, string>)
.map(([key, value]) => ({ key, value })),
})),
targets: (existing.targets ?? []).map((t) => ({
kind: (t.kind ?? 'USER') as TargetKind,
targetId: t.targetId ?? '',
})),
};
}
export function toRequest(f: FormState): AlertRuleRequest {
const scope: Record<string, string | undefined> = {};
if (f.scopeKind === 'app' || f.scopeKind === 'route' || f.scopeKind === 'agent') scope.appSlug = f.appSlug || undefined;
if (f.scopeKind === 'route') scope.routeId = f.routeId || undefined;
if (f.scopeKind === 'agent') scope.agentId = f.agentId || undefined;
const condition = { ...(f.condition as Record<string, unknown>), kind: f.conditionKind, scope } as unknown as AlertCondition;
return {
name: f.name,
description: f.description || undefined,
severity: f.severity,
enabled: f.enabled,
conditionKind: f.conditionKind,
condition,
evaluationIntervalSeconds: f.evaluationIntervalSeconds,
forDurationSeconds: f.forDurationSeconds,
reNotifyMinutes: f.reNotifyMinutes,
notificationTitleTmpl: f.notificationTitleTmpl,
notificationMessageTmpl: f.notificationMessageTmpl,
webhooks: f.webhooks.map((w) => ({
outboundConnectionId: w.outboundConnectionId,
bodyOverride: w.bodyOverride || undefined,
headerOverrides: Object.fromEntries(w.headerOverrides.filter((h) => h.key.trim()).map((h) => [h.key.trim(), h.value])),
})),
targets: f.targets.map((t) => ({ kind: t.kind, targetId: t.targetId })),
} as AlertRuleRequest;
}
export function validateStep(step: WizardStep, f: FormState): string[] {
const errs: string[] = [];
if (step === 'scope') {
if (!f.name.trim()) errs.push('Name is required.');
if (f.scopeKind !== 'env' && !f.appSlug.trim()) errs.push('App is required for app/route/agent scope.');
if (f.scopeKind === 'route' && !f.routeId.trim()) errs.push('Route id is required for route scope.');
if (f.scopeKind === 'agent' && !f.agentId.trim()) errs.push('Agent id is required for agent scope.');
}
if (step === 'condition') {
if (!f.conditionKind) errs.push('Condition kind is required.');
if (f.conditionKind === 'EXCHANGE_MATCH') {
const c = f.condition as Record<string, unknown>;
if (!c.fireMode) {
errs.push('Fire mode is required.');
} else if (c.fireMode === 'PER_EXCHANGE') {
if (c.perExchangeLingerSeconds == null) {
errs.push('Linger seconds is required for PER_EXCHANGE.');
}
} else if (c.fireMode === 'COUNT_IN_WINDOW') {
if (c.threshold == null) errs.push('Threshold is required for COUNT_IN_WINDOW.');
if (c.windowSeconds == null) errs.push('Window (seconds) is required for COUNT_IN_WINDOW.');
}
}
}
if (step === 'trigger') {
if (f.evaluationIntervalSeconds < 5) errs.push('Evaluation interval must be \u2265 5 s.');
if (f.forDurationSeconds < 0) errs.push('For-duration must be \u2265 0.');
if (f.reNotifyMinutes < 0) errs.push('Re-notify cadence must be \u2265 0.');
}
if (step === 'notify') {
if (!f.notificationTitleTmpl.trim()) errs.push('Notification title template is required.');
if (!f.notificationMessageTmpl.trim()) errs.push('Notification message template is required.');
}
return errs;
}