feat(ui/alerts): rule editor wizard shell + form-state module

Wizard navigates 5 steps (scope/condition/trigger/notify/review) with
per-step validation. form-state module is the single source of truth for
the rule form; initialForm/toRequest/validateStep are unit-tested (6
tests). Step components are stubbed and will be implemented in Tasks
20-24. prefillFromPromotion is a thin wrapper in this commit; Task 24
rewrites it to compute scope-adjustment warnings.

Deviation notes:
 - FormState.targets uses {kind, targetId} to match AlertRuleTarget DTO
   field names (plan draft had targetKind).
 - toRequest casts through Record<string, unknown> so the spread over
   the Partial<AlertCondition> union typechecks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-20 13:57:30 +02:00
parent 7e91459cd6
commit 334e815c25
10 changed files with 472 additions and 1 deletions

View File

@@ -0,0 +1,151 @@
import type {
AlertRuleRequest,
AlertRuleResponse,
ConditionKind,
AlertCondition,
} from '../../../api/queries/alertRules';
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: 'CRITICAL' | 'WARNING' | 'INFO';
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: 'USER' | 'GROUP' | 'ROLE'; 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',
condition: { kind: 'ROUTE_METRIC' } 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 'USER' | 'GROUP' | 'ROLE',
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 (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;
}