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 }; }