diff --git a/ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx b/ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx
index 6801e71a..bda16fd5 100644
--- a/ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx
+++ b/ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx
@@ -1,11 +1,62 @@
-import type { FormState } from './form-state';
+import { Toggle } from '@cameleer/design-system';
+import { toRequest, type FormState } from './form-state';
export function ReviewStep({
- form: _form,
- setForm: _setForm,
+ form,
+ setForm,
}: {
form: FormState;
setForm?: (f: FormState) => void;
}) {
- return
Review step — TODO Task 24
;
+ const req = toRequest(form);
+ return (
+
+
+ Name: {form.name}
+
+
+ Severity: {form.severity}
+
+
+ Scope: {form.scopeKind}
+ {form.scopeKind !== 'env' &&
+ ` (app=${form.appSlug}${form.routeId ? `, route=${form.routeId}` : ''}${form.agentId ? `, agent=${form.agentId}` : ''})`}
+
+
+ Condition kind: {form.conditionKind}
+
+
+ Intervals: eval {form.evaluationIntervalSeconds}s · for {form.forDurationSeconds}s · re-notify {form.reNotifyMinutes}m
+
+
+ Targets: {form.targets.length}
+
+
+ Webhooks: {form.webhooks.length}
+
+ {setForm && (
+
+ setForm({ ...form, enabled: e.target.checked })}
+ label="Enabled on save"
+ />
+
+ )}
+
+ Raw request JSON
+
+ {JSON.stringify(req, null, 2)}
+
+
+
+ );
}
diff --git a/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx b/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx
index 84ab1605..250f5bdc 100644
--- a/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx
+++ b/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx
@@ -58,7 +58,8 @@ export default function RuleEditorWizard() {
return;
}
if (promoteFrom && sourceRuleQuery.data) {
- setForm(prefillFromPromotion(sourceRuleQuery.data));
+ const { form: prefilled } = prefillFromPromotion(sourceRuleQuery.data);
+ setForm(prefilled);
return;
}
if (!isEdit && !promoteFrom) {
diff --git a/ui/src/pages/Alerts/RuleEditor/promotion-prefill.test.ts b/ui/src/pages/Alerts/RuleEditor/promotion-prefill.test.ts
new file mode 100644
index 00000000..fa56d760
--- /dev/null
+++ b/ui/src/pages/Alerts/RuleEditor/promotion-prefill.test.ts
@@ -0,0 +1,74 @@
+import { describe, it, expect } from 'vitest';
+import { prefillFromPromotion } from './promotion-prefill';
+import type { AlertRuleResponse } from '../../../api/queries/alertRules';
+
+function fakeRule(overrides: Partial = {}): AlertRuleResponse {
+ return {
+ id: '11111111-1111-1111-1111-111111111111',
+ environmentId: '22222222-2222-2222-2222-222222222222',
+ name: 'High error rate',
+ description: undefined,
+ severity: 'CRITICAL',
+ enabled: true,
+ conditionKind: 'ROUTE_METRIC',
+ condition: {
+ kind: 'RouteMetricCondition',
+ scope: { appSlug: 'orders' },
+ } as unknown as AlertRuleResponse['condition'],
+ evaluationIntervalSeconds: 60,
+ forDurationSeconds: 0,
+ reNotifyMinutes: 60,
+ notificationTitleTmpl: '{{rule.name}}',
+ notificationMessageTmpl: 'msg',
+ webhooks: [],
+ targets: [],
+ createdAt: '2026-04-01T00:00:00Z',
+ createdBy: 'alice',
+ updatedAt: '2026-04-01T00:00:00Z',
+ updatedBy: 'alice',
+ ...overrides,
+ };
+}
+
+describe('prefillFromPromotion', () => {
+ it('appends "(copy)" to name', () => {
+ const { form } = prefillFromPromotion(fakeRule());
+ expect(form.name).toBe('High error rate (copy)');
+ });
+
+ it('warns + clears agentId when source rule is agent-scoped', () => {
+ const { form, warnings } = prefillFromPromotion(
+ fakeRule({
+ conditionKind: 'AGENT_STATE',
+ condition: {
+ kind: 'AgentStateCondition',
+ scope: { appSlug: 'orders', agentId: 'orders-0' },
+ state: 'DEAD',
+ forSeconds: 60,
+ } as unknown as AlertRuleResponse['condition'],
+ }),
+ );
+ expect(form.agentId).toBe('');
+ expect(warnings.find((w) => w.field === 'scope.agentId')).toBeTruthy();
+ });
+
+ it('warns if app does not exist in target env', () => {
+ const { warnings } = prefillFromPromotion(fakeRule(), { targetEnvAppSlugs: ['other-app'] });
+ expect(warnings.find((w) => w.field === 'scope.appSlug')).toBeTruthy();
+ });
+
+ it('warns if webhook connection is not allowed in target env', () => {
+ const rule = fakeRule({
+ webhooks: [
+ {
+ id: 'w1',
+ outboundConnectionId: 'conn-prod',
+ bodyOverride: undefined,
+ headerOverrides: {},
+ },
+ ],
+ });
+ const { warnings } = prefillFromPromotion(rule, { targetEnvAllowedConnectionIds: ['conn-dev'] });
+ expect(warnings.find((w) => w.field.startsWith('webhooks['))).toBeTruthy();
+ });
+});
diff --git a/ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts b/ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts
index 4f3ec43d..51e3c52f 100644
--- a/ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts
+++ b/ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts
@@ -1,15 +1,59 @@
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;
+export interface PrefillWarning {
+ field: string;
+ message: string;
+}
+
+export interface PrefillOptions {
+ targetEnvAppSlugs?: string[];
+ /** IDs of outbound connections allowed in the target env. */
+ targetEnvAllowedConnectionIds?: string[];
+}
+
+/**
+ * Client-side prefill when promoting a rule from another env. Emits warnings for
+ * fields that cross env boundaries (agent IDs, apps missing in target env,
+ * outbound connections not allowed in target env).
+ */
+export function prefillFromPromotion(
+ source: AlertRuleResponse,
+ opts: PrefillOptions = {},
+): { form: FormState; warnings: PrefillWarning[] } {
+ const form = initialForm(source);
+ form.name = `${source.name ?? 'rule'} (copy)`;
+ const warnings: PrefillWarning[] = [];
+
+ // Agent IDs are per-env, can't transfer.
+ if (form.agentId) {
+ warnings.push({
+ field: 'scope.agentId',
+ message: `Agent \`${form.agentId}\` is specific to the source env \u2014 cleared for target env.`,
+ });
+ form.agentId = '';
+ if (form.scopeKind === 'agent') form.scopeKind = 'app';
+ }
+
+ // App slug: warn if not present in target env.
+ if (form.appSlug && opts.targetEnvAppSlugs && !opts.targetEnvAppSlugs.includes(form.appSlug)) {
+ warnings.push({
+ field: 'scope.appSlug',
+ message: `App \`${form.appSlug}\` does not exist in the target env. Update before saving.`,
+ });
+ }
+
+ // Webhook connections: warn if connection is not allowed in target env.
+ if (opts.targetEnvAllowedConnectionIds) {
+ for (const w of form.webhooks) {
+ if (!opts.targetEnvAllowedConnectionIds.includes(w.outboundConnectionId)) {
+ warnings.push({
+ field: `webhooks[${w.outboundConnectionId}]`,
+ message: `Outbound connection is not allowed in the target env \u2014 remove or pick another before saving.`,
+ });
+ }
+ }
+ }
+
+ return { form, warnings };
}