fix(alerting): reject null fireMode on ExchangeMatchCondition + repair in-flight rows
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

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>
This commit is contained in:
hsiegeln
2026-04-20 20:05:55 +02:00
parent e590682f8f
commit efa8390108
7 changed files with 107 additions and 1 deletions

View File

@@ -14,10 +14,19 @@ export function ConditionStep({ form, setForm }: { form: FormState; setForm: (f:
// Reset the condition payload so stale fields from a previous kind don't leak
// into the save request. Preserve scope — it's managed on the scope step.
const prev = form.condition as Record<string, unknown>;
const base: Record<string, unknown> = { kind, scope: prev.scope };
// EXCHANGE_MATCH must carry a fireMode — the backend ctor now rejects null.
// Seed PER_EXCHANGE + 300s linger so a user can save without touching every
// sub-field (matches what the form's Select displays by default).
if (kind === 'EXCHANGE_MATCH') {
base.fireMode = 'PER_EXCHANGE';
base.perExchangeLingerSeconds = 300;
base.filter = {};
}
setForm({
...form,
conditionKind: kind,
condition: { kind, scope: prev.scope } as FormState['condition'],
condition: base as FormState['condition'],
});
};

View File

@@ -47,4 +47,37 @@ describe('validateStep', () => {
f.evaluationIntervalSeconds = 1;
expect(validateStep('trigger', f).some((e) => /Evaluation interval/.test(e))).toBe(true);
});
it('flags missing fireMode on EXCHANGE_MATCH condition step', () => {
const f = initialForm();
f.conditionKind = 'EXCHANGE_MATCH';
// Simulate the old bug: condition payload without a fireMode.
f.condition = { kind: 'EXCHANGE_MATCH', scope: {} } as typeof f.condition;
expect(validateStep('condition', f).some((e) => /Fire mode is required/.test(e))).toBe(true);
});
it('flags missing threshold/window on COUNT_IN_WINDOW', () => {
const f = initialForm();
f.conditionKind = 'EXCHANGE_MATCH';
f.condition = {
kind: 'EXCHANGE_MATCH',
scope: {},
fireMode: 'COUNT_IN_WINDOW',
} as unknown as typeof f.condition;
const errs = validateStep('condition', f);
expect(errs.some((e) => /Threshold is required/.test(e))).toBe(true);
expect(errs.some((e) => /Window/.test(e))).toBe(true);
});
it('passes when EXCHANGE_MATCH PER_EXCHANGE has linger seconds', () => {
const f = initialForm();
f.conditionKind = 'EXCHANGE_MATCH';
f.condition = {
kind: 'EXCHANGE_MATCH',
scope: {},
fireMode: 'PER_EXCHANGE',
perExchangeLingerSeconds: 300,
} as unknown as typeof f.condition;
expect(validateStep('condition', f)).toEqual([]);
});
});

View File

@@ -147,6 +147,19 @@ export function validateStep(step: WizardStep, f: FormState): string[] {
}
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.');