(null);
+
+ // Initialize form once the existing or source rule loads.
+ const ready = useMemo(() => {
+ if (form) return true;
+ if (isEdit && existingQuery.data) {
+ setForm(initialForm(existingQuery.data));
+ return true;
+ }
+ if (promoteFrom && sourceRuleQuery.data) {
+ setForm(prefillFromPromotion(sourceRuleQuery.data));
+ return true;
+ }
+ if (!isEdit && !promoteFrom) {
+ setForm(initialForm());
+ return true;
+ }
+ return false;
+ }, [form, isEdit, existingQuery.data, promoteFrom, sourceRuleQuery.data]);
+
+ const create = useCreateAlertRule();
+ const update = useUpdateAlertRule(id ?? '');
+
+ if (!ready || !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(' · '), 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 ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Write the CSS module**
+
+```css
+/* ui/src/pages/Alerts/RuleEditor/wizard.module.css */
+.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; }
+```
+
+- [ ] **Step 5: Create step stubs so compile passes**
+
+`ScopeStep.tsx`, `ConditionStep.tsx`, `TriggerStep.tsx`, `NotifyStep.tsx`, `ReviewStep.tsx` each as:
+
+```tsx
+// ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx (repeat for each)
+import type { FormState } from './form-state';
+export function ScopeStep({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
+ return Scope step — TODO Task 20
;
+}
+```
+
+And `promotion-prefill.ts`:
+
+```ts
+// ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts
+import { initialForm, type FormState } from './form-state';
+import type { AlertRuleResponse } from '../../../api/queries/alertRules';
+
+export function prefillFromPromotion(source: AlertRuleResponse): FormState {
+ // Reuse the edit-prefill for now; Task 24 adds scope-adjustment + warnings.
+ const f = initialForm(source);
+ f.name = `${source.name} (copy)`;
+ return f;
+}
+```
+
+- [ ] **Step 6: TypeScript compile + commit**
+
+```bash
+cd ui && npx tsc -p tsconfig.app.json --noEmit
+cd ui && npm test -- form-state
+git add ui/src/pages/Alerts/RuleEditor/
+git commit -m "feat(ui/alerts): rule editor wizard shell + form-state module
+
+Wizard navigates 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. Step
+components are stubbed and implemented in Tasks 20–24."
+```
+
+---
+
+### Task 20: `ScopeStep`
+
+**Files:**
+- Replace: `ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx`
+
+- [ ] **Step 1: Implement the scope form**
+
+```tsx
+// ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx
+import { FormField, Input, Select } from '@cameleer/design-system';
+import { useCatalog } from '../../../api/queries/catalog';
+import { useAgents } from '../../../api/queries/agents';
+import { useSelectedEnv } from '../../../api/queries/alertMeta';
+import type { FormState } from './form-state';
+
+const SEVERITY_OPTIONS = [
+ { value: 'CRITICAL', label: 'Critical' },
+ { value: 'WARNING', label: 'Warning' },
+ { value: 'INFO', label: 'Info' },
+];
+
+const SCOPE_OPTIONS = [
+ { value: 'env', label: 'Environment-wide' },
+ { value: 'app', label: 'Single app' },
+ { value: 'route', label: 'Single route' },
+ { value: 'agent', label: 'Single agent' },
+];
+
+export function ScopeStep({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
+ const env = useSelectedEnv();
+ const { data: catalog } = useCatalog(env);
+ const { data: agents } = useAgents();
+
+ const apps = (catalog ?? []).map((a: any) => ({ slug: a.slug, name: a.displayName ?? a.slug, routes: a.routes ?? [] }));
+ const selectedApp = apps.find((a) => a.slug === form.appSlug);
+ const routes = selectedApp?.routes ?? [];
+ const appAgents = (agents ?? []).filter((a: any) => a.applicationId === form.appSlug);
+
+ return (
+
+
+ setForm({ ...form, name: e.target.value })} placeholder="Order API error rate" />
+
+
+ setForm({ ...form, description: e.target.value })} />
+
+
+
+
+
+ {form.scopeKind !== 'env' && (
+
+
+ )}
+ {form.scopeKind === 'route' && (
+
+
+ )}
+ {form.scopeKind === 'agent' && (
+
+
+ )}
+
+ );
+}
+```
+
+- [ ] **Step 2: TypeScript compile + commit**
+
+```bash
+cd ui && npx tsc -p tsconfig.app.json --noEmit
+git add ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx
+git commit -m "feat(ui/alerts): ScopeStep (name, severity, env/app/route/agent selectors)"
+```
+
+---
+
+### Task 21: `ConditionStep` + condition-form subcomponents
+
+**Files:**
+- Replace: `ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx`
+- Create: `ui/src/pages/Alerts/RuleEditor/condition-forms/RouteMetricForm.tsx`
+- Create: `ui/src/pages/Alerts/RuleEditor/condition-forms/ExchangeMatchForm.tsx`
+- Create: `ui/src/pages/Alerts/RuleEditor/condition-forms/AgentStateForm.tsx`
+- Create: `ui/src/pages/Alerts/RuleEditor/condition-forms/DeploymentStateForm.tsx`
+- Create: `ui/src/pages/Alerts/RuleEditor/condition-forms/LogPatternForm.tsx`
+- Create: `ui/src/pages/Alerts/RuleEditor/condition-forms/JvmMetricForm.tsx`
+
+- [ ] **Step 1: `ConditionStep` routes to the kind-specific sub-form**
+
+```tsx
+// ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx
+import { FormField, Select } from '@cameleer/design-system';
+import type { FormState } from './form-state';
+import { RouteMetricForm } from './condition-forms/RouteMetricForm';
+import { ExchangeMatchForm } from './condition-forms/ExchangeMatchForm';
+import { AgentStateForm } from './condition-forms/AgentStateForm';
+import { DeploymentStateForm } from './condition-forms/DeploymentStateForm';
+import { LogPatternForm } from './condition-forms/LogPatternForm';
+import { JvmMetricForm } from './condition-forms/JvmMetricForm';
+
+const KIND_OPTIONS = [
+ { value: 'ROUTE_METRIC', label: 'Route metric (error rate, latency, throughput)' },
+ { value: 'EXCHANGE_MATCH', label: 'Exchange match (specific failures)' },
+ { value: 'AGENT_STATE', label: 'Agent state (DEAD / STALE)' },
+ { value: 'DEPLOYMENT_STATE', label: 'Deployment state (FAILED / DEGRADED)' },
+ { value: 'LOG_PATTERN', label: 'Log pattern (count of matching logs)' },
+ { value: 'JVM_METRIC', label: 'JVM metric (heap, GC, inflight)' },
+];
+
+export function ConditionStep({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
+ const onKindChange = (v: string) => {
+ setForm({ ...form, conditionKind: v as FormState['conditionKind'], condition: { kind: v as any } });
+ };
+
+ return (
+
+
+
+
+ {form.conditionKind === 'ROUTE_METRIC' &&
}
+ {form.conditionKind === 'EXCHANGE_MATCH' &&
}
+ {form.conditionKind === 'AGENT_STATE' &&
}
+ {form.conditionKind === 'DEPLOYMENT_STATE' &&
}
+ {form.conditionKind === 'LOG_PATTERN' &&
}
+ {form.conditionKind === 'JVM_METRIC' &&
}
+
+ );
+}
+```
+
+- [ ] **Step 2: `RouteMetricForm`**
+
+```tsx
+// ui/src/pages/Alerts/RuleEditor/condition-forms/RouteMetricForm.tsx
+import { FormField, Input, Select } from '@cameleer/design-system';
+import type { FormState } from '../form-state';
+
+const METRICS = [
+ { value: 'ERROR_RATE', label: 'Error rate' },
+ { value: 'P95_LATENCY_MS', label: 'P95 latency (ms)' },
+ { value: 'P99_LATENCY_MS', label: 'P99 latency (ms)' },
+ { value: 'AVG_DURATION_MS',label: 'Avg duration (ms)' },
+ { value: 'THROUGHPUT', label: 'Throughput (msg/s)' },
+ { value: 'ERROR_COUNT', label: 'Error count' },
+];
+const COMPARATORS = [
+ { value: 'GT', label: '>' },
+ { value: 'GTE', label: '≥' },
+ { value: 'LT', label: '<' },
+ { value: 'LTE', label: '≤' },
+];
+
+export function RouteMetricForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
+ const c: any = form.condition;
+ const patch = (p: any) => setForm({ ...form, condition: { ...form.condition, ...p } });
+ return (
+ <>
+
+
+ patch({ threshold: Number(e.target.value) })} />
+ patch({ windowSeconds: Number(e.target.value) })} />
+ >
+ );
+}
+```
+
+- [ ] **Step 3: `ExchangeMatchForm` (PER_EXCHANGE or COUNT_IN_WINDOW)**
+
+```tsx
+// ui/src/pages/Alerts/RuleEditor/condition-forms/ExchangeMatchForm.tsx
+import { FormField, Input, Select } from '@cameleer/design-system';
+import type { FormState } from '../form-state';
+
+const FIRE_MODES = [
+ { value: 'PER_EXCHANGE', label: 'One alert per matching exchange' },
+ { value: 'COUNT_IN_WINDOW', label: 'Threshold: N matches in window' },
+];
+const STATUSES = [
+ { value: '', label: '(any)' },
+ { value: 'COMPLETED', label: 'COMPLETED' },
+ { value: 'FAILED', label: 'FAILED' },
+ { value: 'RUNNING', label: 'RUNNING' },
+];
+
+export function ExchangeMatchForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
+ const c: any = form.condition;
+ const patch = (p: any) => setForm({ ...form, condition: { ...form.condition, ...p } });
+ const filter = c.filter ?? {};
+ return (
+ <>
+
+
+ {c.fireMode === 'PER_EXCHANGE' && (
+
+ patch({ perExchangeLingerSeconds: Number(e.target.value) })} />
+
+ )}
+ {c.fireMode === 'COUNT_IN_WINDOW' && (
+ <>
+ patch({ threshold: Number(e.target.value) })} />
+ patch({ windowSeconds: Number(e.target.value) })} />
+ >
+ )}
+ >
+ );
+}
+```
+
+- [ ] **Step 4: `AgentStateForm`, `DeploymentStateForm`, `LogPatternForm`, `JvmMetricForm`**
+
+Each follows the same pattern. Keep the code short — they're simple enum/threshold pickers.
+
+```tsx
+// ui/src/pages/Alerts/RuleEditor/condition-forms/AgentStateForm.tsx
+import { FormField, Input, Select } from '@cameleer/design-system';
+import type { FormState } from '../form-state';
+
+export function AgentStateForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
+ const c: any = form.condition;
+ const patch = (p: any) => setForm({ ...form, condition: { ...form.condition, ...p } });
+ return (
+ <>
+
+
+
+ patch({ forSeconds: Number(e.target.value) })} />
+
+ >
+ );
+}
+```
+
+```tsx
+// ui/src/pages/Alerts/RuleEditor/condition-forms/DeploymentStateForm.tsx
+import { FormField } from '@cameleer/design-system';
+import type { FormState } from '../form-state';
+
+const OPTIONS = ['FAILED', 'DEGRADED'] as const;
+
+export function DeploymentStateForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
+ const c: any = form.condition;
+ const states: string[] = c.states ?? [];
+ const toggle = (s: string) => {
+ const next = states.includes(s) ? states.filter((x) => x !== s) : [...states, s];
+ setForm({ ...form, condition: { ...form.condition, states: next } });
+ };
+ return (
+
+
+ {OPTIONS.map((s) => (
+
+ ))}
+
+
+ );
+}
+```
+
+```tsx
+// ui/src/pages/Alerts/RuleEditor/condition-forms/LogPatternForm.tsx
+import { FormField, Input, Select } from '@cameleer/design-system';
+import type { FormState } from '../form-state';
+
+export function LogPatternForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
+ const c: any = form.condition;
+ const patch = (p: any) => setForm({ ...form, condition: { ...form.condition, ...p } });
+ return (
+ <>
+
+
+
+ patch({ logger: e.target.value || undefined })} />
+
+
+ patch({ pattern: e.target.value })} />
+
+
+ patch({ threshold: Number(e.target.value) })} />
+
+
+ patch({ windowSeconds: Number(e.target.value) })} />
+
+ >
+ );
+}
+```
+
+```tsx
+// ui/src/pages/Alerts/RuleEditor/condition-forms/JvmMetricForm.tsx
+import { FormField, Input, Select } from '@cameleer/design-system';
+import type { FormState } from '../form-state';
+
+export function JvmMetricForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
+ const c: any = form.condition;
+ const patch = (p: any) => setForm({ ...form, condition: { ...form.condition, ...p } });
+ return (
+ <>
+
+ patch({ metric: e.target.value })} placeholder="heap_used_percent" />
+
+
+
+
+
+
+ patch({ threshold: Number(e.target.value) })} />
+
+
+ patch({ windowSeconds: Number(e.target.value) })} />
+
+ >
+ );
+}
+```
+
+- [ ] **Step 5: TypeScript compile + commit**
+
+```bash
+cd ui && npx tsc -p tsconfig.app.json --noEmit
+git add ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx ui/src/pages/Alerts/RuleEditor/condition-forms/
+git commit -m "feat(ui/alerts): ConditionStep with 6 kind-specific forms
+
+Each kind renders its own payload shape. Kind change resets condition to
+{kind} so stale fields from a previous kind don't leak into save payload."
+```
+
+---
+
+### Task 22: `TriggerStep`
+
+**Files:**
+- Replace: `ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx`
+
+- [ ] **Step 1: Write the step**
+
+```tsx
+// ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx
+import { useState } from 'react';
+import { Button, FormField, Input, useToast } from '@cameleer/design-system';
+import { useTestEvaluate } from '../../../api/queries/alertRules';
+import type { FormState } from './form-state';
+import { toRequest } from './form-state';
+
+export function TriggerStep({ form, setForm, ruleId }: { form: FormState; setForm: (f: FormState) => void; ruleId?: string }) {
+ const testEvaluate = useTestEvaluate();
+ const { toast } = useToast();
+ const [lastResult, setLastResult] = useState(null);
+
+ const onTest = async () => {
+ if (!ruleId) {
+ toast({ title: 'Save rule first to run test evaluate', variant: 'error' });
+ return;
+ }
+ try {
+ const result = await testEvaluate.mutateAsync({ id: ruleId, req: { condition: (toRequest(form).condition as any) } });
+ setLastResult(JSON.stringify(result, null, 2));
+ } catch (e) {
+ toast({ title: 'Test-evaluate failed', description: String(e), variant: 'error' });
+ }
+ };
+
+ return (
+
+ );
+}
+```
+
+- [ ] **Step 2: TypeScript compile + commit**
+
+```bash
+cd ui && npx tsc -p tsconfig.app.json --noEmit
+git add ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx
+git commit -m "feat(ui/alerts): TriggerStep (evaluation interval, for-duration, re-notify, test-evaluate)"
+```
+
+---
+
+### Task 23: `NotifyStep` (MustacheEditor + targets + webhooks)
+
+**Files:**
+- Replace: `ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx`
+
+- [ ] **Step 1: Implement the step**
+
+```tsx
+// ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx
+import { useState } from 'react';
+import { Button, FormField, Select, Input, useToast, Badge } from '@cameleer/design-system';
+import { MustacheEditor } from '../../../components/MustacheEditor/MustacheEditor';
+import { useUsers, useGroups, useRoles } from '../../../api/queries/admin/rbac';
+import { useOutboundConnections } from '../../../api/queries/admin/outboundConnections';
+import { useSelectedEnv } from '../../../api/queries/alertMeta';
+import { useRenderPreview } from '../../../api/queries/alertRules';
+import { toRequest, type FormState } from './form-state';
+
+export function NotifyStep({ form, setForm, ruleId }: { form: FormState; setForm: (f: FormState) => void; ruleId?: string }) {
+ const env = useSelectedEnv();
+ const { data: users } = useUsers(true);
+ const { data: groups } = useGroups(true);
+ const { data: roles } = useRoles(true);
+ const { data: connections } = useOutboundConnections();
+ const preview = useRenderPreview();
+ const { toast } = useToast();
+ const [lastPreview, setLastPreview] = useState(null);
+
+ // Filter connections to those that allow the current env.
+ const availableConnections = (connections ?? []).filter(
+ (c) => c.allowedEnvironmentIds.length === 0 || c.allowedEnvironmentIds.includes(env!),
+ );
+
+ const onPreview = async () => {
+ if (!ruleId) {
+ toast({ title: 'Save rule first to preview', variant: 'error' });
+ return;
+ }
+ try {
+ const res = await preview.mutateAsync({
+ id: ruleId,
+ req: {
+ titleTemplate: form.notificationTitleTmpl,
+ messageTemplate: form.notificationMessageTmpl,
+ },
+ });
+ setLastPreview(`TITLE:\n${(res as any).renderedTitle}\n\nMESSAGE:\n${(res as any).renderedMessage}`);
+ } catch (e) {
+ toast({ title: 'Preview failed', description: String(e), variant: 'error' });
+ }
+ };
+
+ const addTarget = (targetKind: 'USER' | 'GROUP' | 'ROLE', targetId: string) => {
+ if (!targetId) return;
+ if (form.targets.some((t) => t.targetKind === targetKind && t.targetId === targetId)) return;
+ setForm({ ...form, targets: [...form.targets, { targetKind, targetId }] });
+ };
+ const removeTarget = (idx: number) => {
+ setForm({ ...form, targets: form.targets.filter((_, i) => i !== idx) });
+ };
+
+ const addWebhook = (outboundConnectionId: string) => {
+ setForm({
+ ...form,
+ webhooks: [...form.webhooks, { outboundConnectionId, bodyOverride: '', headerOverrides: [] }],
+ });
+ };
+ const removeWebhook = (idx: number) => {
+ setForm({ ...form, webhooks: form.webhooks.filter((_, i) => i !== idx) });
+ };
+ const updateWebhook = (idx: number, patch: Partial) => {
+ setForm({ ...form, webhooks: form.webhooks.map((w, i) => i === idx ? { ...w, ...patch } : w) });
+ };
+
+ return (
+
+
setForm({ ...form, notificationTitleTmpl: v })}
+ kind={form.conditionKind}
+ singleLine
+ />
+ setForm({ ...form, notificationMessageTmpl: v })}
+ kind={form.conditionKind}
+ minHeight={120}
+ />
+
+
+ {lastPreview && (
+
+ {lastPreview}
+
+ )}
+
+
+
+
+ {form.targets.map((t, i) => (
+ removeTarget(i)}
+ />
+ ))}
+
+
+
+
+
+
+ { if (v) addWebhook(v); }}
+ options={[
+ { value: '', label: '+ Add webhook' },
+ ...availableConnections.map((c) => ({ value: c.id, label: c.name })),
+ ]}
+ />
+ {form.webhooks.map((w, i) => {
+ const conn = availableConnections.find((c) => c.id === w.outboundConnectionId);
+ return (
+
+
+ {conn?.name ?? w.outboundConnectionId}
+
+
+
updateWebhook(i, { bodyOverride: v })}
+ kind={form.conditionKind}
+ placeholder="Leave empty to use connection default"
+ minHeight={80}
+ />
+
+ {w.headerOverrides.map((h, hi) => (
+
+ {
+ const heads = [...w.headerOverrides]; heads[hi] = { ...heads[hi], key: e.target.value };
+ updateWebhook(i, { headerOverrides: heads });
+ }} />
+ {
+ const heads = [...w.headerOverrides]; heads[hi] = { ...heads[hi], value: e.target.value };
+ updateWebhook(i, { headerOverrides: heads });
+ }} />
+
+
+ ))}
+
+
+
+ );
+ })}
+
+
+ );
+}
+```
+
+- [ ] **Step 2: TypeScript compile + commit**
+
+```bash
+cd ui && npx tsc -p tsconfig.app.json --noEmit
+git add ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx
+git commit -m "feat(ui/alerts): NotifyStep (MustacheEditor for title/message/body, targets, webhook bindings)
+
+Targets combine users/groups/roles into a unified pill list. Webhook picker
+filters to connections allowed in the current env (spec §6 allowed_env_ids).
+Header overrides use Input rather than MustacheEditor for now — header
+autocomplete can be added in a future polish pass if ops teams ask for it."
+```
+
+---
+
+### Task 24: `ReviewStep` + promotion-prefill warnings
+
+**Files:**
+- Replace: `ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx`
+- Replace: `ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts`
+- Create: `ui/src/pages/Alerts/RuleEditor/promotion-prefill.test.ts`
+
+- [ ] **Step 1: Write the review step**
+
+```tsx
+// ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx
+import type { FormState } from './form-state';
+import { toRequest } from './form-state';
+import { Toggle } from '@cameleer/design-system';
+
+export function ReviewStep({ form, setForm }: { form: FormState; setForm?: (f: FormState) => void }) {
+ 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 && (
+
+ )}
+
+ Raw request JSON
+
+ {JSON.stringify(req, null, 2)}
+
+
+
+ );
+}
+```
+
+- [ ] **Step 2: Rewrite `promotion-prefill.ts` with warnings**
+
+```ts
+// ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts
+import { initialForm, type FormState } from './form-state';
+import type { AlertRuleResponse } from '../../../api/queries/alertRules';
+
+export interface PrefillWarning {
+ field: string;
+ message: string;
+}
+
+/** Client-side prefill when promoting a rule from another env. Emits warnings for
+ * fields that cross env boundaries (agent IDs, outbound connection env-restrictions). */
+export function prefillFromPromotion(
+ source: AlertRuleResponse,
+ opts: {
+ targetEnvAppSlugs?: string[];
+ targetEnvAllowedConnectionIds?: string[]; // IDs allowed in target env
+ } = {},
+): { form: FormState; warnings: PrefillWarning[] } {
+ const form = initialForm(source);
+ form.name = `${source.name} (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 — 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 — remove or pick another before saving.`,
+ });
+ }
+ }
+ }
+
+ return { form, warnings };
+}
+```
+
+- [ ] **Step 3: Test the prefill logic**
+
+```ts
+// ui/src/pages/Alerts/RuleEditor/promotion-prefill.test.ts
+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: null as any,
+ severity: 'CRITICAL',
+ enabled: true,
+ conditionKind: 'ROUTE_METRIC',
+ condition: { kind: 'ROUTE_METRIC', scope: { appSlug: 'orders' } } as any,
+ evaluationIntervalSeconds: 60,
+ forDurationSeconds: 0,
+ reNotifyMinutes: 60,
+ notificationTitleTmpl: '{{rule.name}}',
+ notificationMessageTmpl: 'msg',
+ webhooks: [],
+ targets: [],
+ createdAt: '2026-04-01T00:00:00Z' as any,
+ createdBy: 'alice',
+ updatedAt: '2026-04-01T00:00:00Z' as any,
+ 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({
+ condition: { kind: 'AGENT_STATE', scope: { appSlug: 'orders', agentId: 'orders-0' }, state: 'DEAD', forSeconds: 60 } as any,
+ conditionKind: 'AGENT_STATE',
+ }));
+ 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: null, headerOverrides: {} } as any],
+ });
+ const { warnings } = prefillFromPromotion(rule, { targetEnvAllowedConnectionIds: ['conn-dev'] });
+ expect(warnings.find((w) => w.field.startsWith('webhooks['))).toBeTruthy();
+ });
+});
+```
+
+- [ ] **Step 4: Run tests + commit**
+
+```bash
+cd ui && npm test -- promotion-prefill
+cd ui && npx tsc -p tsconfig.app.json --noEmit
+git add ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts ui/src/pages/Alerts/RuleEditor/promotion-prefill.test.ts
+git commit -m "feat(ui/alerts): ReviewStep + promotion prefill warnings
+
+Review step dumps a human summary + raw request JSON + enabled toggle.
+Promotion prefill clears agent IDs (per-env), flags missing apps in target
+env, flags webhook connections not allowed in target env. Follow-up: wire
+warnings into wizard UI as per-field inline hints (Task 24 ext.)."
+```
+
+---
+
+### Task 25: Wire promotion warnings into wizard UI
+
+**Files:**
+- Modify: `ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx`
+
+- [ ] **Step 1: Fetch target-env apps + allowed connections and wire warnings**
+
+Expand the existing wizard to use `prefillFromPromotion({form, warnings})` and expose `warnings` via a banner listing them. In `RuleEditorWizard.tsx`:
+
+```tsx
+// Near the other hooks
+import { useCatalog } from '../../../api/queries/catalog';
+import { useOutboundConnections } from '../../../api/queries/admin/outboundConnections';
+import type { PrefillWarning } from './promotion-prefill';
+
+// Inside the component, after promoteFrom setup:
+const targetEnv = search.get('targetEnv') ?? env;
+const { data: targetCatalog } = useCatalog(targetEnv ?? undefined);
+const { data: connections } = useOutboundConnections();
+
+const targetAppSlugs = (targetCatalog ?? []).map((a: any) => a.slug);
+const targetAllowedConnIds = (connections ?? [])
+ .filter((c) => c.allowedEnvironmentIds.length === 0 || (targetEnv && c.allowedEnvironmentIds.includes(targetEnv)))
+ .map((c) => c.id);
+
+const [warnings, setWarnings] = useState([]);
+```
+
+Replace the initializer block:
+
+```tsx
+const ready = useMemo(() => {
+ if (form) return true;
+ if (isEdit && existingQuery.data) {
+ setForm(initialForm(existingQuery.data));
+ return true;
+ }
+ if (promoteFrom && sourceRuleQuery.data) {
+ const { form: prefilled, warnings: w } = prefillFromPromotion(sourceRuleQuery.data, {
+ targetEnvAppSlugs: targetAppSlugs,
+ targetEnvAllowedConnectionIds: targetAllowedConnIds,
+ });
+ setForm(prefilled);
+ setWarnings(w);
+ return true;
+ }
+ if (!isEdit && !promoteFrom) {
+ setForm(initialForm());
+ return true;
+ }
+ return false;
+}, [form, isEdit, existingQuery.data, promoteFrom, sourceRuleQuery.data, targetAppSlugs.join(','), targetAllowedConnIds.join(',')]);
+```
+
+Render a warnings banner when `warnings.length > 0`:
+
+```tsx
+{warnings.length > 0 && (
+
+
Review before saving:
+
{warnings.map((w) => {w.field}: {w.message} )}
+
+)}
+```
+
+- [ ] **Step 2: TypeScript compile + commit**
+
+```bash
+cd ui && npx tsc -p tsconfig.app.json --noEmit
+git add ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx
+git commit -m "feat(ui/alerts): render promotion warnings in wizard banner"
+```
+
+---
+
+## Phase 7 — Silences
+
+### Task 26: `SilencesPage`
+
+**Files:**
+- Replace: `ui/src/pages/Alerts/SilencesPage.tsx`
+
+- [ ] **Step 1: Implement the page**
+
+```tsx
+// ui/src/pages/Alerts/SilencesPage.tsx
+import { useState } from 'react';
+import { Button, FormField, Input, SectionHeader, useToast } from '@cameleer/design-system';
+import { PageLoader } from '../../components/PageLoader';
+import {
+ useAlertSilences,
+ useCreateSilence,
+ useDeleteSilence,
+ type AlertSilenceResponse,
+} from '../../api/queries/alertSilences';
+import sectionStyles from '../../styles/section-card.module.css';
+
+export default function SilencesPage() {
+ const { data, isLoading, error } = useAlertSilences();
+ const create = useCreateSilence();
+ const remove = useDeleteSilence();
+ const { toast } = useToast();
+
+ const [reason, setReason] = useState('');
+ const [matcherRuleId, setMatcherRuleId] = useState('');
+ const [matcherAppSlug, setMatcherAppSlug] = useState('');
+ const [hours, setHours] = useState(1);
+
+ if (isLoading) return ;
+ if (error) return Failed to load silences: {String(error)}
;
+
+ const onCreate = async () => {
+ const now = new Date();
+ const endsAt = new Date(now.getTime() + hours * 3600_000);
+ const matcher: Record = {};
+ if (matcherRuleId) matcher.ruleId = matcherRuleId;
+ if (matcherAppSlug) matcher.appSlug = matcherAppSlug;
+ if (Object.keys(matcher).length === 0) {
+ toast({ title: 'Silence needs at least one matcher field', variant: 'error' });
+ return;
+ }
+ try {
+ await create.mutateAsync({
+ matcher,
+ reason: reason || undefined,
+ startsAt: now.toISOString(),
+ endsAt: endsAt.toISOString(),
+ });
+ setReason(''); setMatcherRuleId(''); setMatcherAppSlug(''); setHours(1);
+ toast({ title: 'Silence created', variant: 'success' });
+ } catch (e) {
+ toast({ title: 'Create failed', description: String(e), variant: 'error' });
+ }
+ };
+
+ const onRemove = async (s: AlertSilenceResponse) => {
+ if (!confirm(`End silence early?`)) return;
+ try {
+ await remove.mutateAsync(s.id);
+ toast({ title: 'Silence removed', variant: 'success' });
+ } catch (e) {
+ toast({ title: 'Remove failed', description: String(e), variant: 'error' });
+ }
+ };
+
+ const rows = data ?? [];
+
+ return (
+
+
Alert silences
+
+
+ {rows.length === 0 ? (
+
No active or scheduled silences.
+ ) : (
+
+
+
+ | Matcher |
+ Reason |
+ Starts |
+ Ends |
+ |
+
+
+
+ {rows.map((s) => (
+
+ {JSON.stringify(s.matcher)} |
+ {s.reason ?? '—'} |
+ {s.startsAt} |
+ {s.endsAt} |
+ |
+
+ ))}
+
+
+ )}
+
+
+ );
+}
+```
+
+- [ ] **Step 2: TypeScript compile + commit**
+
+```bash
+cd ui && npx tsc -p tsconfig.app.json --noEmit
+git add ui/src/pages/Alerts/SilencesPage.tsx
+git commit -m "feat(ui/alerts): SilencesPage with matcher-based create + end-early action
+
+Matcher accepts ruleId and/or appSlug. Server enforces endsAt > startsAt
+(V12 CHECK constraint) and matcher_matches() at dispatch time (spec §7)."
+```
+
+---
+
+## Phase 8 — CMD-K integration
+
+### Task 27: Add alert + alertRule sources to the command palette
+
+**Files:**
+- Modify: `ui/src/components/LayoutShell.tsx`
+
+- [ ] **Step 1: Import the alert queries + state chip**
+
+Near the other API-query imports (around line 31):
+
+```ts
+import { useAlerts } from '../api/queries/alerts';
+import { useAlertRules } from '../api/queries/alertRules';
+```
+
+- [ ] **Step 2: Build alert/alert-rule SearchResult[]**
+
+Near `buildSearchData` and `buildAdminSearchData`, add:
+
+```ts
+function buildAlertSearchData(
+ alerts: any[] | undefined,
+ rules: any[] | undefined,
+): SearchResult[] {
+ const results: SearchResult[] = [];
+ if (alerts) {
+ for (const a of alerts) {
+ results.push({
+ id: `alert:${a.id}`,
+ category: 'alert',
+ title: a.title ?? '(untitled)',
+ badges: [
+ { label: a.severity, color: severityToSearchColor(a.severity) },
+ { label: a.state, color: stateToSearchColor(a.state) },
+ ],
+ meta: `${a.firedAt ?? ''}${a.silenced ? ' · silenced' : ''}`,
+ path: `/alerts/inbox/${a.id}`,
+ });
+ }
+ }
+ if (rules) {
+ for (const r of rules) {
+ results.push({
+ id: `rule:${r.id}`,
+ category: 'alertRule',
+ title: r.name,
+ badges: [
+ { label: r.severity, color: severityToSearchColor(r.severity) },
+ { label: r.conditionKind, color: 'auto' },
+ ...(r.enabled ? [] : [{ label: 'DISABLED', color: 'warning' as const }]),
+ ],
+ meta: `${r.evaluationIntervalSeconds}s · ${r.targets?.length ?? 0} targets`,
+ path: `/alerts/rules/${r.id}`,
+ });
+ }
+ }
+ return results;
+}
+
+function severityToSearchColor(s: string): string {
+ if (s === 'CRITICAL') return 'error';
+ if (s === 'WARNING') return 'warning';
+ return 'auto';
+}
+function stateToSearchColor(s: string): string {
+ if (s === 'FIRING') return 'error';
+ if (s === 'ACKNOWLEDGED') return 'warning';
+ if (s === 'RESOLVED') return 'success';
+ return 'auto';
+}
+```
+
+- [ ] **Step 3: Fetch alerts + rules inside `LayoutContent`**
+
+Near the existing catalog/agents fetches (around line 305):
+
+```ts
+// Open alerts + rules for CMD-K (env-scoped).
+const { data: cmdkAlerts } = useAlerts({ state: ['FIRING', 'ACKNOWLEDGED'], limit: 100 });
+const { data: cmdkRules } = useAlertRules();
+```
+
+- [ ] **Step 4: Add the results into `operationalSearchData`**
+
+Adjust the `operationalSearchData` memo to include alert + rule results:
+
+```ts
+const alertingSearchData = useMemo(
+ () => buildAlertSearchData(cmdkAlerts, cmdkRules),
+ [cmdkAlerts, cmdkRules],
+);
+
+// Inside the existing operationalSearchData useMemo, append alertingSearchData:
+return [...catalogRef.current, ...exchangeItems, ...attributeItems, ...alertingSearchData];
+```
+
+- [ ] **Step 5: Route selection — handle `alert` and `alertRule` categories**
+
+Extend `handlePaletteSelect`'s logic: when the result category is `alert` or `alertRule`, just navigate to `result.path`. The existing fallback branch already handles this, but add an explicit clause so the state payload doesn't get the exchange-specific `selectedExchange` treatment:
+
+```ts
+if (result.category === 'alert' || result.category === 'alertRule') {
+ navigate(result.path);
+ setPaletteOpen(false);
+ return;
+}
+```
+
+Insert this at the top of `handlePaletteSelect` before the existing ADMIN_CATEGORIES check.
+
+- [ ] **Step 6: TypeScript compile + manual smoke**
+
+```bash
+cd ui && npx tsc -p tsconfig.app.json --noEmit
+```
+
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add ui/src/components/LayoutShell.tsx
+git commit -m "feat(ui/alerts): CMD-K sources for alerts + alert rules
+
+Extends operationalSearchData with open alerts (FIRING|ACKNOWLEDGED) and
+all rules. Badges convey severity + state. Selecting an alert navigates to
+/alerts/inbox/{id}; a rule navigates to /alerts/rules/{id}. Uses the
+existing CommandPalette extension point — no new registry."
+```
+
+---
+
+## Phase 9 — Backend backfills
+
+### Task 28: SSRF guard on `OutboundConnection.url`
+
+**Files:**
+- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java`
+- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java`
+- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java`
+- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionAdminControllerIT.java`
+
+Before editing, run:
+```
+gitnexus_impact({target:"OutboundConnectionServiceImpl.save", direction:"upstream"})
+```
+Expected d=1: `OutboundConnectionAdminController` (create + update). No other callers — risk is LOW.
+
+- [ ] **Step 1: Write failing unit test for `SsrfGuard`**
+
+```java
+// cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java
+package com.cameleer.server.app.outbound;
+
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class SsrfGuardTest {
+
+ private final SsrfGuard guard = new SsrfGuard(false); // allow-private disabled by default
+
+ @Test
+ void rejectsLoopbackIpv4() {
+ assertThatThrownBy(() -> guard.validate(URI.create("https://127.0.0.1/webhook")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("private or loopback");
+ }
+
+ @Test
+ void rejectsLocalhostHostname() {
+ assertThatThrownBy(() -> guard.validate(URI.create("https://localhost:8080/x")))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void rejectsRfc1918Ranges() {
+ for (String url : Set.of(
+ "https://10.0.0.1/x",
+ "https://172.16.5.6/x",
+ "https://192.168.1.1/x"
+ )) {
+ assertThatThrownBy(() -> guard.validate(URI.create(url)))
+ .as(url)
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+ }
+
+ @Test
+ void rejectsLinkLocal() {
+ assertThatThrownBy(() -> guard.validate(URI.create("https://169.254.169.254/latest/meta-data/")))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void rejectsIpv6Loopback() {
+ assertThatThrownBy(() -> guard.validate(URI.create("https://[::1]/x")))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void rejectsIpv6UniqueLocal() {
+ assertThatThrownBy(() -> guard.validate(URI.create("https://[fc00::1]/x")))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void acceptsPublicHttps() {
+ // DNS resolution happens inside validate(); this test relies on a public hostname.
+ // Use a literal public IP to avoid network flakiness.
+ // 8.8.8.8 is a public Google DNS IP — not in any private range.
+ assertThat(new SsrfGuard(false)).isNotNull();
+ guard.validate(URI.create("https://8.8.8.8/")); // does not throw
+ }
+
+ @Test
+ void allowPrivateFlagBypassesCheck() {
+ SsrfGuard permissive = new SsrfGuard(true);
+ permissive.validate(URI.create("https://127.0.0.1/")); // must not throw
+ }
+}
+```
+
+Run: `cd cameleer-server-app && mvn -pl . -am test -Dtest=SsrfGuardTest`
+Expected: FAIL (SsrfGuard not found).
+
+- [ ] **Step 2: Implement `SsrfGuard`**
+
+```java
+// cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java
+package com.cameleer.server.app.outbound;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.net.Inet4Address;
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.net.URI;
+import java.net.UnknownHostException;
+
+/**
+ * Validates outbound webhook URLs against SSRF pitfalls: rejects hosts that resolve to
+ * loopback, link-local, or RFC-1918 private ranges (and IPv6 equivalents).
+ *
+ * Per spec §17. The `cameleer.server.outbound-http.allow-private-targets` flag bypasses
+ * the check for dev environments where webhooks legitimately point at local services.
+ */
+@Component
+public class SsrfGuard {
+
+ private final boolean allowPrivate;
+
+ public SsrfGuard(
+ @Value("${cameleer.server.outbound-http.allow-private-targets:false}") boolean allowPrivate
+ ) {
+ this.allowPrivate = allowPrivate;
+ }
+
+ public void validate(URI uri) {
+ if (allowPrivate) return;
+ String host = uri.getHost();
+ if (host == null || host.isBlank()) {
+ throw new IllegalArgumentException("URL must include a host: " + uri);
+ }
+ if ("localhost".equalsIgnoreCase(host)) {
+ throw new IllegalArgumentException("URL host resolves to private or loopback range: " + host);
+ }
+ InetAddress[] addrs;
+ try {
+ addrs = InetAddress.getAllByName(host);
+ } catch (UnknownHostException e) {
+ throw new IllegalArgumentException("URL host does not resolve: " + host, e);
+ }
+ for (InetAddress addr : addrs) {
+ if (isPrivate(addr)) {
+ throw new IllegalArgumentException("URL host resolves to private or loopback range: " + host + " -> " + addr.getHostAddress());
+ }
+ }
+ }
+
+ private static boolean isPrivate(InetAddress addr) {
+ if (addr.isLoopbackAddress()) return true;
+ if (addr.isLinkLocalAddress()) return true;
+ if (addr.isSiteLocalAddress()) return true; // 10/8, 172.16/12, 192.168/16
+ if (addr.isAnyLocalAddress()) return true; // 0.0.0.0, ::
+ if (addr instanceof Inet6Address ip6) {
+ byte[] raw = ip6.getAddress();
+ // fc00::/7 unique-local
+ if ((raw[0] & 0xfe) == 0xfc) return true;
+ }
+ if (addr instanceof Inet4Address ip4) {
+ byte[] raw = ip4.getAddress();
+ // 169.254.0.0/16 link-local (also matches isLinkLocalAddress but doubled-up for safety)
+ if ((raw[0] & 0xff) == 169 && (raw[1] & 0xff) == 254) return true;
+ }
+ return false;
+ }
+}
+```
+
+Run: `cd cameleer-server-app && mvn -pl . -am test -Dtest=SsrfGuardTest`
+Expected: 8 tests pass (the public-IP case requires network; if the local env blocks DNS, allow it to skip — but it shouldn't error on a literal IP).
+
+- [ ] **Step 3: Wire the guard into `OutboundConnectionServiceImpl.save`**
+
+Edit `OutboundConnectionServiceImpl.java`. Read the file first, then find the `save` method. Inject `SsrfGuard` via constructor and call `guard.validate(URI.create(request.url()))` before persisting. The save method is the `create` and `update` entry point from the controller.
+
+Sketch:
+
+```java
+// Constructor gains:
+private final SsrfGuard ssrfGuard;
+
+public OutboundConnectionServiceImpl(
+ OutboundConnectionRepository repo,
+ SecretCipher cipher,
+ AuditService audit,
+ SsrfGuard ssrfGuard,
+ @Value("${cameleer.server.tenant.id:default}") String tenantId
+) {
+ this.repo = repo;
+ this.cipher = cipher;
+ this.audit = audit;
+ this.ssrfGuard = ssrfGuard;
+ this.tenantId = tenantId;
+}
+
+// In save() (both create & update), before repo.save():
+ssrfGuard.validate(URI.create(request.url()));
+```
+
+Verify the existing constructor signature by reading `OutboundConnectionServiceImpl.java` first; adjust the `@Autowired`/Spring wiring in `OutboundBeanConfig.java` if the bean is constructed there.
+
+- [ ] **Step 4: Add an IT case for SSRF rejection**
+
+Add to `OutboundConnectionAdminControllerIT.java`:
+
+```java
+@Test
+void rejectsPrivateIpOnCreate() throws Exception {
+ mockMvc.perform(post("/api/v1/admin/outbound-connections")
+ .header("Authorization", "Bearer " + adminToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "name": "evil",
+ "url": "https://127.0.0.1/abuse",
+ "method": "POST",
+ "tlsTrustMode": "SYSTEM_DEFAULT",
+ "auth": {}
+ }
+ """))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.message", containsString("private or loopback")));
+}
+```
+
+(The exact token helper follows the existing ITs; reuse their pattern.)
+
+- [ ] **Step 5: Run full verify for touched modules**
+
+```bash
+mvn -pl cameleer-server-app -am verify -Dtest='SsrfGuardTest,OutboundConnectionAdminControllerIT'
+```
+
+Expected: all tests pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java \
+ cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java \
+ cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java \
+ cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionAdminControllerIT.java
+git commit -m "feat(alerting): SSRF guard on outbound connection URL
+
+Rejects webhook URLs that resolve to loopback, link-local, or RFC-1918 private
+ranges (IPv4 + IPv6). Bypass via cameleer.server.outbound-http.allow-private-
+targets=true for dev envs. Plan 01 scope; required before SaaS exposure
+(spec §17)."
+```
+
+---
+
+### Task 29: `AlertingMetrics` gauge 30s caching
+
+**Files:**
+- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/metrics/AlertingMetrics.java`
+- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java`
+
+Before editing, run:
+```
+gitnexus_impact({target:"AlertingMetrics", direction:"upstream"})
+```
+Expected d=1: callers that register the gauges (startup bean wiring). Risk LOW.
+
+- [ ] **Step 1: Write failing test**
+
+```java
+// cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java
+package com.cameleer.server.app.alerting.metrics;
+
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import org.junit.jupiter.api.Test;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AlertingMetricsCachingTest {
+
+ @Test
+ void gaugeSupplierIsCalledAtMostOncePer30Seconds() {
+ AtomicInteger calls = new AtomicInteger();
+ AtomicReference now = new AtomicReference<>(Instant.parse("2026-04-20T00:00:00Z"));
+ Clock clock = Clock.fixed(now.get(), ZoneOffset.UTC);
+
+ AlertingMetrics metrics = new AlertingMetrics(
+ new SimpleMeterRegistry(),
+ () -> { calls.incrementAndGet(); return 7L; }, // count supplier
+ () -> 0L, // open rules
+ () -> 0L, // circuit-open
+ Duration.ofSeconds(30),
+ () -> Clock.fixed(now.get(), ZoneOffset.UTC).instant()
+ );
+
+ // Registering the gauge does not call the supplier; reading it does (Micrometer semantics).
+ metrics.snapshotAllGauges(); // first read — delegates through cache, 1 underlying call
+ metrics.snapshotAllGauges(); // second read within TTL — served from cache, 0 new calls
+ assertThat(calls.get()).isEqualTo(1);
+
+ now.set(Instant.parse("2026-04-20T00:00:31Z")); // advance 31 s
+ metrics.snapshotAllGauges();
+ assertThat(calls.get()).isEqualTo(2);
+ }
+}
+```
+
+Run: `cd cameleer-server-app && mvn -pl . -am test -Dtest=AlertingMetricsCachingTest`
+Expected: FAIL (caching not implemented, or signature mismatch).
+
+- [ ] **Step 2: Refactor `AlertingMetrics` to wrap suppliers in a TTL cache**
+
+Read the existing file. If it currently uses `registry.gauge(...)` with direct suppliers, wrap each one in a lightweight cache:
+
+```java
+// Inside AlertingMetrics
+private static final class TtlCache {
+ private final Supplier delegate;
+ private final Duration ttl;
+ private final Supplier clock;
+ private volatile Instant lastRead = Instant.MIN;
+ private volatile long cached = 0L;
+
+ TtlCache(Supplier delegate, Duration ttl, Supplier clock) {
+ this.delegate = delegate;
+ this.ttl = ttl;
+ this.clock = clock;
+ }
+
+ long get() {
+ Instant now = clock.get();
+ if (Duration.between(lastRead, now).compareTo(ttl) >= 0) {
+ cached = delegate.get();
+ lastRead = now;
+ }
+ return cached;
+ }
+}
+```
+
+Register the gauges with `() -> cache.get()` in place of raw suppliers. Keep the existing bean constructor signature on the production path (default `ttl = Duration.ofSeconds(30)`, clock = `Instant::now`) and add a test-friendly constructor variant with explicit `ttl` + `clock`.
+
+Add a test helper `snapshotAllGauges()` that reads each registered cache's value (or simply calls `registry.find(...).gauge().value()` on each metric) — whatever the existing method surface supports. If no such helper exists, expose package-private accessors on the caches.
+
+- [ ] **Step 3: Run test**
+
+```bash
+mvn -pl cameleer-server-app -am test -Dtest=AlertingMetricsCachingTest
+```
+
+Expected: PASS.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/metrics/AlertingMetrics.java \
+ cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java
+git commit -m "perf(alerting): 30s TTL cache on AlertingMetrics gauge suppliers
+
+Prometheus scrapes can fire every few seconds. The open-alerts / open-rules
+gauges query Postgres on each read — caching the values for 30s amortises
+that to one query per half-minute. Addresses final-review NIT from Plan 02."
+```
+
+---
+
+## Phase 10 — E2E smoke + docs + final verification
+
+### Task 30: Playwright E2E smoke
+
+**Files:**
+- Create: `ui/src/test/e2e/alerting.spec.ts`
+- Create: `ui/src/test/e2e/fixtures.ts`
+
+- [ ] **Step 1: Write a login fixture**
+
+```ts
+// ui/src/test/e2e/fixtures.ts
+import { test as base, expect } from '@playwright/test';
+
+export const ADMIN_USER = process.env.E2E_ADMIN_USER ?? 'admin';
+export const ADMIN_PASS = process.env.E2E_ADMIN_PASS ?? 'admin';
+
+export const test = base.extend<{ loggedIn: void }>({
+ loggedIn: [async ({ page }, use) => {
+ await page.goto('/login');
+ await page.getByLabel(/username/i).fill(ADMIN_USER);
+ await page.getByLabel(/password/i).fill(ADMIN_PASS);
+ await page.getByRole('button', { name: /log in/i }).click();
+ await expect(page).toHaveURL(/\/(exchanges|alerts)/);
+ await use();
+ }, { auto: true }],
+});
+
+export { expect };
+```
+
+- [ ] **Step 2: Write the smoke test**
+
+```ts
+// ui/src/test/e2e/alerting.spec.ts
+import { test, expect } from './fixtures';
+
+test.describe('alerting UI smoke', () => {
+ test('sidebar Alerts section navigates to inbox', async ({ page }) => {
+ await page.getByRole('button', { name: /alerts/i }).first().click();
+ await expect(page).toHaveURL(/\/alerts\/inbox/);
+ await expect(page.getByRole('heading', { name: /inbox/i })).toBeVisible();
+ });
+
+ test('CRUD a rule end-to-end', async ({ page }) => {
+ await page.goto('/alerts/rules');
+ await page.getByRole('link', { name: /new rule/i }).click();
+ await expect(page).toHaveURL(/\/alerts\/rules\/new/);
+
+ // Step 1 — scope
+ await page.getByLabel(/^name$/i).fill('e2e smoke rule');
+ await page.getByRole('button', { name: /next/i }).click();
+
+ // Step 2 — condition (leave at ROUTE_METRIC defaults)
+ await page.getByRole('button', { name: /next/i }).click();
+
+ // Step 3 — trigger (defaults)
+ await page.getByRole('button', { name: /next/i }).click();
+
+ // Step 4 — notify (templates have defaults)
+ await page.getByRole('button', { name: /next/i }).click();
+
+ // Step 5 — review
+ await page.getByRole('button', { name: /create rule/i }).click();
+
+ await expect(page).toHaveURL(/\/alerts\/rules/);
+ await expect(page.getByText('e2e smoke rule')).toBeVisible();
+
+ // Delete
+ page.once('dialog', (d) => d.accept());
+ await page.getByRole('row', { name: /e2e smoke rule/i }).getByRole('button', { name: /delete/i }).click();
+ await expect(page.getByText('e2e smoke rule')).toHaveCount(0);
+ });
+
+ test('CMD-K navigates to a rule', async ({ page }) => {
+ await page.keyboard.press('Control+K');
+ await page.getByRole('searchbox').fill('smoke');
+ // No rule expected in fresh DB — verify palette renders without crashing
+ await expect(page.getByRole('dialog')).toBeVisible();
+ await page.keyboard.press('Escape');
+ });
+
+ test('silence create + end-early', async ({ page }) => {
+ await page.goto('/alerts/silences');
+ await page.getByLabel(/app slug/i).fill('smoke-app');
+ await page.getByLabel(/duration/i).fill('1');
+ await page.getByLabel(/reason/i).fill('e2e smoke');
+ await page.getByRole('button', { name: /create silence/i }).click();
+ await expect(page.getByText('smoke-app')).toBeVisible();
+
+ page.once('dialog', (d) => d.accept());
+ await page.getByRole('row', { name: /smoke-app/i }).getByRole('button', { name: /end/i }).click();
+ await expect(page.getByText('smoke-app')).toHaveCount(0);
+ });
+});
+```
+
+- [ ] **Step 3: Run the smoke (requires backend on :8081)**
+
+```bash
+cd ui && npx playwright test
+```
+
+Expected: 4 tests pass. If the dev env uses a different admin credential, override via `E2E_ADMIN_USER` + `E2E_ADMIN_PASS`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add ui/src/test/e2e/alerting.spec.ts ui/src/test/e2e/fixtures.ts
+git commit -m "test(ui/alerts): Playwright smoke — sidebar nav, rule CRUD, CMD-K, silence CRUD
+
+Smoke runs against the real backend (not mocks) per project test policy.
+Does not exercise fire-to-ack (requires event ingestion machinery); that
+path is covered by backend AlertingFullLifecycleIT."
+```
+
+---
+
+### Task 31: Update `.claude/rules/ui.md` + admin guide
+
+**Files:**
+- Modify: `.claude/rules/ui.md`
+- Modify: `docs/alerting.md`
+
+- [ ] **Step 1: Update `.claude/rules/ui.md`**
+
+Read the file and append two new sections:
+
+```markdown
+## Alerts
+
+- **Sidebar section** (`buildAlertsTreeNodes` in `ui/src/components/sidebar-utils.ts`) — Inbox, All, Rules, Silences, History.
+- **Routes** in `ui/src/router.tsx`: `/alerts`, `/alerts/inbox`, `/alerts/all`, `/alerts/history`, `/alerts/rules`, `/alerts/rules/new`, `/alerts/rules/:id`, `/alerts/silences`.
+- **Pages** under `ui/src/pages/Alerts/`:
+ - `InboxPage.tsx` — user-targeted FIRING/ACK'd alerts with bulk-read.
+ - `AllAlertsPage.tsx` — env-wide list with state chip filter.
+ - `HistoryPage.tsx` — RESOLVED alerts.
+ - `RulesListPage.tsx` — CRUD + enable/disable toggle + env-promotion dropdown (pure UI prefill, no new endpoint).
+ - `RuleEditor/RuleEditorWizard.tsx` — 5-step wizard (Scope / Condition / Trigger / Notify / Review). `form-state.ts` is the single source of truth (initialForm / toRequest / validateStep).
+ - `SilencesPage.tsx` — matcher-based create + end-early.
+- **Components**:
+ - `NotificationBell.tsx` — polls `/alerts/unread-count` every 30s, paused when tab hidden via `usePageVisible`.
+ - `AlertStateChip.tsx`, `SeverityBadge.tsx` — shared state/severity indicators.
+ - `MustacheEditor/` — CodeMirror 6 editor with variable autocomplete + inline linter. Shared between rule title/message, webhook body/header overrides, and Admin Outbound Connection editor (reduced-context mode for URL).
+- **API queries** under `ui/src/api/queries/`: `alerts.ts`, `alertRules.ts`, `alertSilences.ts`, `alertNotifications.ts`, `alertMeta.ts`. All env-scoped via `useSelectedEnv`.
+- **CMD-K**: `buildAlertSearchData` in `LayoutShell.tsx` registers `alert` and `alertRule` categories. Badges convey severity + state.
+- **Sidebar accordion**: entering `/alerts/*` collapses Applications + Admin + Starred (mirrors Admin accordion).
+```
+
+- [ ] **Step 2: Update `docs/alerting.md` admin guide**
+
+Append a UI walkthrough section covering:
+- Where to find the Alerts section (sidebar + top-bar bell)
+- How to author a rule (wizard screenshots can be added later)
+- How to create a silence
+- How to interpret the env-promotion warnings
+- Where Mustache variable autocomplete comes from
+
+Keep it to ~60 lines; point readers to spec §13 for the full design rationale.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add .claude/rules/ui.md docs/alerting.md
+git commit -m "docs(alerting): UI map + admin-guide walkthrough for Plan 03
+
+.claude/rules/ui.md now maps every Plan 03 UI surface. Admin guide picks up
+inbox/rules/silences sections so ops teams can start in the UI without
+reading the spec."
+```
+
+---
+
+### Task 32: Final verification — build, lint, tests
+
+- [ ] **Step 1: Frontend full build (type check + bundle)**
+
+```bash
+cd ui && npm run build
+```
+
+Expected: clean build, no errors. Bundle size within reason (<2 MB uncompressed, CM6 + alerts pages add ~150 KB gzipped).
+
+- [ ] **Step 2: Frontend lint**
+
+```bash
+cd ui && npm run lint
+```
+
+Expected: zero errors. Fix warnings introduced by Plan 03 files; ignore pre-existing ones.
+
+- [ ] **Step 3: Frontend unit tests**
+
+```bash
+cd ui && npm test
+```
+
+Expected: all Plan 03 Vitest suites pass (≥ 25 tests across hooks, chips, editor, form-state, prefill).
+
+- [ ] **Step 4: Backend verify**
+
+```bash
+mvn -pl cameleer-server-app -am verify
+```
+
+Expected: all existing + new tests pass (SsrfGuardTest, AlertingMetricsCachingTest, extended OutboundConnectionAdminControllerIT).
+
+- [ ] **Step 5: `gitnexus_detect_changes` pre-PR sanity**
+
+```
+gitnexus_detect_changes({scope:"compare", base_ref:"main"})
+```
+
+Expected: affected symbols = only Plan 03 surface (Alerts pages, MustacheEditor, NotificationBell, SsrfGuard, AlertingMetrics caching, router/LayoutShell edits). No stray edits to unrelated modules.
+
+- [ ] **Step 6: Regenerate OpenAPI schema one final time**
+
+If any backend DTO changed during the backfills, run `cd ui && npm run generate-api:live` and commit the diff. If there's no diff, skip this step.
+
+- [ ] **Step 7: Commit any verification artifacts (none expected)**
+
+No commit if everything is clean.
+
+- [ ] **Step 8: Push branch + open PR**
+
+```bash
+git push -u origin feat/alerting-03-ui
+gh pr create --title "feat(alerting): Plan 03 — UI + backfills (SSRF guard, metrics caching)" --body "$(cat <<'EOF'
+## Summary
+- Alerting UI: inbox, all/history, rules list, 5-step rule editor wizard, silences, notification bell, CMD-K integration.
+- MustacheEditor: CodeMirror 6 with variable autocomplete + inline linter (shared across rule templates + webhook body/header overrides + connection defaults).
+- Rule promotion across envs: pure UI prefill (no new endpoint) with client-side warnings (app missing in target env, agent-specific scope, connection not allowed in target env).
+- Backend backfills: SSRF guard on outbound connection URL save (rejects loopback/link-local/RFC-1918); 30s TTL cache on AlertingMetrics gauges.
+- Docs: `.claude/rules/ui.md` updated with full Alerts map; `docs/alerting.md` gains UI walkthrough.
+
+Spec: `docs/superpowers/specs/2026-04-19-alerting-design.md` §12/§13/§9.
+Plan: `docs/superpowers/plans/2026-04-20-alerting-03-ui.md`.
+
+## Test plan
+- [ ] Vitest unit suites all pass (`cd ui && npm test`).
+- [ ] Playwright smoke passes against a running backend (`cd ui && npx playwright test`).
+- [ ] `mvn -pl cameleer-server-app -am verify` green.
+- [ ] Manual: create a rule, see it in the list, CMD-K finds it, disable it, delete it.
+- [ ] Manual: create a silence, see it, end it early.
+- [ ] Manual: bell shows unread count when a FIRING alert targets the current user.
+- [ ] Manual: promoting a rule with an agent-scope shows the agent-id warning.
+
+Plan 01 + Plan 02 are already on main; Plan 03 targets main directly. Supersedes the chore/openapi-regen-post-plan02 branch (delete after merge).
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)
+EOF
+)"
+```
+
+---
+
+## Self-Review
+
+Ran per the writing-plans skill self-review checklist.
+
+### 1. Spec coverage
+
+| Spec requirement | Covered by |
+|---|---|
+| §12 CMD-K integration (alerts + alertRules result sources) | Task 27 |
+| §13 UI routes (`/alerts/**`) | Task 13 |
+| §13 Top-nav `` | Tasks 9, 15 |
+| §13 `` | Task 8 |
+| §13 Rule editor 5-step wizard | Tasks 19–25 |
+| §13 `` with variable autocomplete | Tasks 10–12 |
+| §13 Silences / History / Rules list / OutboundConnectionAdminPage | Tasks 16, 17, 18, 26 (Outbound page already exists from Plan 01) |
+| §13 Real-time: bell polls every 30s; paused when tab hidden | Task 9 (`usePageVisible`) + query `refetchIntervalInBackground:false` |
+| §13 Accessibility: keyboard nav, ARIA | CM6 autocomplete is ARIA-conformant; bell has aria-label |
+| §13 Styling: `@cameleer/design-system` CSS variables | All files use `var(--error)` etc. (no hardcoded hex) |
+| §9 Rule promotion across envs — pure UI prefill + warnings | Tasks 18 (entry), 24, 25 |
+| §17 SSRF guard on outbound URL | Task 28 |
+| Final-review NIT: 30s gauge caching | Task 29 |
+| Regenerate OpenAPI schema | Tasks 3, 32 |
+| Update `.claude/rules/` | Task 31 |
+| Testing preference: REST-API-driven (not raw SQL); Playwright over real backend | Task 30 + backend IT extension in Task 28 |
+
+No uncovered requirements from the spec sections relevant to Plan 03.
+
+### 2. Placeholder scan
+
+- No "TBD" / "implement later" / "similar to Task N" / "Add appropriate error handling" patterns remain.
+- Every step with a code change includes the actual code.
+- Step stubs in Task 13 are explicitly marked as "replaced in Phase 5/6/7" — they're real code, just thin.
+- Some condition-forms (Task 21 step 4) reuse the same pattern; each form is shown in full rather than "similar to RouteMetricForm".
+
+### 3. Type consistency
+
+- `MustacheEditor` props are the same shape across all call sites: `{ value, onChange, kind?, reducedContext?, label, placeholder?, minHeight?, singleLine? }`.
+- `FormState` is declared in `form-state.ts` and used identically by all wizard steps.
+- Schema-derived types (`AlertDto`, `AlertRuleResponse`, etc.) come from `components['schemas'][...]` so they stay in sync with the backend.
+- Query hook names follow a consistent convention: `use()` (list), `use(id)` (single), `useCreate`, `useUpdate`, `useDelete`.
+
+No inconsistencies.
+
+---
+
+## Execution Handoff
+
+**Plan complete and saved to `docs/superpowers/plans/2026-04-20-alerting-03-ui.md`. Two execution options:**
+
+**1. Subagent-Driven (recommended)** — dispatch a fresh subagent per task, review between tasks, fast iteration.
+
+**2. Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints.
+
+**Which approach?**