diff --git a/ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx b/ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx
new file mode 100644
index 00000000..a22fe75a
--- /dev/null
+++ b/ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx
@@ -0,0 +1,5 @@
+import type { FormState } from './form-state';
+
+export function ConditionStep({ form: _form, setForm: _setForm }: { form: FormState; setForm: (f: FormState) => void }) {
+ return
Condition step — TODO Task 21
;
+}
diff --git a/ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx b/ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx
new file mode 100644
index 00000000..59d4d853
--- /dev/null
+++ b/ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx
@@ -0,0 +1,13 @@
+import type { FormState } from './form-state';
+
+export function NotifyStep({
+ form: _form,
+ setForm: _setForm,
+ ruleId: _ruleId,
+}: {
+ form: FormState;
+ setForm: (f: FormState) => void;
+ ruleId?: string;
+}) {
+ return Notify step — TODO Task 23
;
+}
diff --git a/ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx b/ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx
new file mode 100644
index 00000000..6801e71a
--- /dev/null
+++ b/ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx
@@ -0,0 +1,11 @@
+import type { FormState } from './form-state';
+
+export function ReviewStep({
+ form: _form,
+ setForm: _setForm,
+}: {
+ form: FormState;
+ setForm?: (f: FormState) => void;
+}) {
+ return Review step — TODO Task 24
;
+}
diff --git a/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx b/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx
index 26c55fc7..84ab1605 100644
--- a/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx
+++ b/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx
@@ -1,3 +1,154 @@
+import { useEffect, useState } from 'react';
+import { useNavigate, useParams, useSearchParams } from 'react-router';
+import { Button, SectionHeader, useToast } from '@cameleer/design-system';
+import { PageLoader } from '../../../components/PageLoader';
+import {
+ useAlertRule,
+ useCreateAlertRule,
+ useUpdateAlertRule,
+} from '../../../api/queries/alertRules';
+import {
+ initialForm,
+ toRequest,
+ validateStep,
+ WIZARD_STEPS,
+ type FormState,
+ type WizardStep,
+} from './form-state';
+import { ScopeStep } from './ScopeStep';
+import { ConditionStep } from './ConditionStep';
+import { TriggerStep } from './TriggerStep';
+import { NotifyStep } from './NotifyStep';
+import { ReviewStep } from './ReviewStep';
+import { prefillFromPromotion } from './promotion-prefill';
+import css from './wizard.module.css';
+
+const STEP_LABELS: Record = {
+ scope: '1. Scope',
+ condition: '2. Condition',
+ trigger: '3. Trigger',
+ notify: '4. Notify',
+ review: '5. Review',
+};
+
export default function RuleEditorWizard() {
- return RuleEditorWizard — coming soon
;
+ const navigate = useNavigate();
+ const { id } = useParams<{ id?: string }>();
+ const [search] = useSearchParams();
+ const { toast } = useToast();
+
+ const isEdit = !!id;
+ const existingQuery = useAlertRule(isEdit ? id : undefined);
+
+ // Promotion prefill uses a separate query against the source env. In
+ // Plan 03 we reuse `useAlertRule` (current-env scoped); cross-env
+ // fetching is handled server-side in the promote query hook when wired.
+ const promoteFrom = search.get('promoteFrom') ?? undefined;
+ const promoteRuleId = search.get('ruleId') ?? undefined;
+ const sourceRuleQuery = useAlertRule(promoteFrom ? promoteRuleId : undefined);
+
+ const [step, setStep] = useState('scope');
+ const [form, setForm] = useState(null);
+
+ // Initialize form once the existing or source rule loads.
+ useEffect(() => {
+ if (form) return;
+ if (isEdit && existingQuery.data) {
+ setForm(initialForm(existingQuery.data));
+ return;
+ }
+ if (promoteFrom && sourceRuleQuery.data) {
+ setForm(prefillFromPromotion(sourceRuleQuery.data));
+ return;
+ }
+ if (!isEdit && !promoteFrom) {
+ setForm(initialForm());
+ }
+ }, [form, isEdit, existingQuery.data, promoteFrom, sourceRuleQuery.data]);
+
+ const create = useCreateAlertRule();
+ const update = useUpdateAlertRule(id ?? '');
+
+ if (!form) return ;
+
+ const idx = WIZARD_STEPS.indexOf(step);
+ const errors = validateStep(step, form);
+
+ const onNext = () => {
+ if (errors.length > 0) {
+ toast({ title: 'Fix validation errors before continuing', description: errors.join(' \u00b7 '), variant: 'error' });
+ return;
+ }
+ if (idx < WIZARD_STEPS.length - 1) setStep(WIZARD_STEPS[idx + 1]);
+ };
+ const onBack = () => {
+ if (idx > 0) setStep(WIZARD_STEPS[idx - 1]);
+ };
+
+ const onSave = async () => {
+ try {
+ if (isEdit) {
+ await update.mutateAsync(toRequest(form));
+ toast({ title: 'Rule updated', description: form.name, variant: 'success' });
+ } else {
+ await create.mutateAsync(toRequest(form));
+ toast({ title: 'Rule created', description: form.name, variant: 'success' });
+ }
+ navigate('/alerts/rules');
+ } catch (e) {
+ toast({ title: 'Save failed', description: String(e), variant: 'error' });
+ }
+ };
+
+ const body =
+ step === 'scope' ? (
+
+ ) : step === 'condition' ? (
+
+ ) : step === 'trigger' ? (
+
+ ) : step === 'notify' ? (
+
+ ) : (
+
+ );
+
+ return (
+
+
+
{isEdit ? `Edit rule: ${form.name}` : 'New alert rule'}
+ {promoteFrom && (
+
+ Promoting from {promoteFrom} — review and adjust, then save.
+
+ )}
+
+
+
{body}
+
+
+ {idx < WIZARD_STEPS.length - 1 ? (
+
+ ) : (
+
+ )}
+
+
+ );
}
diff --git a/ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx b/ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx
new file mode 100644
index 00000000..649457f0
--- /dev/null
+++ b/ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx
@@ -0,0 +1,5 @@
+import type { FormState } from './form-state';
+
+export function ScopeStep({ form: _form, setForm: _setForm }: { form: FormState; setForm: (f: FormState) => void }) {
+ return Scope step — TODO Task 20
;
+}
diff --git a/ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx b/ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx
new file mode 100644
index 00000000..7ab844be
--- /dev/null
+++ b/ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx
@@ -0,0 +1,13 @@
+import type { FormState } from './form-state';
+
+export function TriggerStep({
+ form: _form,
+ setForm: _setForm,
+ ruleId: _ruleId,
+}: {
+ form: FormState;
+ setForm: (f: FormState) => void;
+ ruleId?: string;
+}) {
+ return Trigger step — TODO Task 22
;
+}
diff --git a/ui/src/pages/Alerts/RuleEditor/form-state.test.ts b/ui/src/pages/Alerts/RuleEditor/form-state.test.ts
new file mode 100644
index 00000000..40e68df8
--- /dev/null
+++ b/ui/src/pages/Alerts/RuleEditor/form-state.test.ts
@@ -0,0 +1,50 @@
+import { describe, it, expect } from 'vitest';
+import { initialForm, toRequest, validateStep } from './form-state';
+
+describe('initialForm', () => {
+ it('defaults to env-wide ROUTE_METRIC with safe intervals', () => {
+ const f = initialForm();
+ expect(f.scopeKind).toBe('env');
+ expect(f.conditionKind).toBe('ROUTE_METRIC');
+ expect(f.evaluationIntervalSeconds).toBeGreaterThanOrEqual(5);
+ expect(f.enabled).toBe(true);
+ });
+});
+
+describe('toRequest', () => {
+ it('strips empty scope fields for env-wide rules', () => {
+ const f = initialForm();
+ f.name = 'test';
+ const req = toRequest(f);
+ const scope = (req.condition as unknown as { scope: Record }).scope;
+ expect(scope.appSlug).toBeUndefined();
+ expect(scope.routeId).toBeUndefined();
+ expect(scope.agentId).toBeUndefined();
+ });
+
+ it('includes appSlug for app/route/agent scopes', () => {
+ const f = initialForm();
+ f.scopeKind = 'app';
+ f.appSlug = 'orders';
+ const req = toRequest(f);
+ const scope = (req.condition as unknown as { scope: Record }).scope;
+ expect(scope.appSlug).toBe('orders');
+ });
+});
+
+describe('validateStep', () => {
+ it('flags blank name on scope step', () => {
+ expect(validateStep('scope', initialForm())).toContain('Name is required.');
+ });
+ it('flags app requirement for app-scope', () => {
+ const f = initialForm();
+ f.name = 'x';
+ f.scopeKind = 'app';
+ expect(validateStep('scope', f).some((e) => /App is required/.test(e))).toBe(true);
+ });
+ it('flags intervals below floor on trigger step', () => {
+ const f = initialForm();
+ f.evaluationIntervalSeconds = 1;
+ expect(validateStep('trigger', f).some((e) => /Evaluation interval/.test(e))).toBe(true);
+ });
+});
diff --git a/ui/src/pages/Alerts/RuleEditor/form-state.ts b/ui/src/pages/Alerts/RuleEditor/form-state.ts
new file mode 100644
index 00000000..e6cee874
--- /dev/null
+++ b/ui/src/pages/Alerts/RuleEditor/form-state.ts
@@ -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;
+
+ 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,
+ 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,
+ 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)
+ .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 = {};
+ 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), 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;
+}
diff --git a/ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts b/ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts
new file mode 100644
index 00000000..4f3ec43d
--- /dev/null
+++ b/ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts
@@ -0,0 +1,15 @@
+import { initialForm, type FormState } from './form-state';
+import type { AlertRuleResponse } from '../../../api/queries/alertRules';
+
+/**
+ * Prefill the wizard form from a source rule being promoted from another env.
+ *
+ * Task 19 scaffolding: reuses the edit-prefill path and renames the rule.
+ * Task 24 rewrites this to compute scope-adjustment warnings and return
+ * `{ form, warnings }`.
+ */
+export function prefillFromPromotion(source: AlertRuleResponse): FormState {
+ const f = initialForm(source);
+ f.name = `${source.name ?? 'rule'} (copy)`;
+ return f;
+}
diff --git a/ui/src/pages/Alerts/RuleEditor/wizard.module.css b/ui/src/pages/Alerts/RuleEditor/wizard.module.css
new file mode 100644
index 00000000..4dfd103f
--- /dev/null
+++ b/ui/src/pages/Alerts/RuleEditor/wizard.module.css
@@ -0,0 +1,57 @@
+.wizard {
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.promoteBanner {
+ padding: 8px 12px;
+ background: var(--amber-bg, rgba(255, 180, 0, 0.12));
+ border: 1px solid var(--amber);
+ border-radius: 6px;
+ font-size: 13px;
+}
+
+.steps {
+ display: flex;
+ gap: 8px;
+ border-bottom: 1px solid var(--border);
+ padding-bottom: 8px;
+}
+
+.step {
+ background: none;
+ border: none;
+ padding: 8px 12px;
+ border-bottom: 2px solid transparent;
+ cursor: pointer;
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.stepActive {
+ color: var(--fg);
+ border-bottom-color: var(--accent);
+}
+
+.stepDone {
+ color: var(--fg);
+}
+
+.stepBody {
+ min-height: 320px;
+}
+
+.footer {
+ display: flex;
+ justify-content: space-between;
+}