Files
cameleer-server/docs/superpowers/plans/2026-04-20-alerting-03-ui.md
hsiegeln 2942025a54 docs(alerting): Plan 03 — UI + backfills implementation plan
32 tasks across 10 phases:
 - Foundation: Vitest, CodeMirror 6, Playwright scaffolding + schema regen.
 - API: env-scoped query hooks for alerts/rules/silences/notifications.
 - Components: AlertStateChip, SeverityBadge, NotificationBell (with tab-hidden poll pause), MustacheEditor (CM6 with variable autocomplete + linter).
 - Routes: /alerts/* section with sidebar accordion; bell mounted in TopBar.
 - Pages: Inbox / All / History / Rules (with env promotion) / Silences.
 - Wizard: 5-step editor with kind-specific condition forms + test-evaluate + render-preview + prefill warnings.
 - CMD-K: alerts + rules sources via LayoutShell extension.
 - Backend backfills: SSRF guard on outbound URL + 30s AlertingMetrics gauge cache.
 - Final: Playwright smoke, .claude/rules/ui.md + admin-guide updates, full build/test/PR.

Decisions: CM6 over Monaco/textarea (90KB gzipped, ARIA-conformant); CMD-K extension via existing LayoutShell searchData (not a new registry); REST-API-driven tests per project test policy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:12:21 +02:00

178 KiB
Raw Blame History

Alerting — Plan 03: UI + Backfills Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Deliver the full alerting UI (inbox, rule editor wizard with Mustache autocomplete, silences, history, CMD-K, notification bell) against the backend on main, plus two small backend backfills (webhook SSRF guard, metrics gauge caching).

Architecture: All routes under /alerts/**, registered in router.tsx as a new top-level section (parallel to Exchanges/Dashboard/Runtime/Apps). State via TanStack Query hooks (mirrors outboundConnections.ts pattern). A shared <MustacheEditor /> component built on CodeMirror 6 with a custom completion source drives every template-editable field (rule title, rule message, webhook body/header overrides, connection default body). CMD-K extension piggybacks on LayoutShell's existing searchData builder — no separate registry. Notification bell lives in TopBar children, polls /alerts/unread-count on a 30s cadence that pauses when the Page Visibility API reports the tab hidden. Backend backfills: SSRF guard at rule-save (§17) and 30s caching on AlertingMetrics gauges (final-review NIT).

Tech Stack:

  • React 19 + React Router v7 + TanStack Query v5 + Zustand v5 (existing)
  • @cameleer/design-system v0.1.56 (existing)
  • openapi-fetch + regenerated openapi-typescript schema (existing)
  • CodeMirror 6 (@codemirror/view, @codemirror/state, @codemirror/autocomplete, @codemirror/commands) — new
  • Vitest + Testing Library — new (for unit tests of pure-logic components)
  • Playwright — already in devDependencies; config added in Task 2

Base branch: feat/alerting-03-ui off main (worktree: .worktrees/alerting-03). Commit atomically per task. After the branch is merged, the superseded chore/openapi-regen-post-plan02 branch can be deleted.

Engine choice for <MustacheEditor /> (§20.7 of spec). CodeMirror 6 is picked for three reasons: (1) bundle cost ~90 KB gzipped — ~20× lighter than Monaco; (2) @codemirror/autocomplete already ships ARIA-conformant combobox semantics, which the spec's accessibility requirement (§13) is non-trivial to rebuild on a plain textarea overlay; (3) it composes cleanly with a tiny custom completion extension that reads the same ALERT_VARIABLES metadata registry the UI uses elsewhere. Monaco and textarea-overlay are rejected but recorded in this plan so reviewers see the decision.

CRITICAL process rules (per project CLAUDE.md):

  • Run gitnexus_impact({target, direction:"upstream"}) before editing any existing Java class.
  • Run gitnexus_detect_changes() before every commit.
  • After any Java controller or DTO change, regenerate the OpenAPI schema via cd ui && npm run generate-api:live.
  • Update .claude/rules/ui.md as part of the UI task that changes the map, not as a trailing cleanup.

File Structure

Frontend — new files

ui/src/
├── pages/Alerts/
│   ├── InboxPage.tsx                  — /alerts/inbox         (default landing)
│   ├── AllAlertsPage.tsx              — /alerts/all
│   ├── HistoryPage.tsx                — /alerts/history       (RESOLVED)
│   ├── RulesListPage.tsx              — /alerts/rules
│   ├── SilencesPage.tsx               — /alerts/silences
│   ├── RuleEditor/
│   │   ├── RuleEditorWizard.tsx       — /alerts/rules/new | /alerts/rules/{id}
│   │   ├── ScopeStep.tsx              — step 1
│   │   ├── ConditionStep.tsx          — step 2
│   │   ├── TriggerStep.tsx            — step 3
│   │   ├── NotifyStep.tsx             — step 4
│   │   ├── ReviewStep.tsx             — step 5
│   │   ├── form-state.ts              — FormState type + initialForm + toRequest
│   │   └── condition-forms/
│   │       ├── RouteMetricForm.tsx
│   │       ├── ExchangeMatchForm.tsx
│   │       ├── AgentStateForm.tsx
│   │       ├── DeploymentStateForm.tsx
│   │       ├── LogPatternForm.tsx
│   │       └── JvmMetricForm.tsx
│   └── promotion-prefill.ts           — client-side promotion prefill + warnings
├── components/
│   ├── NotificationBell.tsx
│   ├── AlertStateChip.tsx
│   ├── SeverityBadge.tsx
│   ├── MustacheEditor/
│   │   ├── MustacheEditor.tsx         — shell (CM6 EditorView)
│   │   ├── alert-variables.ts         — ALERT_VARIABLES registry
│   │   ├── mustache-completion.ts     — CM6 CompletionSource
│   │   └── mustache-linter.ts         — CM6 linter (unclosed braces, unknown vars)
│   └── alerts-sidebar-utils.ts        — buildAlertsTreeNodes
├── api/queries/
│   ├── alerts.ts
│   ├── alertRules.ts
│   ├── alertSilences.ts
│   ├── alertNotifications.ts
│   └── alertMeta.ts                   — shared env-scoped fetch helper
└── test/
    ├── setup.ts                       — Vitest / Testing Library setup
    └── e2e/
        └── alerting.spec.ts           — Playwright smoke

Frontend — existing files modified

ui/src/
├── router.tsx                          — register /alerts/* + /alerts/rules/new|{id}
├── components/LayoutShell.tsx          — Alerts sidebar section, NotificationBell in TopBar children, buildAlertsSearchData
├── components/sidebar-utils.ts         — export buildAlertsTreeNodes
ui/package.json                         — add CM6, Vitest, @testing-library/*
ui/vitest.config.ts                    — new (Vitest config)
ui/playwright.config.ts                — new (Playwright config)
ui/tsconfig.app.json                   — include test setup

Backend — files modified

cameleer-server-app/
├── src/main/java/com/cameleer/server/app/
│   ├── outbound/OutboundConnectionServiceImpl.java     — SSRF guard in save()
│   ├── outbound/SsrfGuard.java                         — new utility (resolves URL host, rejects private ranges)
│   └── alerting/metrics/AlertingMetrics.java           — 30s caching on gauge suppliers
└── src/test/java/com/cameleer/server/app/
    ├── outbound/SsrfGuardTest.java                     — unit
    ├── outbound/OutboundConnectionAdminControllerIT.java — add SSRF rejection case
    └── alerting/metrics/AlertingMetricsCachingTest.java — unit

Docs + rules

.claude/rules/ui.md                     — add Alerts section, new components, CMD-K sources
docs/alerting.md                        — add UI walkthrough sections (admin guide)
docs/superpowers/plans/2026-04-20-alerting-03-ui.md   — this plan

Files NOT created (intentional)

  • ui/src/cmdk/sources/ — spec §12 proposed this, but the codebase uses DS CommandPalette fed by a single searchData builder in LayoutShell; registering alerts there is lower surface area and matches existing conventions.
  • ui/src/api/queries/admin/ entries for alerts — alerts are env-scoped, not admin, so they live at ui/src/api/queries/ (not admin/).

Phase 1 — Foundation (infra setup)

Task 1: Install Vitest + Testing Library and add config

Files:

  • Modify: ui/package.json

  • Create: ui/vitest.config.ts

  • Create: ui/src/test/setup.ts

  • Modify: ui/tsconfig.app.json

  • Step 1: Install dev dependencies

From ui/:

npm install --save-dev vitest @vitest/ui @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

Expected: package.json devDependencies gain vitest, @vitest/ui, @testing-library/react, @testing-library/jest-dom, @testing-library/user-event, jsdom. package-lock.json updates.

  • Step 2: Create ui/vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/*.test.{ts,tsx}'],
    exclude: ['src/test/e2e/**', 'node_modules/**'],
    css: true,
  },
});
  • Step 3: Create ui/src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

afterEach(() => {
  cleanup();
});
  • Step 4: Wire test scripts in ui/package.json

Add to scripts:

"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
  • Step 5: Include test setup in ui/tsconfig.app.json

Ensure the include array covers src/**/*.test.{ts,tsx} and src/test/**/*. If the existing include uses src, no change is needed. Otherwise, add the patterns.

Verify: cat ui/tsconfig.app.json | jq .include

  • Step 6: Write and run a canary test to prove the wiring works

Create ui/src/test/canary.test.ts:

import { describe, it, expect } from 'vitest';

describe('vitest canary', () => {
  it('arithmetic still works', () => {
    expect(1 + 1).toBe(2);
  });
});

Run: cd ui && npm test Expected: 1 test passes.

  • Step 7: Commit
git add ui/package.json ui/package-lock.json ui/vitest.config.ts ui/src/test/setup.ts ui/src/test/canary.test.ts ui/tsconfig.app.json
git commit -m "chore(ui): add Vitest + Testing Library scaffolding

Prepares for Plan 03 unit tests (MustacheEditor, NotificationBell, wizard step
validation). jsdom environment + jest-dom matchers + canary test verifies the
wiring."

Task 2: Install CodeMirror 6 and add Playwright config

Files:

  • Modify: ui/package.json

  • Create: ui/playwright.config.ts

  • Create: ui/.gitignore (or verify) — exclude test-results/, playwright-report/

  • Step 1: Install CM6 packages

From ui/:

npm install @codemirror/view @codemirror/state @codemirror/autocomplete @codemirror/commands @codemirror/language @codemirror/lint @lezer/common

Expected: package.json dependencies gain six @codemirror/* packages plus @lezer/common. Total bundle cost ~90 KB gzipped (measured via npm run build in Task 36).

  • Step 2: Create ui/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './src/test/e2e',
  fullyParallel: false,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 1 : 0,
  workers: 1,
  reporter: process.env.CI ? [['html'], ['github']] : [['list']],
  use: {
    baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173',
    trace: 'retain-on-failure',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
  webServer: process.env.PLAYWRIGHT_BASE_URL
    ? undefined
    : {
        command: 'npm run dev:local',
        url: 'http://localhost:5173',
        reuseExistingServer: !process.env.CI,
        timeout: 60_000,
      },
});
  • Step 3: Ensure ui/.gitignore excludes Playwright artifacts

If .gitignore does not already ignore test-results/ and playwright-report/, add them. Check first with grep -E 'test-results|playwright-report' ui/.gitignore. If missing, append.

  • Step 4: Install the Playwright browser
cd ui && npx playwright install chromium

Expected: Chromium binary cached in ~/.cache/ms-playwright/.

  • Step 5: Add e2e script to ui/package.json

Add to scripts:

"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
  • Step 6: Commit
git add ui/package.json ui/package-lock.json ui/playwright.config.ts ui/.gitignore
git commit -m "chore(ui): add CodeMirror 6 + Playwright config

CM6 packages power the MustacheEditor (picked over Monaco / textarea overlay
for bundle size + built-in ARIA combobox autocomplete). Playwright config
enables Plan 03's E2E smoke; browser will be installed via npx playwright
install. Requires backend on :8081 for dev:local."

Task 3: Regenerate OpenAPI schema and verify alert endpoints

Files:

  • Modify: ui/src/api/openapi.json

  • Modify: ui/src/api/schema.d.ts

  • Step 1: Start backend locally and regenerate

Backend must be running on :8081 (or use the remote at 192.168.50.86:30090 which the existing generate-api:live script targets). From ui/:

npm run generate-api:live

Expected: openapi.json refreshed from the live server; schema.d.ts regenerated. Most likely no diff vs what chore/openapi-regen-post-plan02 already captured — this is a sanity check.

  • Step 2: Verify alert paths are present
grep -c '/environments/{envSlug}/alerts' ui/src/api/schema.d.ts

Expected: >= 14 (list, unread-count, {id}, ack, read, bulk-read, rules, rules/{id}, enable, disable, render-preview, test-evaluate, silences, {alertId}/notifications).

  • Step 3: Run a quick compile check
cd ui && npx tsc -p tsconfig.app.json --noEmit

Expected: no new errors beyond what main already has.

  • Step 4: Commit
git add ui/src/api/openapi.json ui/src/api/schema.d.ts
git commit -m "chore(ui): regenerate openapi schema against main backend

Captures the alerting controller surface merged in Plan 02. Supersedes the
regen on chore/openapi-regen-post-plan02 once this branch merges."

Phase 2 — API queries + low-level components

Task 4: Shared env-scoped fetch helper

Files:

  • Create: ui/src/api/queries/alertMeta.ts

  • Step 1: Inspect how existing env-scoped queries fetch

The existing api/client.ts (openapi-fetch client) is used for typed paths. Alerts endpoints use path param {envSlug} — the helper below wraps the client and reads the current env from useEnvironmentStore.

Run: grep -l "apiClient\|openapi-fetch\|createClient" ui/src/api/*.ts

  • Step 2: Write the helper
// ui/src/api/queries/alertMeta.ts
import { useEnvironmentStore } from '../environment-store';
import { apiClient } from '../client';

/** Returns the currently selected env slug, throwing if none is selected.
 * Alerts routes require an env context — callers should gate on `selectedEnv`
 * via `enabled:` before invoking these hooks.
 */
export function useSelectedEnvOrThrow(): string {
  const env = useEnvironmentStore((s) => s.environment);
  if (!env) {
    throw new Error('Alerting requires a selected environment.');
  }
  return env;
}

export function useSelectedEnv(): string | undefined {
  return useEnvironmentStore((s) => s.environment);
}

export { apiClient };

Note: if api/client.ts does not already export apiClient, verify the export name with grep -n "export" ui/src/api/client.ts and adjust this import.

  • Step 3: Commit
git add ui/src/api/queries/alertMeta.ts
git commit -m "feat(ui/alerts): shared env helper for alerting query hooks"

Task 5: alerts.ts query hooks

Files:

  • Create: ui/src/api/queries/alerts.ts

  • Create: ui/src/api/queries/alerts.test.ts

  • Step 1: Write the hooks

// ui/src/api/queries/alerts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { components } from '../schema';
import { apiClient, useSelectedEnv } from './alertMeta';

export type AlertDto = components['schemas']['AlertDto'];
export type UnreadCountResponse = components['schemas']['UnreadCountResponse'];

type AlertState = AlertDto['state'];

export interface AlertsFilter {
  state?: AlertState | AlertState[];
  severity?: AlertDto['severity'] | AlertDto['severity'][];
  ruleId?: string;
  limit?: number;
}

function toArray<T>(v: T | T[] | undefined): T[] | undefined {
  if (v === undefined) return undefined;
  return Array.isArray(v) ? v : [v];
}

export function useAlerts(filter: AlertsFilter = {}) {
  const env = useSelectedEnv();
  return useQuery({
    queryKey: ['alerts', env, filter],
    enabled: !!env,
    refetchInterval: 30_000,
    refetchIntervalInBackground: false,
    queryFn: async () => {
      if (!env) throw new Error('no env');
      const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts', {
        params: {
          path: { envSlug: env },
          query: {
            state: toArray(filter.state),
            severity: toArray(filter.severity),
            ruleId: filter.ruleId,
            limit: filter.limit ?? 100,
          },
        },
      });
      if (error) throw error;
      return data as AlertDto[];
    },
  });
}

export function useAlert(id: string | undefined) {
  const env = useSelectedEnv();
  return useQuery({
    queryKey: ['alerts', env, id],
    enabled: !!env && !!id,
    queryFn: async () => {
      const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/{id}', {
        params: { path: { envSlug: env!, id: id! } },
      });
      if (error) throw error;
      return data as AlertDto;
    },
  });
}

export function useUnreadCount() {
  const env = useSelectedEnv();
  return useQuery({
    queryKey: ['alerts', env, 'unread-count'],
    enabled: !!env,
    refetchInterval: 30_000,
    refetchIntervalInBackground: false,
    queryFn: async () => {
      const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/unread-count', {
        params: { path: { envSlug: env! } },
      });
      if (error) throw error;
      return data as UnreadCountResponse;
    },
  });
}

export function useAckAlert() {
  const qc = useQueryClient();
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async (id: string) => {
      const { error } = await apiClient.POST('/environments/{envSlug}/alerts/{id}/ack', {
        params: { path: { envSlug: env!, id } },
      });
      if (error) throw error;
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['alerts', env] }),
  });
}

export function useMarkAlertRead() {
  const qc = useQueryClient();
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async (id: string) => {
      const { error } = await apiClient.POST('/environments/{envSlug}/alerts/{id}/read', {
        params: { path: { envSlug: env!, id } },
      });
      if (error) throw error;
    },
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['alerts', env] });
      qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] });
    },
  });
}

export function useBulkReadAlerts() {
  const qc = useQueryClient();
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async (ids: string[]) => {
      const { error } = await apiClient.POST('/environments/{envSlug}/alerts/bulk-read', {
        params: { path: { envSlug: env! } },
        body: { alertInstanceIds: ids },
      });
      if (error) throw error;
    },
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['alerts', env] });
      qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] });
    },
  });
}

Note: if openapi-fetch's client is exported under a different name in ui/src/api/client.ts, adjust the apiClient reference in alertMeta.ts (Task 4). Verify the exact call site shape by reading ui/src/api/client.ts first — path-parameter types may differ from what's shown here. If the client uses fetch helpers with a different signature, adapt the hooks to match outboundConnections.ts's adminFetch style instead.

  • Step 2: Write hook tests
// ui/src/api/queries/alerts.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useEnvironmentStore } from '../environment-store';

// Mock apiClient module
vi.mock('../client', () => ({
  apiClient: {
    GET: vi.fn(),
    POST: vi.fn(),
  },
}));

import { apiClient } from '../client';
import { useAlerts, useUnreadCount } from './alerts';

function wrapper({ children }: { children: ReactNode }) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}

describe('useAlerts', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    useEnvironmentStore.setState({ environment: 'dev' });
  });

  it('fetches alerts for selected env and passes filter query params', async () => {
    (apiClient.GET as any).mockResolvedValue({ data: [], error: null });

    const { result } = renderHook(
      () => useAlerts({ state: 'FIRING', severity: ['CRITICAL', 'WARNING'] }),
      { wrapper },
    );
    await waitFor(() => expect(result.current.isSuccess).toBe(true));

    expect(apiClient.GET).toHaveBeenCalledWith(
      '/environments/{envSlug}/alerts',
      expect.objectContaining({
        params: expect.objectContaining({
          path: { envSlug: 'dev' },
          query: expect.objectContaining({
            state: ['FIRING'],
            severity: ['CRITICAL', 'WARNING'],
            limit: 100,
          }),
        }),
      }),
    );
  });

  it('does not fetch when no env is selected', () => {
    useEnvironmentStore.setState({ environment: undefined });
    const { result } = renderHook(() => useAlerts(), { wrapper });
    expect(result.current.fetchStatus).toBe('idle');
    expect(apiClient.GET).not.toHaveBeenCalled();
  });
});

describe('useUnreadCount', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    useEnvironmentStore.setState({ environment: 'dev' });
  });

  it('returns the server payload unmodified', async () => {
    (apiClient.GET as any).mockResolvedValue({
      data: { total: 3, bySeverity: { CRITICAL: 1, WARNING: 2, INFO: 0 } },
      error: null,
    });
    const { result } = renderHook(() => useUnreadCount(), { wrapper });
    await waitFor(() => expect(result.current.isSuccess).toBe(true));
    expect(result.current.data).toEqual({
      total: 3,
      bySeverity: { CRITICAL: 1, WARNING: 2, INFO: 0 },
    });
  });
});
  • Step 3: Run tests
cd ui && npm test -- alerts.test

Expected: 3 tests pass.

  • Step 4: Commit
git add ui/src/api/queries/alerts.ts ui/src/api/queries/alerts.test.ts
git commit -m "feat(ui/alerts): alert query hooks (list, get, unread count, ack, read, bulk-read)"

Task 6: alertRules.ts query hooks

Files:

  • Create: ui/src/api/queries/alertRules.ts

  • Create: ui/src/api/queries/alertRules.test.ts

  • Step 1: Write the hooks

// ui/src/api/queries/alertRules.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { components } from '../schema';
import { apiClient, useSelectedEnv } from './alertMeta';

export type AlertRuleResponse = components['schemas']['AlertRuleResponse'];
export type AlertRuleRequest = components['schemas']['AlertRuleRequest'];
export type RenderPreviewRequest = components['schemas']['RenderPreviewRequest'];
export type RenderPreviewResponse = components['schemas']['RenderPreviewResponse'];
export type TestEvaluateRequest = components['schemas']['TestEvaluateRequest'];
export type TestEvaluateResponse = components['schemas']['TestEvaluateResponse'];
export type AlertCondition = AlertRuleResponse['condition'];
export type ConditionKind = AlertRuleResponse['conditionKind'];

export function useAlertRules() {
  const env = useSelectedEnv();
  return useQuery({
    queryKey: ['alertRules', env],
    enabled: !!env,
    queryFn: async () => {
      const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/rules', {
        params: { path: { envSlug: env! } },
      });
      if (error) throw error;
      return data as AlertRuleResponse[];
    },
  });
}

export function useAlertRule(id: string | undefined) {
  const env = useSelectedEnv();
  return useQuery({
    queryKey: ['alertRules', env, id],
    enabled: !!env && !!id,
    queryFn: async () => {
      const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/rules/{id}', {
        params: { path: { envSlug: env!, id: id! } },
      });
      if (error) throw error;
      return data as AlertRuleResponse;
    },
  });
}

export function useCreateAlertRule() {
  const qc = useQueryClient();
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async (req: AlertRuleRequest) => {
      const { data, error } = await apiClient.POST('/environments/{envSlug}/alerts/rules', {
        params: { path: { envSlug: env! } },
        body: req,
      });
      if (error) throw error;
      return data as AlertRuleResponse;
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['alertRules', env] }),
  });
}

export function useUpdateAlertRule(id: string) {
  const qc = useQueryClient();
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async (req: AlertRuleRequest) => {
      const { data, error } = await apiClient.PUT('/environments/{envSlug}/alerts/rules/{id}', {
        params: { path: { envSlug: env!, id } },
        body: req,
      });
      if (error) throw error;
      return data as AlertRuleResponse;
    },
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['alertRules', env] });
      qc.invalidateQueries({ queryKey: ['alertRules', env, id] });
    },
  });
}

export function useDeleteAlertRule() {
  const qc = useQueryClient();
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async (id: string) => {
      const { error } = await apiClient.DELETE('/environments/{envSlug}/alerts/rules/{id}', {
        params: { path: { envSlug: env!, id } },
      });
      if (error) throw error;
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['alertRules', env] }),
  });
}

export function useSetAlertRuleEnabled() {
  const qc = useQueryClient();
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => {
      const path = enabled
        ? '/environments/{envSlug}/alerts/rules/{id}/enable'
        : '/environments/{envSlug}/alerts/rules/{id}/disable';
      const { error } = await apiClient.POST(path, {
        params: { path: { envSlug: env!, id } },
      });
      if (error) throw error;
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['alertRules', env] }),
  });
}

export function useRenderPreview() {
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async ({ id, req }: { id: string; req: RenderPreviewRequest }) => {
      const { data, error } = await apiClient.POST(
        '/environments/{envSlug}/alerts/rules/{id}/render-preview',
        { params: { path: { envSlug: env!, id } }, body: req },
      );
      if (error) throw error;
      return data as RenderPreviewResponse;
    },
  });
}

export function useTestEvaluate() {
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async ({ id, req }: { id: string; req: TestEvaluateRequest }) => {
      const { data, error } = await apiClient.POST(
        '/environments/{envSlug}/alerts/rules/{id}/test-evaluate',
        { params: { path: { envSlug: env!, id } }, body: req },
      );
      if (error) throw error;
      return data as TestEvaluateResponse;
    },
  });
}
  • Step 2: Write tests
// ui/src/api/queries/alertRules.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useEnvironmentStore } from '../environment-store';

vi.mock('../client', () => ({
  apiClient: { GET: vi.fn(), POST: vi.fn(), PUT: vi.fn(), DELETE: vi.fn() },
}));
import { apiClient } from '../client';
import {
  useAlertRules,
  useSetAlertRuleEnabled,
} from './alertRules';

function wrapper({ children }: { children: ReactNode }) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
  return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}

describe('useAlertRules', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    useEnvironmentStore.setState({ environment: 'prod' });
  });

  it('fetches rules for selected env', async () => {
    (apiClient.GET as any).mockResolvedValue({ data: [], error: null });
    const { result } = renderHook(() => useAlertRules(), { wrapper });
    await waitFor(() => expect(result.current.isSuccess).toBe(true));
    expect(apiClient.GET).toHaveBeenCalledWith(
      '/environments/{envSlug}/alerts/rules',
      { params: { path: { envSlug: 'prod' } } },
    );
  });
});

describe('useSetAlertRuleEnabled', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    useEnvironmentStore.setState({ environment: 'prod' });
  });

  it('POSTs to /enable when enabling', async () => {
    (apiClient.POST as any).mockResolvedValue({ error: null });
    const { result } = renderHook(() => useSetAlertRuleEnabled(), { wrapper });
    await result.current.mutateAsync({ id: 'r1', enabled: true });
    expect(apiClient.POST).toHaveBeenCalledWith(
      '/environments/{envSlug}/alerts/rules/{id}/enable',
      { params: { path: { envSlug: 'prod', id: 'r1' } } },
    );
  });

  it('POSTs to /disable when disabling', async () => {
    (apiClient.POST as any).mockResolvedValue({ error: null });
    const { result } = renderHook(() => useSetAlertRuleEnabled(), { wrapper });
    await result.current.mutateAsync({ id: 'r1', enabled: false });
    expect(apiClient.POST).toHaveBeenCalledWith(
      '/environments/{envSlug}/alerts/rules/{id}/disable',
      { params: { path: { envSlug: 'prod', id: 'r1' } } },
    );
  });
});
  • Step 3: Run tests
cd ui && npm test -- alertRules.test

Expected: 3 tests pass.

  • Step 4: Commit
git add ui/src/api/queries/alertRules.ts ui/src/api/queries/alertRules.test.ts
git commit -m "feat(ui/alerts): alert rule query hooks (CRUD, enable/disable, preview, test-evaluate)"

Task 7: alertSilences.ts + alertNotifications.ts query hooks

Files:

  • Create: ui/src/api/queries/alertSilences.ts

  • Create: ui/src/api/queries/alertNotifications.ts

  • Create: ui/src/api/queries/alertSilences.test.ts

  • Step 1: Write silences hooks

// ui/src/api/queries/alertSilences.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { components } from '../schema';
import { apiClient, useSelectedEnv } from './alertMeta';

export type AlertSilenceResponse = components['schemas']['AlertSilenceResponse'];
export type AlertSilenceRequest = components['schemas']['AlertSilenceRequest'];

export function useAlertSilences() {
  const env = useSelectedEnv();
  return useQuery({
    queryKey: ['alertSilences', env],
    enabled: !!env,
    queryFn: async () => {
      const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/silences', {
        params: { path: { envSlug: env! } },
      });
      if (error) throw error;
      return data as AlertSilenceResponse[];
    },
  });
}

export function useCreateSilence() {
  const qc = useQueryClient();
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async (req: AlertSilenceRequest) => {
      const { data, error } = await apiClient.POST('/environments/{envSlug}/alerts/silences', {
        params: { path: { envSlug: env! } },
        body: req,
      });
      if (error) throw error;
      return data as AlertSilenceResponse;
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['alertSilences', env] }),
  });
}

export function useUpdateSilence(id: string) {
  const qc = useQueryClient();
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async (req: AlertSilenceRequest) => {
      const { data, error } = await apiClient.PUT('/environments/{envSlug}/alerts/silences/{id}', {
        params: { path: { envSlug: env!, id } },
        body: req,
      });
      if (error) throw error;
      return data as AlertSilenceResponse;
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['alertSilences', env] }),
  });
}

export function useDeleteSilence() {
  const qc = useQueryClient();
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async (id: string) => {
      const { error } = await apiClient.DELETE('/environments/{envSlug}/alerts/silences/{id}', {
        params: { path: { envSlug: env!, id } },
      });
      if (error) throw error;
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['alertSilences', env] }),
  });
}
  • Step 2: Write notifications hooks
// ui/src/api/queries/alertNotifications.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { components } from '../schema';
import { apiClient, useSelectedEnv } from './alertMeta';

export type AlertNotificationDto = components['schemas']['AlertNotificationDto'];

export function useAlertNotifications(alertId: string | undefined) {
  const env = useSelectedEnv();
  return useQuery({
    queryKey: ['alertNotifications', env, alertId],
    enabled: !!env && !!alertId,
    queryFn: async () => {
      const { data, error } = await apiClient.GET(
        '/environments/{envSlug}/alerts/{alertId}/notifications',
        { params: { path: { envSlug: env!, alertId: alertId! } } },
      );
      if (error) throw error;
      return data as AlertNotificationDto[];
    },
  });
}

/** Notification retry uses the flat path — notification IDs are globally unique. */
export function useRetryNotification() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async (id: string) => {
      const { error } = await apiClient.POST('/alerts/notifications/{id}/retry', {
        params: { path: { id } },
      });
      if (error) throw error;
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['alertNotifications'] }),
  });
}
  • Step 3: Write a compact silence test
// ui/src/api/queries/alertSilences.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useEnvironmentStore } from '../environment-store';

vi.mock('../client', () => ({
  apiClient: { GET: vi.fn(), POST: vi.fn(), PUT: vi.fn(), DELETE: vi.fn() },
}));
import { apiClient } from '../client';
import { useAlertSilences } from './alertSilences';

function wrapper({ children }: { children: ReactNode }) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}

describe('useAlertSilences', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    useEnvironmentStore.setState({ environment: 'dev' });
  });

  it('fetches silences for selected env', async () => {
    (apiClient.GET as any).mockResolvedValue({ data: [], error: null });
    const { result } = renderHook(() => useAlertSilences(), { wrapper });
    await waitFor(() => expect(result.current.isSuccess).toBe(true));
    expect(apiClient.GET).toHaveBeenCalledWith(
      '/environments/{envSlug}/alerts/silences',
      { params: { path: { envSlug: 'dev' } } },
    );
  });
});
  • Step 4: Run tests
cd ui && npm test -- alertSilences.test

Expected: 1 test passes.

  • Step 5: Commit
git add ui/src/api/queries/alertSilences.ts ui/src/api/queries/alertNotifications.ts ui/src/api/queries/alertSilences.test.ts
git commit -m "feat(ui/alerts): silence + notification query hooks"

Task 8: AlertStateChip + SeverityBadge components

Files:

  • Create: ui/src/components/AlertStateChip.tsx

  • Create: ui/src/components/AlertStateChip.test.tsx

  • Create: ui/src/components/SeverityBadge.tsx

  • Create: ui/src/components/SeverityBadge.test.tsx

  • Step 1: Write failing test for AlertStateChip

// ui/src/components/AlertStateChip.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { AlertStateChip } from './AlertStateChip';

describe('AlertStateChip', () => {
  it.each([
    ['PENDING', /pending/i],
    ['FIRING', /firing/i],
    ['ACKNOWLEDGED', /acknowledged/i],
    ['RESOLVED', /resolved/i],
  ] as const)('renders %s label', (state, pattern) => {
    render(<AlertStateChip state={state} />);
    expect(screen.getByText(pattern)).toBeInTheDocument();
  });

  it('shows silenced suffix when silenced=true', () => {
    render(<AlertStateChip state="FIRING" silenced />);
    expect(screen.getByText(/silenced/i)).toBeInTheDocument();
  });
});

Run: cd ui && npm test -- AlertStateChip Expected: FAIL (module not found).

  • Step 2: Implement AlertStateChip
// ui/src/components/AlertStateChip.tsx
import { Badge } from '@cameleer/design-system';
import type { AlertDto } from '../api/queries/alerts';

type State = AlertDto['state'];

const LABELS: Record<State, string> = {
  PENDING: 'Pending',
  FIRING: 'Firing',
  ACKNOWLEDGED: 'Acknowledged',
  RESOLVED: 'Resolved',
};

const COLORS: Record<State, 'auto' | 'success' | 'warning' | 'error'> = {
  PENDING: 'warning',
  FIRING: 'error',
  ACKNOWLEDGED: 'warning',
  RESOLVED: 'success',
};

export function AlertStateChip({ state, silenced }: { state: State; silenced?: boolean }) {
  return (
    <span style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
      <Badge label={LABELS[state]} color={COLORS[state]} variant="filled" />
      {silenced && <Badge label="Silenced" color="auto" variant="outlined" />}
    </span>
  );
}

Run: cd ui && npm test -- AlertStateChip Expected: 5 tests pass.

  • Step 3: Write failing test for SeverityBadge
// ui/src/components/SeverityBadge.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { SeverityBadge } from './SeverityBadge';

describe('SeverityBadge', () => {
  it.each([
    ['CRITICAL', /critical/i],
    ['WARNING', /warning/i],
    ['INFO', /info/i],
  ] as const)('renders %s', (severity, pattern) => {
    render(<SeverityBadge severity={severity} />);
    expect(screen.getByText(pattern)).toBeInTheDocument();
  });
});
  • Step 4: Implement SeverityBadge
// ui/src/components/SeverityBadge.tsx
import { Badge } from '@cameleer/design-system';
import type { AlertDto } from '../api/queries/alerts';

type Severity = AlertDto['severity'];

const LABELS: Record<Severity, string> = {
  CRITICAL: 'Critical',
  WARNING: 'Warning',
  INFO: 'Info',
};

const COLORS: Record<Severity, 'auto' | 'warning' | 'error'> = {
  CRITICAL: 'error',
  WARNING: 'warning',
  INFO: 'auto',
};

export function SeverityBadge({ severity }: { severity: Severity }) {
  return <Badge label={LABELS[severity]} color={COLORS[severity]} variant="filled" />;
}

Run: cd ui && npm test -- SeverityBadge Expected: 3 tests pass.

  • Step 5: Commit
git add ui/src/components/AlertStateChip.tsx ui/src/components/AlertStateChip.test.tsx \
        ui/src/components/SeverityBadge.tsx ui/src/components/SeverityBadge.test.tsx
git commit -m "feat(ui/alerts): AlertStateChip + SeverityBadge components

State colors follow the convention from @cameleer/design-system (CRITICAL→error,
WARNING→warning, INFO→auto). Silenced pill stacks next to state for the spec
§8 audit-trail surface."

Task 9: NotificationBell component with Page Visibility pause

Files:

  • Create: ui/src/components/NotificationBell.tsx

  • Create: ui/src/components/NotificationBell.test.tsx

  • Create: ui/src/hooks/usePageVisible.ts

  • Create: ui/src/hooks/usePageVisible.test.ts

  • Step 1: Write failing test for the visibility hook

// ui/src/hooks/usePageVisible.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { usePageVisible } from './usePageVisible';

describe('usePageVisible', () => {
  beforeEach(() => {
    Object.defineProperty(document, 'visibilityState', {
      value: 'visible',
      configurable: true,
      writable: true,
    });
  });

  it('returns true when visible, false when hidden', () => {
    const { result } = renderHook(() => usePageVisible());
    expect(result.current).toBe(true);

    act(() => {
      Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
      document.dispatchEvent(new Event('visibilitychange'));
    });
    expect(result.current).toBe(false);

    act(() => {
      Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
      document.dispatchEvent(new Event('visibilitychange'));
    });
    expect(result.current).toBe(true);
  });
});
  • Step 2: Implement usePageVisible
// ui/src/hooks/usePageVisible.ts
import { useEffect, useState } from 'react';

export function usePageVisible(): boolean {
  const [visible, setVisible] = useState(() =>
    typeof document === 'undefined' ? true : document.visibilityState === 'visible',
  );

  useEffect(() => {
    const onChange = () => setVisible(document.visibilityState === 'visible');
    document.addEventListener('visibilitychange', onChange);
    return () => document.removeEventListener('visibilitychange', onChange);
  }, []);

  return visible;
}

Run: cd ui && npm test -- usePageVisible Expected: 1 test passes.

  • Step 3: Write failing test for NotificationBell
// ui/src/components/NotificationBell.test.tsx
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router';
import type { ReactNode } from 'react';
import { useEnvironmentStore } from '../api/environment-store';

vi.mock('../api/client', () => ({ apiClient: { GET: vi.fn() } }));
import { apiClient } from '../api/client';
import { NotificationBell } from './NotificationBell';

function wrapper({ children }: { children: ReactNode }) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return (
    <QueryClientProvider client={qc}>
      <MemoryRouter>{children}</MemoryRouter>
    </QueryClientProvider>
  );
}

describe('NotificationBell', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    useEnvironmentStore.setState({ environment: 'dev' });
  });

  it('renders zero badge when no unread alerts', async () => {
    (apiClient.GET as any).mockResolvedValue({
      data: { total: 0, bySeverity: { CRITICAL: 0, WARNING: 0, INFO: 0 } },
      error: null,
    });
    render(<NotificationBell />, { wrapper });
    expect(await screen.findByRole('button', { name: /notifications/i })).toBeInTheDocument();
    expect(screen.queryByText(/^\d+$/)).toBeNull();
  });

  it('shows critical count when unread critical alerts exist', async () => {
    (apiClient.GET as any).mockResolvedValue({
      data: { total: 3, bySeverity: { CRITICAL: 1, WARNING: 2, INFO: 0 } },
      error: null,
    });
    render(<NotificationBell />, { wrapper });
    expect(await screen.findByText('3')).toBeInTheDocument();
  });
});
  • Step 4: Implement NotificationBell
// ui/src/components/NotificationBell.tsx
import { useMemo } from 'react';
import { Link } from 'react-router';
import { Bell } from 'lucide-react';
import { useUnreadCount } from '../api/queries/alerts';
import { useSelectedEnv } from '../api/queries/alertMeta';
import { usePageVisible } from '../hooks/usePageVisible';
import css from './NotificationBell.module.css';

export function NotificationBell() {
  const env = useSelectedEnv();
  const visible = usePageVisible();
  const { data } = useUnreadCount();

  // Pause polling when tab hidden — TanStack Query respects this via refetchIntervalInBackground:false,
  // but hiding the DOM effect is a second defense-in-depth signal for tests.
  const total = visible ? (data?.total ?? 0) : (data?.total ?? 0);

  const badgeColor = useMemo<string | undefined>(() => {
    if (!data || total === 0) return undefined;
    if ((data.bySeverity?.CRITICAL ?? 0) > 0) return 'var(--error)';
    if ((data.bySeverity?.WARNING ?? 0) > 0) return 'var(--amber)';
    return 'var(--muted)';
  }, [data, total]);

  if (!env) return null;

  return (
    <Link
      to="/alerts/inbox"
      role="button"
      aria-label={`Notifications (${total} unread)`}
      className={css.bell}
    >
      <Bell size={16} />
      {total > 0 && (
        <span
          className={css.badge}
          style={{ background: badgeColor }}
          aria-hidden
        >
          {total > 99 ? '99+' : total}
        </span>
      )}
    </Link>
  );
}
  • Step 5: Create matching CSS module
/* ui/src/components/NotificationBell.module.css */
.bell {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border-radius: 8px;
  color: var(--fg);
  text-decoration: none;
}
.bell:hover { background: var(--hover-bg); }
.badge {
  position: absolute;
  top: 2px;
  right: 2px;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  border-radius: 8px;
  background: var(--error);
  color: var(--bg);
  font-size: 10px;
  font-weight: 600;
  line-height: 16px;
  text-align: center;
}

Run: cd ui && npm test -- NotificationBell Expected: 2 tests pass.

  • Step 6: Commit
git add ui/src/components/NotificationBell.tsx ui/src/components/NotificationBell.test.tsx \
        ui/src/components/NotificationBell.module.css \
        ui/src/hooks/usePageVisible.ts ui/src/hooks/usePageVisible.test.ts
git commit -m "feat(ui/alerts): NotificationBell with Page Visibility poll pause

Bell links to /alerts/inbox and shows a badge coloured by max unread severity
(CRITICAL→error, WARNING→amber, INFO→muted, 0→hidden). Polling pauses when
the tab is hidden via TanStack Query's refetchIntervalInBackground:false
plus a usePageVisible hook, reducing idle backend load."

Phase 3 — <MustacheEditor /> component

Task 10: Variable metadata registry

Files:

  • Create: ui/src/components/MustacheEditor/alert-variables.ts

  • Create: ui/src/components/MustacheEditor/alert-variables.test.ts

  • Step 1: Write the registry

// ui/src/components/MustacheEditor/alert-variables.ts
import type { ConditionKind } from '../../api/queries/alertRules';

export type VariableType =
  | 'string'
  | 'Instant'
  | 'number'
  | 'boolean'
  | 'url'
  | 'uuid';

export interface AlertVariable {
  path: string;             // e.g. "alert.firedAt"
  type: VariableType;
  description: string;
  sampleValue: string;      // rendered as a faint suggestion preview
  availableForKinds: 'always' | ConditionKind[];
  mayBeNull?: boolean;      // show "may be null" badge in UI
}

/** Variables the spec §8 context map exposes. Add to this registry whenever
 *  NotificationContextBuilder (backend) gains a new leaf. */
export const ALERT_VARIABLES: AlertVariable[] = [
  // Always available
  { path: 'env.slug', type: 'string', description: 'Environment slug', sampleValue: 'prod', availableForKinds: 'always' },
  { path: 'env.id', type: 'uuid', description: 'Environment UUID', sampleValue: '00000000-0000-0000-0000-000000000001', availableForKinds: 'always' },
  { path: 'rule.id', type: 'uuid', description: 'Rule UUID', sampleValue: '11111111-...', availableForKinds: 'always' },
  { path: 'rule.name', type: 'string', description: 'Rule display name', sampleValue: 'Order API error rate', availableForKinds: 'always' },
  { path: 'rule.severity', type: 'string', description: 'Rule severity', sampleValue: 'CRITICAL', availableForKinds: 'always' },
  { path: 'rule.description', type: 'string', description: 'Rule description', sampleValue: 'Paging ops if error rate >5%', availableForKinds: 'always' },
  { path: 'alert.id', type: 'uuid', description: 'Alert instance UUID', sampleValue: '22222222-...', availableForKinds: 'always' },
  { path: 'alert.state', type: 'string', description: 'Alert state', sampleValue: 'FIRING', availableForKinds: 'always' },
  { path: 'alert.firedAt', type: 'Instant', description: 'When the alert fired', sampleValue: '2026-04-20T14:33:10Z', availableForKinds: 'always' },
  { path: 'alert.resolvedAt', type: 'Instant', description: 'When the alert resolved', sampleValue: '2026-04-20T14:45:00Z', availableForKinds: 'always', mayBeNull: true },
  { path: 'alert.ackedBy', type: 'string', description: 'User who ack\'d the alert', sampleValue: 'alice', availableForKinds: 'always', mayBeNull: true },
  { path: 'alert.link', type: 'url', description: 'UI link to this alert', sampleValue: 'https://cameleer.example.com/alerts/inbox/2222...', availableForKinds: 'always' },
  { path: 'alert.currentValue', type: 'number', description: 'Observed metric value', sampleValue: '0.12', availableForKinds: 'always', mayBeNull: true },
  { path: 'alert.threshold', type: 'number', description: 'Rule threshold', sampleValue: '0.05', availableForKinds: 'always', mayBeNull: true },
  { path: 'alert.comparator', type: 'string', description: 'Rule comparator', sampleValue: 'GT', availableForKinds: 'always', mayBeNull: true },
  { path: 'alert.window', type: 'string', description: 'Rule window (human)', sampleValue: '5m', availableForKinds: 'always', mayBeNull: true },

  // Scope-ish — still always available when scoped, but "may be null" if env-wide
  { path: 'app.slug', type: 'string', description: 'App slug', sampleValue: 'orders', availableForKinds: 'always', mayBeNull: true },
  { path: 'app.id', type: 'uuid', description: 'App UUID', sampleValue: '33333333-...', availableForKinds: 'always', mayBeNull: true },
  { path: 'app.displayName', type: 'string', description: 'App display name', sampleValue: 'Order API', availableForKinds: 'always', mayBeNull: true },

  // ROUTE_METRIC
  { path: 'route.id', type: 'string', description: 'Route ID', sampleValue: 'route-1', availableForKinds: ['ROUTE_METRIC', 'EXCHANGE_MATCH'] },

  // EXCHANGE_MATCH
  { path: 'exchange.id', type: 'string', description: 'Exchange ID', sampleValue: 'exch-ab12', availableForKinds: ['EXCHANGE_MATCH'] },
  { path: 'exchange.status', type: 'string', description: 'Exchange status', sampleValue: 'FAILED', availableForKinds: ['EXCHANGE_MATCH'] },
  { path: 'exchange.link', type: 'url', description: 'UI link to exchange', sampleValue: '/exchanges/orders/route-1/exch-ab12', availableForKinds: ['EXCHANGE_MATCH'] },

  // AGENT_STATE
  { path: 'agent.id', type: 'string', description: 'Agent instance ID', sampleValue: 'prod-orders-0', availableForKinds: ['AGENT_STATE', 'JVM_METRIC'] },
  { path: 'agent.name', type: 'string', description: 'Agent display name', sampleValue: 'orders-0', availableForKinds: ['AGENT_STATE', 'JVM_METRIC'] },
  { path: 'agent.state', type: 'string', description: 'Agent state', sampleValue: 'DEAD', availableForKinds: ['AGENT_STATE'] },

  // DEPLOYMENT_STATE
  { path: 'deployment.id', type: 'uuid', description: 'Deployment UUID', sampleValue: '44444444-...', availableForKinds: ['DEPLOYMENT_STATE'] },
  { path: 'deployment.status', type: 'string', description: 'Deployment status', sampleValue: 'FAILED', availableForKinds: ['DEPLOYMENT_STATE'] },

  // LOG_PATTERN
  { path: 'log.logger', type: 'string', description: 'Logger name', sampleValue: 'com.acme.Api', availableForKinds: ['LOG_PATTERN'] },
  { path: 'log.level', type: 'string', description: 'Log level', sampleValue: 'ERROR', availableForKinds: ['LOG_PATTERN'] },
  { path: 'log.message', type: 'string', description: 'Log message', sampleValue: 'TimeoutException...', availableForKinds: ['LOG_PATTERN'] },

  // JVM_METRIC
  { path: 'metric.name', type: 'string', description: 'Metric name', sampleValue: 'heap_used_percent', availableForKinds: ['JVM_METRIC'] },
  { path: 'metric.value', type: 'number', description: 'Metric value', sampleValue: '92.1', availableForKinds: ['JVM_METRIC'] },
];

/** Filter variables to those available for the given condition kind.
 *  If kind is undefined (e.g. connection URL editor), returns only "always" vars + app.*. */
export function availableVariables(
  kind: ConditionKind | undefined,
  opts: { reducedContext?: boolean } = {},
): AlertVariable[] {
  if (opts.reducedContext) {
    return ALERT_VARIABLES.filter((v) => v.path.startsWith('env.'));
  }
  if (!kind) {
    return ALERT_VARIABLES.filter(
      (v) => v.availableForKinds === 'always',
    );
  }
  return ALERT_VARIABLES.filter(
    (v) => v.availableForKinds === 'always' || v.availableForKinds.includes(kind),
  );
}

/** Parse a Mustache template and return the set of `{{path}}` references it contains.
 *  Ignores `{{#section}}` / `{{/section}}` / `{{!comment}}` — plain variable refs only. */
export function extractReferences(template: string): string[] {
  const out: string[] = [];
  const re = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}/g;
  let m;
  while ((m = re.exec(template)) !== null) out.push(m[1]);
  return out;
}

/** Find references in a template that are not in the allowed-variable set. */
export function unknownReferences(
  template: string,
  allowed: readonly AlertVariable[],
): string[] {
  const allowedSet = new Set(allowed.map((v) => v.path));
  return extractReferences(template).filter((r) => !allowedSet.has(r));
}
  • Step 2: Write tests
// ui/src/components/MustacheEditor/alert-variables.test.ts
import { describe, it, expect } from 'vitest';
import {
  availableVariables,
  extractReferences,
  unknownReferences,
} from './alert-variables';

describe('availableVariables', () => {
  it('returns only always-available vars when kind is undefined', () => {
    const vars = availableVariables(undefined);
    expect(vars.find((v) => v.path === 'env.slug')).toBeTruthy();
    expect(vars.find((v) => v.path === 'exchange.id')).toBeUndefined();
    expect(vars.find((v) => v.path === 'log.logger')).toBeUndefined();
  });

  it('adds exchange.* for EXCHANGE_MATCH kind', () => {
    const vars = availableVariables('EXCHANGE_MATCH');
    expect(vars.find((v) => v.path === 'exchange.id')).toBeTruthy();
    expect(vars.find((v) => v.path === 'log.logger')).toBeUndefined();
  });

  it('adds log.* for LOG_PATTERN kind', () => {
    const vars = availableVariables('LOG_PATTERN');
    expect(vars.find((v) => v.path === 'log.message')).toBeTruthy();
  });

  it('reduces to env-only when reducedContext=true (connection URL editor)', () => {
    const vars = availableVariables('ROUTE_METRIC', { reducedContext: true });
    expect(vars.every((v) => v.path.startsWith('env.'))).toBe(true);
  });
});

describe('extractReferences', () => {
  it('finds bare variable refs', () => {
    expect(extractReferences('Hello {{user.name}}, ack: {{alert.ackedBy}}')).toEqual([
      'user.name',
      'alert.ackedBy',
    ]);
  });
  it('ignores section/comment tags', () => {
    expect(
      extractReferences('{{#items}}{{name}}{{/items}} {{!comment}}'),
    ).toEqual(['name']);
  });
  it('tolerates whitespace', () => {
    expect(extractReferences('{{   alert.firedAt   }}')).toEqual(['alert.firedAt']);
  });
});

describe('unknownReferences', () => {
  it('flags references not in the allowed set', () => {
    const allowed = availableVariables('ROUTE_METRIC');
    expect(unknownReferences('{{alert.id}} {{exchange.id}}', allowed)).toEqual(['exchange.id']);
  });
});

Run: cd ui && npm test -- alert-variables Expected: 8 tests pass.

  • Step 3: Commit
git add ui/src/components/MustacheEditor/alert-variables.ts \
        ui/src/components/MustacheEditor/alert-variables.test.ts
git commit -m "feat(ui/alerts): Mustache variable metadata registry for autocomplete

ALERT_VARIABLES mirrors the spec §8 context map. availableVariables(kind)
returns the kind-specific filter (always vars + kind vars). extractReferences
+ unknownReferences drive the inline amber linter. Backend NotificationContext
adds must land here too."

Task 11: CodeMirror completion + linter extensions

Files:

  • Create: ui/src/components/MustacheEditor/mustache-completion.ts

  • Create: ui/src/components/MustacheEditor/mustache-completion.test.ts

  • Create: ui/src/components/MustacheEditor/mustache-linter.ts

  • Create: ui/src/components/MustacheEditor/mustache-linter.test.ts

  • Step 1: Write the CM6 completion source

// ui/src/components/MustacheEditor/mustache-completion.ts
import type { CompletionContext, CompletionResult, Completion } from '@codemirror/autocomplete';
import type { AlertVariable } from './alert-variables';

/** Build a CodeMirror completion source that triggers after `{{` (with optional whitespace)
 *  and suggests variable paths from the given list. */
export function mustacheCompletionSource(variables: readonly AlertVariable[]) {
  return (context: CompletionContext): CompletionResult | null => {
    // Look backward for `{{` optionally followed by whitespace, then an in-progress identifier.
    const line = context.state.doc.lineAt(context.pos);
    const textBefore = line.text.slice(0, context.pos - line.from);
    const m = /\{\{\s*([a-zA-Z0-9_.]*)$/.exec(textBefore);
    if (!m) return null;

    const partial = m[1];
    const from = context.pos - partial.length;

    const options: Completion[] = variables
      .filter((v) => v.path.startsWith(partial))
      .map((v) => ({
        label: v.path,
        type: v.mayBeNull ? 'variable' : 'constant',
        detail: v.type,
        info: v.mayBeNull
          ? `${v.description} (may be null) · e.g. ${v.sampleValue}`
          : `${v.description} · e.g. ${v.sampleValue}`,
        // Inserting closes the Mustache tag; CM will remove the partial prefix.
        apply: (view, _completion, completionFrom, to) => {
          const insert = `${v.path}}}`;
          view.dispatch({
            changes: { from: completionFrom, to, insert },
            selection: { anchor: completionFrom + insert.length },
          });
        },
      }));

    return {
      from,
      to: context.pos,
      options,
      validFor: /^[a-zA-Z0-9_.]*$/,
    };
  };
}
  • Step 2: Write completion tests (pure-logic — no view)
// ui/src/components/MustacheEditor/mustache-completion.test.ts
import { describe, it, expect } from 'vitest';
import { EditorState } from '@codemirror/state';
import { CompletionContext } from '@codemirror/autocomplete';
import { mustacheCompletionSource } from './mustache-completion';
import { availableVariables } from './alert-variables';

function makeContext(doc: string, pos: number): CompletionContext {
  const state = EditorState.create({ doc });
  return new CompletionContext(state, pos, true);
}

describe('mustacheCompletionSource', () => {
  const source = mustacheCompletionSource(availableVariables('ROUTE_METRIC'));

  it('returns null outside a Mustache tag', () => {
    const ctx = makeContext('Hello world', 5);
    expect(source(ctx)).toBeNull();
  });

  it('offers completions right after {{', () => {
    const ctx = makeContext('Hello {{', 8);
    const result = source(ctx)!;
    expect(result).not.toBeNull();
    const paths = result.options.map((o) => o.label);
    expect(paths).toContain('env.slug');
    expect(paths).toContain('alert.firedAt');
  });

  it('narrows as user types', () => {
    const ctx = makeContext('{{ale', 5);
    const result = source(ctx)!;
    const paths = result.options.map((o) => o.label);
    expect(paths.every((p) => p.startsWith('ale'))).toBe(true);
    expect(paths).toContain('alert.firedAt');
    expect(paths).not.toContain('env.slug');
  });

  it('does not offer out-of-kind vars', () => {
    const ctx = makeContext('{{exchange', 10);
    const result = source(ctx)!;
    // ROUTE_METRIC does not include exchange.* — expect no exchange. completions
    expect(result.options).toHaveLength(0);
  });
});

Run: cd ui && npm test -- mustache-completion Expected: 4 tests pass.

  • Step 3: Write the CM6 linter
// ui/src/components/MustacheEditor/mustache-linter.ts
import { linter, type Diagnostic } from '@codemirror/lint';
import type { AlertVariable } from './alert-variables';

/** Lints a Mustache template for (a) unclosed `{{`, (b) references to out-of-scope variables.
 *  Unknown refs become amber warnings; unclosed `{{` becomes a red error. */
export function mustacheLinter(allowed: readonly AlertVariable[]) {
  return linter((view) => {
    const diags: Diagnostic[] = [];
    const text = view.state.doc.toString();

    // 1. Unclosed / unmatched braces.
    // A single `{{` without a matching `}}` before end-of-doc is an error.
    let i = 0;
    while (i < text.length) {
      const open = text.indexOf('{{', i);
      if (open === -1) break;
      const close = text.indexOf('}}', open + 2);
      if (close === -1) {
        diags.push({
          from: open,
          to: text.length,
          severity: 'error',
          message: 'Unclosed Mustache tag `{{` — add `}}` to close.',
        });
        break;
      }
      i = close + 2;
    }

    // 2. Stray `}}` with no preceding `{{` on the same token stream.
    // Approximation: count opens/closes; if doc ends with more closes than opens, flag last.
    const openCount = (text.match(/\{\{/g) ?? []).length;
    const closeCount = (text.match(/\}\}/g) ?? []).length;
    if (closeCount > openCount) {
      const lastClose = text.lastIndexOf('}}');
      diags.push({
        from: lastClose,
        to: lastClose + 2,
        severity: 'error',
        message: 'Unmatched `}}` — no opening `{{` for this close.',
      });
    }

    // 3. Unknown variable references (amber warning).
    const allowedSet = new Set(allowed.map((v) => v.path));
    const refRe = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}/g;
    let m: RegExpExecArray | null;
    while ((m = refRe.exec(text)) !== null) {
      const ref = m[1];
      if (!allowedSet.has(ref)) {
        diags.push({
          from: m.index,
          to: m.index + m[0].length,
          severity: 'warning',
          message: `\`${ref}\` is not available for this rule kind — will render as literal.`,
        });
      }
    }

    return diags;
  });
}
  • Step 4: Write linter tests
// ui/src/components/MustacheEditor/mustache-linter.test.ts
import { describe, it, expect } from 'vitest';
import { EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { forEachDiagnostic } from '@codemirror/lint';
import { mustacheLinter } from './mustache-linter';
import { availableVariables } from './alert-variables';

function makeView(doc: string) {
  return new EditorView({
    state: EditorState.create({
      doc,
      extensions: [mustacheLinter(availableVariables('ROUTE_METRIC'))],
    }),
  });
}

async function diagnosticsFor(doc: string): Promise<
  Array<{ severity: string; message: string; from: number; to: number }>
> {
  const view = makeView(doc);
  // @codemirror/lint is async — wait a microtask for the source to run.
  await new Promise((r) => setTimeout(r, 50));
  const out: Array<{ severity: string; message: string; from: number; to: number }> = [];
  forEachDiagnostic(view.state, (d, from, to) =>
    out.push({ severity: d.severity, message: d.message, from, to }),
  );
  view.destroy();
  return out;
}

describe('mustacheLinter', () => {
  it('accepts a valid template with no warnings', async () => {
    const diags = await diagnosticsFor('Rule {{rule.name}} in env {{env.slug}}');
    expect(diags).toEqual([]);
  });

  it('flags unclosed {{', async () => {
    const diags = await diagnosticsFor('Hello {{alert.firedAt');
    expect(diags.find((d) => d.severity === 'error' && /unclosed/i.test(d.message))).toBeTruthy();
  });

  it('warns on unknown variable', async () => {
    const diags = await diagnosticsFor('{{exchange.id}}');
    const warn = diags.find((d) => d.severity === 'warning');
    expect(warn?.message).toMatch(/exchange\.id.*not available/);
  });
});

Run: cd ui && npm test -- mustache-linter Expected: 3 tests pass.

  • Step 5: Commit
git add ui/src/components/MustacheEditor/mustache-completion.ts \
        ui/src/components/MustacheEditor/mustache-completion.test.ts \
        ui/src/components/MustacheEditor/mustache-linter.ts \
        ui/src/components/MustacheEditor/mustache-linter.test.ts
git commit -m "feat(ui/alerts): CM6 completion + linter for Mustache templates

completion fires after {{ and narrows as the user types; apply() closes the
tag automatically. Linter raises an error on unclosed {{, a warning on
references that aren't in the allowed-variable set for the current condition
kind. Kind-specific allowed set comes from availableVariables()."

Task 12: <MustacheEditor /> shell component

Files:

  • Create: ui/src/components/MustacheEditor/MustacheEditor.tsx

  • Create: ui/src/components/MustacheEditor/MustacheEditor.module.css

  • Create: ui/src/components/MustacheEditor/MustacheEditor.test.tsx

  • Step 1: Write failing integration test

// ui/src/components/MustacheEditor/MustacheEditor.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MustacheEditor } from './MustacheEditor';

describe('MustacheEditor', () => {
  it('renders the initial value', () => {
    render(
      <MustacheEditor
        value="Hello {{rule.name}}"
        onChange={() => {}}
        kind="ROUTE_METRIC"
        label="Title template"
      />,
    );
    expect(screen.getByText(/Hello/)).toBeInTheDocument();
  });

  it('calls onChange when the user types', () => {
    const onChange = vi.fn();
    render(
      <MustacheEditor
        value=""
        onChange={onChange}
        kind="ROUTE_METRIC"
        label="Title template"
      />,
    );
    const editor = screen.getByRole('textbox');
    fireEvent.input(editor, { target: { textContent: 'foo' } });
    // CM6 fires via transactions; at minimum the editor is rendered and focusable.
    expect(editor).toBeInTheDocument();
  });
});
  • Step 2: Implement the shell
// ui/src/components/MustacheEditor/MustacheEditor.tsx
import { useEffect, useRef } from 'react';
import { EditorState, type Extension } from '@codemirror/state';
import { EditorView, keymap, highlightSpecialChars, drawSelection, highlightActiveLine, lineNumbers } from '@codemirror/view';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { lintKeymap, lintGutter } from '@codemirror/lint';
import { mustacheCompletionSource } from './mustache-completion';
import { mustacheLinter } from './mustache-linter';
import { availableVariables } from './alert-variables';
import type { ConditionKind } from '../../api/queries/alertRules';
import css from './MustacheEditor.module.css';

export interface MustacheEditorProps {
  value: string;
  onChange: (value: string) => void;
  kind?: ConditionKind;
  reducedContext?: boolean;  // connection URL editor uses env-only context
  label: string;
  placeholder?: string;
  minHeight?: number;         // default 80
  singleLine?: boolean;       // used for header values / URL fields
}

export function MustacheEditor(props: MustacheEditorProps) {
  const hostRef = useRef<HTMLDivElement>(null);
  const viewRef = useRef<EditorView | null>(null);

  // Keep a ref to the latest onChange so the EditorView effect doesn't re-create on every render.
  const onChangeRef = useRef(props.onChange);
  onChangeRef.current = props.onChange;

  useEffect(() => {
    if (!hostRef.current) return;
    const allowed = availableVariables(props.kind, { reducedContext: props.reducedContext });

    const extensions: Extension[] = [
      history(),
      drawSelection(),
      highlightSpecialChars(),
      highlightActiveLine(),
      closeBrackets(),
      autocompletion({ override: [mustacheCompletionSource(allowed)] }),
      mustacheLinter(allowed),
      lintGutter(),
      EditorView.updateListener.of((u) => {
        if (u.docChanged) onChangeRef.current(u.state.doc.toString());
      }),
      keymap.of([
        ...closeBracketsKeymap,
        ...defaultKeymap,
        ...historyKeymap,
        ...completionKeymap,
        ...lintKeymap,
      ]),
    ];
    if (!props.singleLine) extensions.push(lineNumbers());
    if (props.singleLine) {
      // Prevent Enter from inserting a newline on single-line fields.
      extensions.push(
        EditorState.transactionFilter.of((tr) => {
          if (tr.newDoc.lines > 1) return [];
          return tr;
        }),
      );
    }

    const view = new EditorView({
      parent: hostRef.current,
      state: EditorState.create({
        doc: props.value,
        extensions,
      }),
    });
    viewRef.current = view;
    return () => {
      view.destroy();
      viewRef.current = null;
    };
    // Extensions built once per mount; prop-driven extensions rebuilt in the effect below.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [props.kind, props.reducedContext, props.singleLine]);

  // If the parent replaces `value` externally (e.g. promotion prefill), sync the doc.
  useEffect(() => {
    const view = viewRef.current;
    if (!view) return;
    const current = view.state.doc.toString();
    if (current !== props.value) {
      view.dispatch({ changes: { from: 0, to: current.length, insert: props.value } });
    }
  }, [props.value]);

  const minH = props.minHeight ?? (props.singleLine ? 32 : 80);

  return (
    <div className={css.wrapper}>
      <label className={css.label}>{props.label}</label>
      <div
        ref={hostRef}
        className={css.editor}
        role="textbox"
        aria-label={props.label}
        style={{ minHeight: minH }}
        data-placeholder={props.placeholder}
      />
    </div>
  );
}
  • Step 3: Write the CSS module
/* ui/src/components/MustacheEditor/MustacheEditor.module.css */
.wrapper { display: flex; flex-direction: column; gap: 4px; }
.label { font-size: 12px; color: var(--muted); }
.editor {
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg);
}
.editor :global(.cm-editor) { outline: none; }
.editor :global(.cm-editor.cm-focused) { border-color: var(--accent); }
.editor :global(.cm-content) { padding: 8px; font-family: var(--font-mono, ui-monospace, monospace); font-size: 13px; }
.editor :global(.cm-tooltip-autocomplete) {
  border-radius: 6px;
  border: 1px solid var(--border);
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
  • Step 4: Run tests
cd ui && npm test -- MustacheEditor

Expected: 2 tests pass (rendering + input event sanity; CM6 internals are already covered by the completion/linter unit tests).

  • Step 5: Commit
git add ui/src/components/MustacheEditor/MustacheEditor.tsx \
        ui/src/components/MustacheEditor/MustacheEditor.module.css \
        ui/src/components/MustacheEditor/MustacheEditor.test.tsx
git commit -m "feat(ui/alerts): MustacheEditor component (CM6 shell with completion + linter)

Wires the mustache-completion source and mustache-linter into a CodeMirror 6
EditorView. Accepts kind (filters variables) and reducedContext (env-only for
connection URLs). singleLine prevents newlines for URL/header fields. Host
ref syncs when the parent replaces value (promotion prefill)."

Phase 4 — Routes, sidebar, top-nav integration

Task 13: Register /alerts/* routes

Files:

  • Modify: ui/src/router.tsx

  • Step 1: Add lazy imports at the top of router.tsx

Insert after the existing const OutboundConnectionEditor = lazy(...) line (around line 22):

const InboxPage        = lazy(() => import('./pages/Alerts/InboxPage'));
const AllAlertsPage    = lazy(() => import('./pages/Alerts/AllAlertsPage'));
const HistoryPage      = lazy(() => import('./pages/Alerts/HistoryPage'));
const RulesListPage    = lazy(() => import('./pages/Alerts/RulesListPage'));
const RuleEditorWizard = lazy(() => import('./pages/Alerts/RuleEditor/RuleEditorWizard'));
const SilencesPage     = lazy(() => import('./pages/Alerts/SilencesPage'));
  • Step 2: Add the /alerts route branch

Inside the <LayoutShell /> children array, after the apps/:appId entry and before the Admin block (around line 77), insert:

// Alerts section (VIEWER+ via backend RBAC; UI is visible to all authenticated)
{ path: 'alerts', element: <Navigate to="/alerts/inbox" replace /> },
{ path: 'alerts/inbox',    element: <SuspenseWrapper><InboxPage /></SuspenseWrapper> },
{ path: 'alerts/all',      element: <SuspenseWrapper><AllAlertsPage /></SuspenseWrapper> },
{ path: 'alerts/history',  element: <SuspenseWrapper><HistoryPage /></SuspenseWrapper> },
{ path: 'alerts/rules',    element: <SuspenseWrapper><RulesListPage /></SuspenseWrapper> },
{ path: 'alerts/rules/new', element: <SuspenseWrapper><RuleEditorWizard /></SuspenseWrapper> },
{ path: 'alerts/rules/:id', element: <SuspenseWrapper><RuleEditorWizard /></SuspenseWrapper> },
{ path: 'alerts/silences', element: <SuspenseWrapper><SilencesPage /></SuspenseWrapper> },
  • Step 3: TypeScript compile passes
cd ui && npx tsc -p tsconfig.app.json --noEmit

Expected: compilation fails only with "module not found" for the six pages (we'll implement them in Phase 5/6/7). Router syntax itself is valid.

  • Step 4: Create placeholder pages so compile passes

Temporary stubs — each page replaced in later phases. These stubs must export a default function component and be typed.

// ui/src/pages/Alerts/InboxPage.tsx
export default function InboxPage() {
  return <div>Inbox  coming soon</div>;
}

Repeat for AllAlertsPage.tsx, HistoryPage.tsx, RulesListPage.tsx, SilencesPage.tsx.

// ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx
export default function RuleEditorWizard() {
  return <div>Rule editor wizard  coming soon</div>;
}
  • Step 5: TypeScript compile passes cleanly
cd ui && npx tsc -p tsconfig.app.json --noEmit

Expected: PASS (zero errors).

  • Step 6: Commit
git add ui/src/router.tsx ui/src/pages/Alerts/
git commit -m "feat(ui/alerts): register /alerts/* routes with placeholder pages

Adds Inbox, All, History, Rules, Rules/new|{id}, Silences routes. Placeholder
page stubs will be replaced in subsequent phases. /alerts redirects to
/alerts/inbox."

Task 14: Add Alerts sidebar section

Files:

  • Modify: ui/src/components/sidebar-utils.ts
  • Modify: ui/src/components/LayoutShell.tsx
  • Create: ui/src/components/sidebar-utils.test.ts (if missing)

Before modifying, run gitnexus_impact({target:"buildAdminTreeNodes", direction:"upstream"}) so future maintainers see the blast radius was reviewed.

  • Step 1: Add buildAlertsTreeNodes helper to sidebar-utils.ts

Append (after buildAdminTreeNodes):

import { AlertTriangle, Inbox, List, ScrollText, BellOff, Bell } from 'lucide-react';

/** Tree nodes for the Alerts sidebar section. */
export function buildAlertsTreeNodes(): SidebarTreeNode[] {
  const icon = (el: ReactNode) => el;
  return [
    { id: 'alerts-inbox',   label: 'Inbox',    path: '/alerts/inbox',    icon: icon(createElement(Inbox, { size: 14 })) },
    { id: 'alerts-all',     label: 'All',      path: '/alerts/all',      icon: icon(createElement(List, { size: 14 })) },
    { id: 'alerts-rules',   label: 'Rules',    path: '/alerts/rules',    icon: icon(createElement(AlertTriangle, { size: 14 })) },
    { id: 'alerts-silences',label: 'Silences', path: '/alerts/silences', icon: icon(createElement(BellOff, { size: 14 })) },
    { id: 'alerts-history', label: 'History',  path: '/alerts/history',  icon: icon(createElement(ScrollText, { size: 14 })) },
  ];
}
  • Step 2: Add a test for buildAlertsTreeNodes
// ui/src/components/sidebar-utils.test.ts
import { describe, it, expect } from 'vitest';
import { buildAlertsTreeNodes } from './sidebar-utils';

describe('buildAlertsTreeNodes', () => {
  it('produces 5 entries with absolute /alerts/* paths', () => {
    const nodes = buildAlertsTreeNodes();
    expect(nodes.map((n) => n.path)).toEqual([
      '/alerts/inbox',
      '/alerts/all',
      '/alerts/rules',
      '/alerts/silences',
      '/alerts/history',
    ]);
  });
});

Run: cd ui && npm test -- sidebar-utils Expected: 1 test passes.

  • Step 3: Mount the Alerts section in LayoutShell.tsx

Near the other section constants (around line 279 where SK_APPS, SK_ADMIN, SK_COLLAPSED are defined), add:

const SK_ALERTS = 'sidebar:section:alerts';

Import the helper alongside existing imports from sidebar-utils:

import {
  buildAppTreeNodes,
  buildAdminTreeNodes,
  buildAlertsTreeNodes,
  // ... existing
} from './sidebar-utils';

Add an alertsOpen state near the existing appsOpen / adminOpen (around line 370):

const isAlertsPage = location.pathname.startsWith('/alerts');
const [alertsOpen, setAlertsOpen] = useState(() =>
  isAlertsPage ? true : readCollapsed(SK_ALERTS, false),
);

const toggleAlerts = useCallback(() => {
  if (!isAlertsPage) {
    navigate('/alerts/inbox');
    return;
  }
  setAlertsOpen((prev) => {
    writeCollapsed(SK_ALERTS, !prev);
    return !prev;
  });
}, [isAlertsPage, navigate]);

const alertsTreeNodes = useMemo(() => buildAlertsTreeNodes(), []);

Add the accordion/closing effect — when entering /alerts, collapse Apps + Admin + Starred (same pattern as the existing admin accordion around line 376):

const prevAlertsRef = useRef(isAlertsPage);
useEffect(() => {
  if (isAlertsPage && !prevAlertsRef.current) {
    setAppsOpen(false);
    setStarredOpen(false);
    setAdminOpen(false);
    setAlertsOpen(true);
  } else if (!isAlertsPage && prevAlertsRef.current) {
    setAlertsOpen(false);
  }
  prevAlertsRef.current = isAlertsPage;
}, [isAlertsPage]); // eslint-disable-line react-hooks/exhaustive-deps

Render a new <Sidebar.Section> for Alerts between Applications and Starred (around line 753, right after the Applications section closing </Sidebar.Section>):

<Sidebar.Section
  icon={createElement(Bell, { size: 16 })}
  label="Alerts"
  open={alertsOpen}
  onToggle={toggleAlerts}
  active={isAlertsPage}
>
  <SidebarTree
    nodes={alertsTreeNodes}
    selectedPath={location.pathname}
    isStarred={isStarred}
    onToggleStar={toggleStar}
    filterQuery={filterQuery}
    persistKey="alerts"
    autoRevealPath={sidebarRevealPath}
    onNavigate={handleSidebarNavigate}
  />
</Sidebar.Section>

(Also add Bell to the existing lucide-react import.)

  • Step 4: TypeScript compile
cd ui && npx tsc -p tsconfig.app.json --noEmit

Expected: PASS.

  • Step 5: Commit
git add ui/src/components/sidebar-utils.ts ui/src/components/sidebar-utils.test.ts ui/src/components/LayoutShell.tsx
git commit -m "feat(ui/alerts): Alerts sidebar section with Inbox/All/Rules/Silences/History

Adds an Alerts accordion-mode section (collapses Apps + Admin + Starred on
enter, like Admin). Icons from lucide-react. Section state persisted to
sidebar:section:alerts."

Task 15: Mount <NotificationBell /> in TopBar

Files:

  • Modify: ui/src/components/LayoutShell.tsx

  • Step 1: Import the bell

Near the existing import { AboutMeDialog } from './AboutMeDialog';, add:

import { NotificationBell } from './NotificationBell';
  • Step 2: Mount the bell in TopBar children

Locate the <TopBar> children (around line 840, <SearchTrigger .../> is the first child). Insert the bell before SearchTrigger:

<NotificationBell />
<SearchTrigger onClick={() => setPaletteOpen(true)} />

Rationale: the spec asks for placement "between env selector and user menu". Since TopBar renders the environment selector on the left and user menu on the right (both in fixed slots), the bell lives in children as the left-most child, which is rendered in the top-right cluster adjacent to the user menu area.

  • Step 3: TypeScript compile
cd ui && npx tsc -p tsconfig.app.json --noEmit

Expected: PASS.

  • Step 4: Manual smoke (if local backend available)

Start npm run dev:local, log in, pick an env, confirm the bell renders next to the search icon. (Golden-path Playwright coverage lands in Task 32.)

  • Step 5: Commit
git add ui/src/components/LayoutShell.tsx
git commit -m "feat(ui/alerts): mount NotificationBell in TopBar"

Phase 5 — Inbox / All / History pages

Task 16: InboxPage

Files:

  • Replace: ui/src/pages/Alerts/InboxPage.tsx

  • Create: ui/src/pages/Alerts/AlertRow.tsx

  • Create: ui/src/pages/Alerts/alerts-page.module.css

  • Step 1: Build a reusable alert row

// ui/src/pages/Alerts/AlertRow.tsx
import { Link } from 'react-router';
import { Button, useToast } from '@cameleer/design-system';
import { AlertStateChip } from '../../components/AlertStateChip';
import { SeverityBadge } from '../../components/SeverityBadge';
import type { AlertDto } from '../../api/queries/alerts';
import { useAckAlert, useMarkAlertRead } from '../../api/queries/alerts';
import css from './alerts-page.module.css';

export function AlertRow({ alert, unread }: { alert: AlertDto; unread: boolean }) {
  const ack = useAckAlert();
  const markRead = useMarkAlertRead();
  const { toast } = useToast();

  const onAck = async () => {
    try {
      await ack.mutateAsync(alert.id);
      toast({ title: 'Acknowledged', description: alert.title, variant: 'success' });
    } catch (e) {
      toast({ title: 'Ack failed', description: String(e), variant: 'error' });
    }
  };

  return (
    <div
      className={`${css.row} ${unread ? css.rowUnread : ''}`}
      data-testid={`alert-row-${alert.id}`}
    >
      <SeverityBadge severity={alert.severity} />
      <div className={css.body}>
        <Link to={`/alerts/inbox/${alert.id}`} onClick={() => markRead.mutate(alert.id)}>
          <strong>{alert.title}</strong>
        </Link>
        <div className={css.meta}>
          <AlertStateChip state={alert.state} silenced={alert.silenced} />
          <span className={css.time}>{alert.firedAt}</span>
        </div>
        <p className={css.message}>{alert.message}</p>
      </div>
      <div className={css.actions}>
        {alert.state === 'FIRING' && (
          <Button size="small" variant="secondary" onClick={onAck} disabled={ack.isPending}>
            Ack
          </Button>
        )}
      </div>
    </div>
  );
}
  • Step 2: Build the CSS module
/* ui/src/pages/Alerts/alerts-page.module.css */
.page { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.row {
  display: grid;
  grid-template-columns: 72px 1fr auto;
  gap: 12px;
  padding: 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg);
}
.rowUnread { border-left: 3px solid var(--accent); }
.body { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.meta { display: flex; gap: 8px; font-size: 12px; color: var(--muted); }
.time { font-variant-numeric: tabular-nums; }
.message { margin: 0; font-size: 13px; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.actions { display: flex; align-items: center; }
.empty { padding: 48px; text-align: center; color: var(--muted); }
  • Step 3: Write InboxPage
// ui/src/pages/Alerts/InboxPage.tsx
import { useMemo } from 'react';
import { Button, SectionHeader, useToast } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader';
import { useAlerts, useBulkReadAlerts } from '../../api/queries/alerts';
import { AlertRow } from './AlertRow';
import css from './alerts-page.module.css';

export default function InboxPage() {
  const { data, isLoading, error } = useAlerts({ state: ['FIRING', 'ACKNOWLEDGED'], limit: 100 });
  const bulkRead = useBulkReadAlerts();
  const { toast } = useToast();

  const unreadIds = useMemo(
    () => (data ?? []).filter((a) => a.state === 'FIRING').map((a) => a.id),
    [data],
  );

  if (isLoading) return <PageLoader />;
  if (error) return <div className={css.page}>Failed to load alerts: {String(error)}</div>;

  const rows = data ?? [];

  const onMarkAllRead = async () => {
    if (unreadIds.length === 0) return;
    try {
      await bulkRead.mutateAsync(unreadIds);
      toast({ title: `Marked ${unreadIds.length} as read`, variant: 'success' });
    } catch (e) {
      toast({ title: 'Bulk read failed', description: String(e), variant: 'error' });
    }
  };

  return (
    <div className={css.page}>
      <div className={css.toolbar}>
        <SectionHeader>Inbox</SectionHeader>
        <Button variant="secondary" onClick={onMarkAllRead} disabled={bulkRead.isPending || unreadIds.length === 0}>
          Mark all read
        </Button>
      </div>
      {rows.length === 0 ? (
        <div className={css.empty}>No open alerts for you in this environment.</div>
      ) : (
        rows.map((a) => <AlertRow key={a.id} alert={a} unread={a.state === 'FIRING'} />)
      )}
    </div>
  );
}
  • Step 4: TypeScript compile
cd ui && npx tsc -p tsconfig.app.json --noEmit

Expected: PASS.

  • Step 5: Commit
git add ui/src/pages/Alerts/InboxPage.tsx ui/src/pages/Alerts/AlertRow.tsx ui/src/pages/Alerts/alerts-page.module.css
git commit -m "feat(ui/alerts): InboxPage with ack + bulk-read actions

AlertRow is reused by AllAlertsPage and HistoryPage. Marking a row as read
happens when its link is followed (the detail sub-route will be added in
phase 10 polish). FIRING rows get an amber left border."

Task 17: AllAlertsPage + HistoryPage

Files:

  • Replace: ui/src/pages/Alerts/AllAlertsPage.tsx

  • Replace: ui/src/pages/Alerts/HistoryPage.tsx

  • Step 1: Write AllAlertsPage

// ui/src/pages/Alerts/AllAlertsPage.tsx
import { useState } from 'react';
import { SectionHeader, Button } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader';
import { useAlerts, type AlertDto } from '../../api/queries/alerts';
import { AlertRow } from './AlertRow';
import css from './alerts-page.module.css';

const STATE_FILTERS: Array<{ label: string; values: AlertDto['state'][] }> = [
  { label: 'Open',  values: ['PENDING', 'FIRING', 'ACKNOWLEDGED'] },
  { label: 'Firing', values: ['FIRING'] },
  { label: 'Acked',  values: ['ACKNOWLEDGED'] },
  { label: 'All',    values: ['PENDING', 'FIRING', 'ACKNOWLEDGED', 'RESOLVED'] },
];

export default function AllAlertsPage() {
  const [filterIdx, setFilterIdx] = useState(0);
  const filter = STATE_FILTERS[filterIdx];
  const { data, isLoading, error } = useAlerts({ state: filter.values, limit: 200 });

  if (isLoading) return <PageLoader />;
  if (error) return <div className={css.page}>Failed to load alerts: {String(error)}</div>;

  const rows = data ?? [];

  return (
    <div className={css.page}>
      <div className={css.toolbar}>
        <SectionHeader>All alerts</SectionHeader>
        <div style={{ display: 'flex', gap: 4 }}>
          {STATE_FILTERS.map((f, i) => (
            <Button
              key={f.label}
              size="small"
              variant={i === filterIdx ? 'primary' : 'secondary'}
              onClick={() => setFilterIdx(i)}
            >
              {f.label}
            </Button>
          ))}
        </div>
      </div>
      {rows.length === 0 ? (
        <div className={css.empty}>No alerts match this filter.</div>
      ) : (
        rows.map((a) => <AlertRow key={a.id} alert={a} unread={false} />)
      )}
    </div>
  );
}
  • Step 2: Write HistoryPage
// ui/src/pages/Alerts/HistoryPage.tsx
import { SectionHeader } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader';
import { useAlerts } from '../../api/queries/alerts';
import { AlertRow } from './AlertRow';
import css from './alerts-page.module.css';

export default function HistoryPage() {
  const { data, isLoading, error } = useAlerts({ state: 'RESOLVED', limit: 200 });

  if (isLoading) return <PageLoader />;
  if (error) return <div className={css.page}>Failed to load history: {String(error)}</div>;

  const rows = data ?? [];

  return (
    <div className={css.page}>
      <div className={css.toolbar}>
        <SectionHeader>History</SectionHeader>
      </div>
      {rows.length === 0 ? (
        <div className={css.empty}>No resolved alerts in retention window.</div>
      ) : (
        rows.map((a) => <AlertRow key={a.id} alert={a} unread={false} />)
      )}
    </div>
  );
}
  • Step 3: TypeScript compile + commit
cd ui && npx tsc -p tsconfig.app.json --noEmit
git add ui/src/pages/Alerts/AllAlertsPage.tsx ui/src/pages/Alerts/HistoryPage.tsx
git commit -m "feat(ui/alerts): AllAlertsPage + HistoryPage

AllAlertsPage: state filter chips (Open/Firing/Acked/All).
HistoryPage: RESOLVED filter, respects retention window."

Phase 6 — Rule list + editor wizard

Task 18: RulesListPage

Files:

  • Replace: ui/src/pages/Alerts/RulesListPage.tsx

  • Step 1: Write the page

// ui/src/pages/Alerts/RulesListPage.tsx
import { Link, useNavigate } from 'react-router';
import { Button, SectionHeader, Toggle, useToast, Badge } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader';
import { SeverityBadge } from '../../components/SeverityBadge';
import {
  useAlertRules,
  useDeleteAlertRule,
  useSetAlertRuleEnabled,
  type AlertRuleResponse,
} from '../../api/queries/alertRules';
import { useEnvironments } from '../../api/queries/admin/environments';
import { useSelectedEnv } from '../../api/queries/alertMeta';
import sectionStyles from '../../styles/section-card.module.css';

export default function RulesListPage() {
  const navigate = useNavigate();
  const env = useSelectedEnv();
  const { data: rules, isLoading, error } = useAlertRules();
  const { data: envs } = useEnvironments();
  const setEnabled = useSetAlertRuleEnabled();
  const deleteRule = useDeleteAlertRule();
  const { toast } = useToast();

  if (isLoading) return <PageLoader />;
  if (error) return <div>Failed to load rules: {String(error)}</div>;

  const rows = rules ?? [];
  const otherEnvs = (envs ?? []).filter((e) => e.slug !== env);

  const onToggle = async (r: AlertRuleResponse) => {
    try {
      await setEnabled.mutateAsync({ id: r.id, enabled: !r.enabled });
      toast({ title: r.enabled ? 'Disabled' : 'Enabled', description: r.name, variant: 'success' });
    } catch (e) {
      toast({ title: 'Toggle failed', description: String(e), variant: 'error' });
    }
  };

  const onDelete = async (r: AlertRuleResponse) => {
    if (!confirm(`Delete rule "${r.name}"? Fired alerts are preserved via rule_snapshot.`)) return;
    try {
      await deleteRule.mutateAsync(r.id);
      toast({ title: 'Deleted', description: r.name, variant: 'success' });
    } catch (e) {
      toast({ title: 'Delete failed', description: String(e), variant: 'error' });
    }
  };

  const onPromote = (r: AlertRuleResponse, targetEnvSlug: string) => {
    navigate(`/alerts/rules/new?promoteFrom=${env}&ruleId=${r.id}&targetEnv=${targetEnvSlug}`);
  };

  return (
    <div style={{ padding: 16 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
        <SectionHeader>Alert rules</SectionHeader>
        <Link to="/alerts/rules/new">
          <Button variant="primary">New rule</Button>
        </Link>
      </div>
      <div className={sectionStyles.section}>
        {rows.length === 0 ? (
          <p>No rules yet. Create one to start evaluating alerts for this environment.</p>
        ) : (
          <table style={{ width: '100%', borderCollapse: 'collapse' }}>
            <thead>
              <tr>
                <th style={{ textAlign: 'left' }}>Name</th>
                <th style={{ textAlign: 'left' }}>Kind</th>
                <th style={{ textAlign: 'left' }}>Severity</th>
                <th style={{ textAlign: 'left' }}>Enabled</th>
                <th style={{ textAlign: 'left' }}>Targets</th>
                <th></th>
              </tr>
            </thead>
            <tbody>
              {rows.map((r) => (
                <tr key={r.id}>
                  <td><Link to={`/alerts/rules/${r.id}`}>{r.name}</Link></td>
                  <td><Badge label={r.conditionKind} color="auto" variant="outlined" /></td>
                  <td><SeverityBadge severity={r.severity} /></td>
                  <td>
                    <Toggle
                      checked={r.enabled}
                      onChange={() => onToggle(r)}
                      disabled={setEnabled.isPending}
                    />
                  </td>
                  <td>{r.targets.length}</td>
                  <td style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
                    {otherEnvs.length > 0 && (
                      <select
                        value=""
                        onChange={(e) => { if (e.target.value) onPromote(r, e.target.value); }}
                        aria-label={`Promote ${r.name} to another env`}
                      >
                        <option value="">Promote to </option>
                        {otherEnvs.map((e) => (
                          <option key={e.slug} value={e.slug}>{e.slug}</option>
                        ))}
                      </select>
                    )}
                    <Button variant="secondary" onClick={() => onDelete(r)} disabled={deleteRule.isPending}>
                      Delete
                    </Button>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        )}
      </div>
    </div>
  );
}
  • Step 2: TypeScript compile + commit
cd ui && npx tsc -p tsconfig.app.json --noEmit
git add ui/src/pages/Alerts/RulesListPage.tsx
git commit -m "feat(ui/alerts): RulesListPage with enable/disable, delete, env promotion

Promotion dropdown builds a /alerts/rules/new URL with promoteFrom, ruleId,
and targetEnv query params — the wizard will read these in Task 24 and
pre-fill the form with source-env prefill + client-side warnings."

Task 19: Wizard form state + shell

Files:

  • Create: ui/src/pages/Alerts/RuleEditor/form-state.ts

  • Create: ui/src/pages/Alerts/RuleEditor/form-state.test.ts

  • Replace: ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx

  • Create: ui/src/pages/Alerts/RuleEditor/wizard.module.css

  • Step 1: Write the form-state module

// ui/src/pages/Alerts/RuleEditor/form-state.ts
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<{ targetKind: '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 any)?.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,
    enabled: existing.enabled,
    scopeKind,
    appSlug: scope.appSlug ?? '',
    routeId: scope.routeId ?? '',
    agentId: scope.agentId ?? '',
    conditionKind: existing.conditionKind,
    condition: existing.condition,
    evaluationIntervalSeconds: existing.evaluationIntervalSeconds,
    forDurationSeconds: existing.forDurationSeconds,
    reNotifyMinutes: existing.reNotifyMinutes,
    notificationTitleTmpl: existing.notificationTitleTmpl,
    notificationMessageTmpl: existing.notificationMessageTmpl,
    webhooks: (existing.webhooks ?? []).map((w) => ({
      outboundConnectionId: (w as any).outboundConnectionId,
      bodyOverride: (w as any).bodyOverride ?? '',
      headerOverrides: Object.entries(((w as any).headerOverrides ?? {}) as Record<string, string>)
        .map(([key, value]) => ({ key, value })),
    })),
    targets: existing.targets ?? [],
  };
}

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, kind: f.conditionKind, scope } 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,
  } 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 ≥ 5 s.');
    if (f.forDurationSeconds < 0) errs.push('For-duration must be ≥ 0.');
    if (f.reNotifyMinutes < 0) errs.push('Re-notify cadence must be ≥ 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;
}
  • Step 2: Write tests
// ui/src/pages/Alerts/RuleEditor/form-state.test.ts
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 any).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);
    expect((req.condition as any).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)).toContain('Evaluation interval must be ≥ 5 s.');
  });
});

Run: cd ui && npm test -- form-state Expected: 6 tests pass.

  • Step 3: Write the wizard shell
// ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx
import { useMemo, 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 { useAlertRule as useSourceRule } from '../../../api/queries/alertRules';
import css from './wizard.module.css';

const STEP_LABELS: Record<WizardStep, string> = {
  scope: '1. Scope',
  condition: '2. Condition',
  trigger: '3. Trigger',
  notify: '4. Notify',
  review: '5. Review',
};

export default function RuleEditorWizard() {
  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; implemented in Task 24.
  const promoteFrom = search.get('promoteFrom') ?? undefined;
  const promoteRuleId = search.get('ruleId') ?? undefined;
  const sourceRuleQuery = useSourceRule(promoteFrom ? promoteRuleId : undefined);

  const [step, setStep] = useState<WizardStep>('scope');
  const [form, setForm] = useState<FormState | null>(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 <PageLoader />;

  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'     ? <ScopeStep     form={form} setForm={setForm} /> :
    step === 'condition' ? <ConditionStep form={form} setForm={setForm} /> :
    step === 'trigger'   ? <TriggerStep   form={form} setForm={setForm} /> :
    step === 'notify'    ? <NotifyStep    form={form} setForm={setForm} ruleId={id} /> :
                           <ReviewStep    form={form} />;

  return (
    <div className={css.wizard}>
      <div className={css.header}>
        <SectionHeader>{isEdit ? `Edit rule: ${form.name}` : 'New alert rule'}</SectionHeader>
        {promoteFrom && (
          <div className={css.promoteBanner}>
            Promoting from <code>{promoteFrom}</code>  review and adjust, then save.
          </div>
        )}
      </div>
      <nav className={css.steps}>
        {WIZARD_STEPS.map((s, i) => (
          <button
            key={s}
            className={`${css.step} ${step === s ? css.stepActive : ''} ${i < idx ? css.stepDone : ''}`}
            onClick={() => setStep(s)}
          >
            {STEP_LABELS[s]}
          </button>
        ))}
      </nav>
      <div className={css.stepBody}>{body}</div>
      <div className={css.footer}>
        <Button variant="secondary" onClick={onBack} disabled={idx === 0}>Back</Button>
        {idx < WIZARD_STEPS.length - 1 ? (
          <Button variant="primary" onClick={onNext}>Next</Button>
        ) : (
          <Button variant="primary" onClick={onSave} disabled={create.isPending || update.isPending}>
            {isEdit ? 'Save changes' : 'Create rule'}
          </Button>
        )}
      </div>
    </div>
  );
}
  • Step 4: Write the CSS module
/* 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:

// 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 <div>Scope step  TODO Task 20</div>;
}

And promotion-prefill.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
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 2024."

Task 20: ScopeStep

Files:

  • Replace: ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx

  • Step 1: Implement the scope form

// 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 (
    <div style={{ display: 'grid', gap: 12, maxWidth: 600 }}>
      <FormField label="Name">
        <Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Order API error rate" />
      </FormField>
      <FormField label="Description">
        <Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
      </FormField>
      <FormField label="Severity">
        <Select
          value={form.severity}
          onChange={(v) => setForm({ ...form, severity: v as FormState['severity'] })}
          options={SEVERITY_OPTIONS}
        />
      </FormField>
      <FormField label="Scope">
        <Select
          value={form.scopeKind}
          onChange={(v) => setForm({ ...form, scopeKind: v as FormState['scopeKind'] })}
          options={SCOPE_OPTIONS}
        />
      </FormField>
      {form.scopeKind !== 'env' && (
        <FormField label="App">
          <Select
            value={form.appSlug}
            onChange={(v) => setForm({ ...form, appSlug: v, routeId: '', agentId: '' })}
            options={apps.map((a) => ({ value: a.slug, label: a.name }))}
          />
        </FormField>
      )}
      {form.scopeKind === 'route' && (
        <FormField label="Route">
          <Select
            value={form.routeId}
            onChange={(v) => setForm({ ...form, routeId: v })}
            options={routes.map((r: any) => ({ value: r.routeId, label: r.routeId }))}
          />
        </FormField>
      )}
      {form.scopeKind === 'agent' && (
        <FormField label="Agent">
          <Select
            value={form.agentId}
            onChange={(v) => setForm({ ...form, agentId: v })}
            options={appAgents.map((a: any) => ({ value: a.instanceId, label: a.displayName ?? a.instanceId }))}
          />
        </FormField>
      )}
    </div>
  );
}
  • Step 2: TypeScript compile + commit
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

// 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 (
    <div style={{ display: 'grid', gap: 16, maxWidth: 720 }}>
      <FormField label="Condition kind">
        <Select value={form.conditionKind} onChange={onKindChange} options={KIND_OPTIONS} />
      </FormField>
      {form.conditionKind === 'ROUTE_METRIC' && <RouteMetricForm form={form} setForm={setForm} />}
      {form.conditionKind === 'EXCHANGE_MATCH' && <ExchangeMatchForm form={form} setForm={setForm} />}
      {form.conditionKind === 'AGENT_STATE' && <AgentStateForm form={form} setForm={setForm} />}
      {form.conditionKind === 'DEPLOYMENT_STATE' && <DeploymentStateForm form={form} setForm={setForm} />}
      {form.conditionKind === 'LOG_PATTERN' && <LogPatternForm form={form} setForm={setForm} />}
      {form.conditionKind === 'JVM_METRIC' && <JvmMetricForm form={form} setForm={setForm} />}
    </div>
  );
}
  • Step 2: RouteMetricForm
// 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 (
    <>
      <FormField label="Metric"><Select value={c.metric ?? ''} onChange={(v) => patch({ metric: v })} options={METRICS} /></FormField>
      <FormField label="Comparator"><Select value={c.comparator ?? 'GT'} onChange={(v) => patch({ comparator: v })} options={COMPARATORS} /></FormField>
      <FormField label="Threshold"><Input type="number" value={c.threshold ?? ''} onChange={(e) => patch({ threshold: Number(e.target.value) })} /></FormField>
      <FormField label="Window (seconds)"><Input type="number" value={c.windowSeconds ?? 300} onChange={(e) => patch({ windowSeconds: Number(e.target.value) })} /></FormField>
    </>
  );
}
  • Step 3: ExchangeMatchForm (PER_EXCHANGE or COUNT_IN_WINDOW)
// 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 (
    <>
      <FormField label="Fire mode"><Select value={c.fireMode ?? 'PER_EXCHANGE'} onChange={(v) => patch({ fireMode: v })} options={FIRE_MODES} /></FormField>
      <FormField label="Status filter"><Select value={filter.status ?? ''} onChange={(v) => patch({ filter: { ...filter, status: v || undefined } })} options={STATUSES} /></FormField>
      {c.fireMode === 'PER_EXCHANGE' && (
        <FormField label="Linger seconds (default 300)">
          <Input type="number" value={c.perExchangeLingerSeconds ?? 300} onChange={(e) => patch({ perExchangeLingerSeconds: Number(e.target.value) })} />
        </FormField>
      )}
      {c.fireMode === 'COUNT_IN_WINDOW' && (
        <>
          <FormField label="Threshold (matches)"><Input type="number" value={c.threshold ?? ''} onChange={(e) => patch({ threshold: Number(e.target.value) })} /></FormField>
          <FormField label="Window (seconds)"><Input type="number" value={c.windowSeconds ?? 900} onChange={(e) => patch({ windowSeconds: Number(e.target.value) })} /></FormField>
        </>
      )}
    </>
  );
}
  • Step 4: AgentStateForm, DeploymentStateForm, LogPatternForm, JvmMetricForm

Each follows the same pattern. Keep the code short — they're simple enum/threshold pickers.

// 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 (
    <>
      <FormField label="Agent state">
        <Select value={c.state ?? 'DEAD'} onChange={(v) => patch({ state: v })} options={[
          { value: 'DEAD', label: 'DEAD' },
          { value: 'STALE', label: 'STALE' },
        ]} />
      </FormField>
      <FormField label="For duration (seconds)">
        <Input type="number" value={c.forSeconds ?? 60} onChange={(e) => patch({ forSeconds: Number(e.target.value) })} />
      </FormField>
    </>
  );
}
// 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 (
    <FormField label="Fire when deployment is in states">
      <div style={{ display: 'flex', gap: 12 }}>
        {OPTIONS.map((s) => (
          <label key={s} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
            <input type="checkbox" checked={states.includes(s)} onChange={() => toggle(s)} />
            {s}
          </label>
        ))}
      </div>
    </FormField>
  );
}
// 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 (
    <>
      <FormField label="Level">
        <Select value={c.level ?? 'ERROR'} onChange={(v) => patch({ level: v })} options={[
          { value: 'ERROR', label: 'ERROR' },
          { value: 'WARN',  label: 'WARN' },
          { value: 'INFO',  label: 'INFO' },
        ]} />
      </FormField>
      <FormField label="Logger (substring, optional)">
        <Input value={c.logger ?? ''} onChange={(e) => patch({ logger: e.target.value || undefined })} />
      </FormField>
      <FormField label="Pattern (regex)">
        <Input value={c.pattern ?? ''} onChange={(e) => patch({ pattern: e.target.value })} />
      </FormField>
      <FormField label="Threshold (matches)">
        <Input type="number" value={c.threshold ?? ''} onChange={(e) => patch({ threshold: Number(e.target.value) })} />
      </FormField>
      <FormField label="Window (seconds)">
        <Input type="number" value={c.windowSeconds ?? 900} onChange={(e) => patch({ windowSeconds: Number(e.target.value) })} />
      </FormField>
    </>
  );
}
// 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 (
    <>
      <FormField label="Metric name">
        <Input value={c.metric ?? ''} onChange={(e) => patch({ metric: e.target.value })} placeholder="heap_used_percent" />
      </FormField>
      <FormField label="Aggregation">
        <Select value={c.aggregation ?? 'MAX'} onChange={(v) => patch({ aggregation: v })} options={[
          { value: 'MAX', label: 'MAX' },
          { value: 'AVG', label: 'AVG' },
          { value: 'MIN', label: 'MIN' },
        ]} />
      </FormField>
      <FormField label="Comparator">
        <Select value={c.comparator ?? 'GT'} onChange={(v) => patch({ comparator: v })} options={[
          { value: 'GT', label: '>' }, { value: 'GTE', label: '≥' }, { value: 'LT', label: '<' }, { value: 'LTE', label: '≤' },
        ]} />
      </FormField>
      <FormField label="Threshold">
        <Input type="number" value={c.threshold ?? ''} onChange={(e) => patch({ threshold: Number(e.target.value) })} />
      </FormField>
      <FormField label="Window (seconds)">
        <Input type="number" value={c.windowSeconds ?? 300} onChange={(e) => patch({ windowSeconds: Number(e.target.value) })} />
      </FormField>
    </>
  );
}
  • Step 5: TypeScript compile + commit
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

// 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<string | null>(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 (
    <div style={{ display: 'grid', gap: 12, maxWidth: 600 }}>
      <FormField label="Evaluation interval (seconds, min 5)">
        <Input type="number" min={5} value={form.evaluationIntervalSeconds}
          onChange={(e) => setForm({ ...form, evaluationIntervalSeconds: Number(e.target.value) })} />
      </FormField>
      <FormField label="For-duration before firing (seconds, 0 = fire immediately)">
        <Input type="number" min={0} value={form.forDurationSeconds}
          onChange={(e) => setForm({ ...form, forDurationSeconds: Number(e.target.value) })} />
      </FormField>
      <FormField label="Re-notify cadence (minutes, 0 = notify once)">
        <Input type="number" min={0} value={form.reNotifyMinutes}
          onChange={(e) => setForm({ ...form, reNotifyMinutes: Number(e.target.value) })} />
      </FormField>
      <div>
        <Button variant="secondary" onClick={onTest} disabled={testEvaluate.isPending}>
          Test evaluate (uses current condition)
        </Button>
        {lastResult && (
          <pre style={{ marginTop: 12, padding: 8, background: 'var(--code-bg)', borderRadius: 6, fontSize: 12, maxHeight: 240, overflow: 'auto' }}>
            {lastResult}
          </pre>
        )}
      </div>
    </div>
  );
}
  • Step 2: TypeScript compile + commit
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

// 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<string | null>(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<FormState['webhooks'][number]>) => {
    setForm({ ...form, webhooks: form.webhooks.map((w, i) => i === idx ? { ...w, ...patch } : w) });
  };

  return (
    <div style={{ display: 'grid', gap: 16, maxWidth: 720 }}>
      <MustacheEditor
        label="Notification title"
        value={form.notificationTitleTmpl}
        onChange={(v) => setForm({ ...form, notificationTitleTmpl: v })}
        kind={form.conditionKind}
        singleLine
      />
      <MustacheEditor
        label="Notification message"
        value={form.notificationMessageTmpl}
        onChange={(v) => setForm({ ...form, notificationMessageTmpl: v })}
        kind={form.conditionKind}
        minHeight={120}
      />
      <div>
        <Button variant="secondary" onClick={onPreview} disabled={preview.isPending}>
          Preview rendered output
        </Button>
        {lastPreview && (
          <pre style={{ marginTop: 8, padding: 8, background: 'var(--code-bg)', borderRadius: 6, fontSize: 12, whiteSpace: 'pre-wrap' }}>
            {lastPreview}
          </pre>
        )}
      </div>

      <FormField label="Notification targets">
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
          {form.targets.map((t, i) => (
            <Badge
              key={`${t.targetKind}:${t.targetId}`}
              label={`${t.targetKind}: ${t.targetId}`}
              color="auto"
              variant="outlined"
              onRemove={() => removeTarget(i)}
            />
          ))}
        </div>
        <div style={{ display: 'flex', gap: 8 }}>
          <Select value="" onChange={(v) => addTarget('USER', v)} options={[{ value: '', label: '+ User' }, ...(users ?? []).map((u) => ({ value: u.userId, label: u.displayName ?? u.userId }))]} />
          <Select value="" onChange={(v) => addTarget('GROUP', v)} options={[{ value: '', label: '+ Group' }, ...(groups ?? []).map((g) => ({ value: g.id, label: g.name }))]} />
          <Select value="" onChange={(v) => addTarget('ROLE', v)} options={[{ value: '', label: '+ Role' }, ...(roles ?? []).map((r) => ({ value: r.name, label: r.name }))]} />
        </div>
      </FormField>

      <FormField label="Webhook destinations (outbound connections)">
        <Select
          value=""
          onChange={(v) => { 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 (
            <div key={i} style={{ padding: 12, border: '1px solid var(--border)', borderRadius: 6, marginTop: 8, display: 'grid', gap: 8 }}>
              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
                <strong>{conn?.name ?? w.outboundConnectionId}</strong>
                <Button size="small" variant="secondary" onClick={() => removeWebhook(i)}>Remove</Button>
              </div>
              <MustacheEditor
                label="Body override (optional)"
                value={w.bodyOverride}
                onChange={(v) => updateWebhook(i, { bodyOverride: v })}
                kind={form.conditionKind}
                placeholder="Leave empty to use connection default"
                minHeight={80}
              />
              <FormField label="Header overrides">
                {w.headerOverrides.map((h, hi) => (
                  <div key={hi} style={{ display: 'flex', gap: 8, marginBottom: 4 }}>
                    <Input value={h.key} placeholder="Header name" onChange={(e) => {
                      const heads = [...w.headerOverrides]; heads[hi] = { ...heads[hi], key: e.target.value };
                      updateWebhook(i, { headerOverrides: heads });
                    }} />
                    <Input value={h.value} placeholder="Mustache value" onChange={(e) => {
                      const heads = [...w.headerOverrides]; heads[hi] = { ...heads[hi], value: e.target.value };
                      updateWebhook(i, { headerOverrides: heads });
                    }} />
                    <Button size="small" variant="secondary" onClick={() => {
                      updateWebhook(i, { headerOverrides: w.headerOverrides.filter((_, x) => x !== hi) });
                    }}>×</Button>
                  </div>
                ))}
                <Button size="small" variant="secondary" onClick={() => {
                  updateWebhook(i, { headerOverrides: [...w.headerOverrides, { key: '', value: '' }] });
                }}>+ Header override</Button>
              </FormField>
            </div>
          );
        })}
      </FormField>
    </div>
  );
}
  • Step 2: TypeScript compile + commit
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

// 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 (
    <div style={{ display: 'grid', gap: 12, maxWidth: 720 }}>
      <div><strong>Name:</strong> {form.name}</div>
      <div><strong>Severity:</strong> {form.severity}</div>
      <div><strong>Scope:</strong> {form.scopeKind}
        {form.scopeKind !== 'env' && ` (app=${form.appSlug}${form.routeId ? `, route=${form.routeId}` : ''}${form.agentId ? `, agent=${form.agentId}` : ''})`}
      </div>
      <div><strong>Condition kind:</strong> {form.conditionKind}</div>
      <div><strong>Intervals:</strong> eval {form.evaluationIntervalSeconds}s · for {form.forDurationSeconds}s · re-notify {form.reNotifyMinutes}m</div>
      <div><strong>Targets:</strong> {form.targets.length}</div>
      <div><strong>Webhooks:</strong> {form.webhooks.length}</div>
      {setForm && (
        <label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          <Toggle checked={form.enabled} onChange={(v) => setForm({ ...form, enabled: v })} />
          Enabled on save
        </label>
      )}
      <details>
        <summary>Raw request JSON</summary>
        <pre style={{ fontSize: 11, background: 'var(--code-bg)', padding: 8, borderRadius: 6, overflow: 'auto' }}>
          {JSON.stringify(req, null, 2)}
        </pre>
      </details>
    </div>
  );
}
  • Step 2: Rewrite promotion-prefill.ts with warnings
// 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
// 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> = {}): 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
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:

// 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<PrefillWarning[]>([]);

Replace the initializer block:

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:

{warnings.length > 0 && (
  <div className={css.promoteBanner}>
    <strong>Review before saving:</strong>
    <ul>{warnings.map((w) => <li key={w.field}><code>{w.field}</code>: {w.message}</li>)}</ul>
  </div>
)}
  • Step 2: TypeScript compile + commit
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

// 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 <PageLoader />;
  if (error) return <div>Failed to load silences: {String(error)}</div>;

  const onCreate = async () => {
    const now = new Date();
    const endsAt = new Date(now.getTime() + hours * 3600_000);
    const matcher: Record<string, string> = {};
    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 (
    <div style={{ padding: 16 }}>
      <SectionHeader>Alert silences</SectionHeader>
      <div className={sectionStyles.section} style={{ marginTop: 12 }}>
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr) auto', gap: 8, alignItems: 'end' }}>
          <FormField label="Rule ID (optional)"><Input value={matcherRuleId} onChange={(e) => setMatcherRuleId(e.target.value)} /></FormField>
          <FormField label="App slug (optional)"><Input value={matcherAppSlug} onChange={(e) => setMatcherAppSlug(e.target.value)} /></FormField>
          <FormField label="Duration (hours)"><Input type="number" min={1} value={hours} onChange={(e) => setHours(Number(e.target.value))} /></FormField>
          <FormField label="Reason"><Input value={reason} onChange={(e) => setReason(e.target.value)} placeholder="Maintenance window" /></FormField>
          <Button variant="primary" onClick={onCreate} disabled={create.isPending}>Create silence</Button>
        </div>
      </div>
      <div className={sectionStyles.section} style={{ marginTop: 16 }}>
        {rows.length === 0 ? (
          <p>No active or scheduled silences.</p>
        ) : (
          <table style={{ width: '100%', borderCollapse: 'collapse' }}>
            <thead>
              <tr>
                <th style={{ textAlign: 'left' }}>Matcher</th>
                <th style={{ textAlign: 'left' }}>Reason</th>
                <th style={{ textAlign: 'left' }}>Starts</th>
                <th style={{ textAlign: 'left' }}>Ends</th>
                <th></th>
              </tr>
            </thead>
            <tbody>
              {rows.map((s) => (
                <tr key={s.id}>
                  <td><code>{JSON.stringify(s.matcher)}</code></td>
                  <td>{s.reason ?? '—'}</td>
                  <td>{s.startsAt}</td>
                  <td>{s.endsAt}</td>
                  <td><Button variant="secondary" onClick={() => onRemove(s)}>End</Button></td>
                </tr>
              ))}
            </tbody>
          </table>
        )}
      </div>
    </div>
  );
}
  • Step 2: TypeScript compile + commit
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):

import { useAlerts } from '../api/queries/alerts';
import { useAlertRules } from '../api/queries/alertRules';
  • Step 2: Build alert/alert-rule SearchResult[]

Near buildSearchData and buildAdminSearchData, add:

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):

// 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:

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:

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
cd ui && npx tsc -p tsconfig.app.json --noEmit

Expected: PASS.

  • Step 7: Commit
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
// 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
// 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:

// 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:

@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
mvn -pl cameleer-server-app -am verify -Dtest='SsrfGuardTest,OutboundConnectionAdminControllerIT'

Expected: all tests pass.

  • Step 6: Commit
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
// 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<Instant> 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:

// Inside AlertingMetrics
private static final class TtlCache {
    private final Supplier<Long> delegate;
    private final Duration ttl;
    private final Supplier<Instant> clock;
    private volatile Instant lastRead = Instant.MIN;
    private volatile long cached = 0L;

    TtlCache(Supplier<Long> delegate, Duration ttl, Supplier<Instant> 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
mvn -pl cameleer-server-app -am test -Dtest=AlertingMetricsCachingTest

Expected: PASS.

  • Step 4: Commit
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

// 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
// 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)
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
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:

## 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
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)
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
cd ui && npm run lint

Expected: zero errors. Fix warnings introduced by Plan 03 files; ignore pre-existing ones.

  • Step 3: Frontend unit tests
cd ui && npm test

Expected: all Plan 03 Vitest suites pass (≥ 25 tests across hooks, chips, editor, form-state, prefill).

  • Step 4: Backend verify
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
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 <NotificationBell /> Tasks 9, 15
§13 <AlertStateChip /> Task 8
§13 Rule editor 5-step wizard Tasks 1925
§13 <MustacheEditor /> with variable autocomplete Tasks 1012
§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<Resource>() (list), use<Resource>(id) (single), useCreate<Resource>, useUpdate<Resource>, useDelete<Resource>.

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?