diff --git a/docs/superpowers/plans/2026-04-20-alerting-03-ui.md b/docs/superpowers/plans/2026-04-20-alerting-03-ui.md new file mode 100644 index 00000000..f0ea0880 --- /dev/null +++ b/docs/superpowers/plans/2026-04-20-alerting-03-ui.md @@ -0,0 +1,5031 @@ +# 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 `` 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 `` (§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/`: + +```bash +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`** + +```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`** + +```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`: + +```jsonc +"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`: + +```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** + +```bash +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/`: + +```bash +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`** + +```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** + +```bash +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`: + +```jsonc +"test:e2e": "playwright test", +"test:e2e:ui": "playwright test --ui", +``` + +- [ ] **Step 6: Commit** + +```bash +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/`: + +```bash +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** + +```bash +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** + +```bash +cd ui && npx tsc -p tsconfig.app.json --noEmit +``` + +Expected: no new errors beyond what main already has. + +- [ ] **Step 4: Commit** + +```bash +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** + +```ts +// 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** + +```bash +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** + +```ts +// 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(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** + +```ts +// 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 {children}; +} + +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** + +```bash +cd ui && npm test -- alerts.test +``` + +Expected: 3 tests pass. + +- [ ] **Step 4: Commit** + +```bash +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** + +```ts +// 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** + +```ts +// 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 {children}; +} + +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** + +```bash +cd ui && npm test -- alertRules.test +``` + +Expected: 3 tests pass. + +- [ ] **Step 4: Commit** + +```bash +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** + +```ts +// 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** + +```ts +// 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** + +```ts +// 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 {children}; +} + +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** + +```bash +cd ui && npm test -- alertSilences.test +``` + +Expected: 1 test passes. + +- [ ] **Step 5: Commit** + +```bash +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`** + +```tsx +// 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(); + expect(screen.getByText(pattern)).toBeInTheDocument(); + }); + + it('shows silenced suffix when silenced=true', () => { + render(); + expect(screen.getByText(/silenced/i)).toBeInTheDocument(); + }); +}); +``` + +Run: `cd ui && npm test -- AlertStateChip` +Expected: FAIL (module not found). + +- [ ] **Step 2: Implement `AlertStateChip`** + +```tsx +// 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 = { + PENDING: 'Pending', + FIRING: 'Firing', + ACKNOWLEDGED: 'Acknowledged', + RESOLVED: 'Resolved', +}; + +const COLORS: Record = { + PENDING: 'warning', + FIRING: 'error', + ACKNOWLEDGED: 'warning', + RESOLVED: 'success', +}; + +export function AlertStateChip({ state, silenced }: { state: State; silenced?: boolean }) { + return ( + + + {silenced && } + + ); +} +``` + +Run: `cd ui && npm test -- AlertStateChip` +Expected: 5 tests pass. + +- [ ] **Step 3: Write failing test for `SeverityBadge`** + +```tsx +// 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(); + expect(screen.getByText(pattern)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 4: Implement `SeverityBadge`** + +```tsx +// 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 = { + CRITICAL: 'Critical', + WARNING: 'Warning', + INFO: 'Info', +}; + +const COLORS: Record = { + CRITICAL: 'error', + WARNING: 'warning', + INFO: 'auto', +}; + +export function SeverityBadge({ severity }: { severity: Severity }) { + return ; +} +``` + +Run: `cd ui && npm test -- SeverityBadge` +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +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** + +```ts +// 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`** + +```ts +// 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`** + +```tsx +// 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 ( + + {children} + + ); +} + +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(, { 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(, { wrapper }); + expect(await screen.findByText('3')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 4: Implement `NotificationBell`** + +```tsx +// 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(() => { + 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 ( + + + {total > 0 && ( + + {total > 99 ? '99+' : total} + + )} + + ); +} +``` + +- [ ] **Step 5: Create matching CSS module** + +```css +/* 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** + +```bash +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 — `` 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** + +```ts +// 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** + +```ts +// 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** + +```bash +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** + +```ts +// 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)** + +```ts +// 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** + +```ts +// 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** + +```ts +// 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** + +```bash +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: `` 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** + +```tsx +// 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( + {}} + kind="ROUTE_METRIC" + label="Title template" + />, + ); + expect(screen.getByText(/Hello/)).toBeInTheDocument(); + }); + + it('calls onChange when the user types', () => { + const onChange = vi.fn(); + render( + , + ); + 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** + +```tsx +// 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(null); + const viewRef = useRef(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 ( +
+ +
+
+ ); +} +``` + +- [ ] **Step 3: Write the CSS module** + +```css +/* 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** + +```bash +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** + +```bash +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): + +```tsx +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 `` children array, after the `apps/:appId` entry and before the Admin block (around line 77), insert: + +```tsx +// Alerts section (VIEWER+ via backend RBAC; UI is visible to all authenticated) +{ path: 'alerts', element: }, +{ path: 'alerts/inbox', element: }, +{ path: 'alerts/all', element: }, +{ path: 'alerts/history', element: }, +{ path: 'alerts/rules', element: }, +{ path: 'alerts/rules/new', element: }, +{ path: 'alerts/rules/:id', element: }, +{ path: 'alerts/silences', element: }, +``` + +- [ ] **Step 3: TypeScript compile passes** + +```bash +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. + +```tsx +// ui/src/pages/Alerts/InboxPage.tsx +export default function InboxPage() { + return
Inbox — coming soon
; +} +``` + +Repeat for `AllAlertsPage.tsx`, `HistoryPage.tsx`, `RulesListPage.tsx`, `SilencesPage.tsx`. + +```tsx +// ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx +export default function RuleEditorWizard() { + return
Rule editor wizard — coming soon
; +} +``` + +- [ ] **Step 5: TypeScript compile passes cleanly** + +```bash +cd ui && npx tsc -p tsconfig.app.json --noEmit +``` + +Expected: PASS (zero errors). + +- [ ] **Step 6: Commit** + +```bash +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`): + +```ts +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`** + +```ts +// 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: + +```ts +const SK_ALERTS = 'sidebar:section:alerts'; +``` + +Import the helper alongside existing imports from `sidebar-utils`: + +```ts +import { + buildAppTreeNodes, + buildAdminTreeNodes, + buildAlertsTreeNodes, + // ... existing +} from './sidebar-utils'; +``` + +Add an `alertsOpen` state near the existing `appsOpen` / `adminOpen` (around line 370): + +```ts +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): + +```ts +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 `` for Alerts between Applications and Starred (around line 753, right after the Applications section closing ``): + +```tsx + + + +``` + +(Also add `Bell` to the existing `lucide-react` import.) + +- [ ] **Step 4: TypeScript compile** + +```bash +cd ui && npx tsc -p tsconfig.app.json --noEmit +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +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 `` in TopBar + +**Files:** +- Modify: `ui/src/components/LayoutShell.tsx` + +- [ ] **Step 1: Import the bell** + +Near the existing `import { AboutMeDialog } from './AboutMeDialog';`, add: + +```ts +import { NotificationBell } from './NotificationBell'; +``` + +- [ ] **Step 2: Mount the bell in TopBar children** + +Locate the `` children (around line 840, `` is the first child). Insert the bell before `SearchTrigger`: + +```tsx + + 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** + +```bash +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** + +```bash +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** + +```tsx +// 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 ( +
+ +
+ markRead.mutate(alert.id)}> + {alert.title} + +
+ + {alert.firedAt} +
+

{alert.message}

+
+
+ {alert.state === 'FIRING' && ( + + )} +
+
+ ); +} +``` + +- [ ] **Step 2: Build the CSS module** + +```css +/* 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`** + +```tsx +// 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 ; + if (error) return
Failed to load alerts: {String(error)}
; + + 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 ( +
+
+ Inbox + +
+ {rows.length === 0 ? ( +
No open alerts for you in this environment.
+ ) : ( + rows.map((a) => ) + )} +
+ ); +} +``` + +- [ ] **Step 4: TypeScript compile** + +```bash +cd ui && npx tsc -p tsconfig.app.json --noEmit +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +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`** + +```tsx +// 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 ; + if (error) return
Failed to load alerts: {String(error)}
; + + const rows = data ?? []; + + return ( +
+
+ All alerts +
+ {STATE_FILTERS.map((f, i) => ( + + ))} +
+
+ {rows.length === 0 ? ( +
No alerts match this filter.
+ ) : ( + rows.map((a) => ) + )} +
+ ); +} +``` + +- [ ] **Step 2: Write `HistoryPage`** + +```tsx +// 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 ; + if (error) return
Failed to load history: {String(error)}
; + + const rows = data ?? []; + + return ( +
+
+ History +
+ {rows.length === 0 ? ( +
No resolved alerts in retention window.
+ ) : ( + rows.map((a) => ) + )} +
+ ); +} +``` + +- [ ] **Step 3: TypeScript compile + commit** + +```bash +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** + +```tsx +// 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 ; + if (error) return
Failed to load rules: {String(error)}
; + + 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 ( +
+
+ Alert rules + + + +
+
+ {rows.length === 0 ? ( +

No rules yet. Create one to start evaluating alerts for this environment.

+ ) : ( + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + ))} + +
NameKindSeverityEnabledTargets
{r.name} + onToggle(r)} + disabled={setEnabled.isPending} + /> + {r.targets.length} + {otherEnvs.length > 0 && ( + + )} + +
+ )} +
+
+ ); +} +``` + +- [ ] **Step 2: TypeScript compile + commit** + +```bash +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** + +```ts +// 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; + + 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, + 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) + .map(([key, value]) => ({ key, value })), + })), + targets: existing.targets ?? [], + }; +} + +export function toRequest(f: FormState): AlertRuleRequest { + const scope: Record = {}; + if (f.scopeKind === 'app' || f.scopeKind === 'route' || f.scopeKind === 'agent') scope.appSlug = f.appSlug || undefined; + if (f.scopeKind === 'route') scope.routeId = f.routeId || undefined; + if (f.scopeKind === 'agent') scope.agentId = f.agentId || undefined; + + const condition = { ...f.condition, 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** + +```ts +// 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** + +```tsx +// 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 = { + 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('scope'); + const [form, setForm] = useState(null); + + // Initialize form once the existing or source rule loads. + const ready = useMemo(() => { + if (form) return true; + if (isEdit && existingQuery.data) { + setForm(initialForm(existingQuery.data)); + return true; + } + if (promoteFrom && sourceRuleQuery.data) { + setForm(prefillFromPromotion(sourceRuleQuery.data)); + return true; + } + if (!isEdit && !promoteFrom) { + setForm(initialForm()); + return true; + } + return false; + }, [form, isEdit, existingQuery.data, promoteFrom, sourceRuleQuery.data]); + + const create = useCreateAlertRule(); + const update = useUpdateAlertRule(id ?? ''); + + if (!ready || !form) return ; + + const idx = WIZARD_STEPS.indexOf(step); + const errors = validateStep(step, form); + + const onNext = () => { + if (errors.length > 0) { + toast({ title: 'Fix validation errors before continuing', description: errors.join(' · '), variant: 'error' }); + return; + } + if (idx < WIZARD_STEPS.length - 1) setStep(WIZARD_STEPS[idx + 1]); + }; + const onBack = () => { if (idx > 0) setStep(WIZARD_STEPS[idx - 1]); }; + + const onSave = async () => { + try { + if (isEdit) { + await update.mutateAsync(toRequest(form)); + toast({ title: 'Rule updated', description: form.name, variant: 'success' }); + } else { + await create.mutateAsync(toRequest(form)); + toast({ title: 'Rule created', description: form.name, variant: 'success' }); + } + navigate('/alerts/rules'); + } catch (e) { + toast({ title: 'Save failed', description: String(e), variant: 'error' }); + } + }; + + const body = + step === 'scope' ? : + step === 'condition' ? : + step === 'trigger' ? : + step === 'notify' ? : + ; + + return ( +
+
+ {isEdit ? `Edit rule: ${form.name}` : 'New alert rule'} + {promoteFrom && ( +
+ Promoting from {promoteFrom} — review and adjust, then save. +
+ )} +
+ +
{body}
+
+ + {idx < WIZARD_STEPS.length - 1 ? ( + + ) : ( + + )} +
+
+ ); +} +``` + +- [ ] **Step 4: Write the CSS module** + +```css +/* ui/src/pages/Alerts/RuleEditor/wizard.module.css */ +.wizard { padding: 16px; display: flex; flex-direction: column; gap: 16px; } +.header { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; } +.promoteBanner { + padding: 8px 12px; + background: var(--amber-bg, rgba(255, 180, 0, 0.12)); + border: 1px solid var(--amber); + border-radius: 6px; + font-size: 13px; +} +.steps { display: flex; gap: 8px; border-bottom: 1px solid var(--border); padding-bottom: 8px; } +.step { + background: none; border: none; padding: 8px 12px; + border-bottom: 2px solid transparent; cursor: pointer; + color: var(--muted); + font-size: 13px; +} +.stepActive { color: var(--fg); border-bottom-color: var(--accent); } +.stepDone { color: var(--fg); } +.stepBody { min-height: 320px; } +.footer { display: flex; justify-content: space-between; } +``` + +- [ ] **Step 5: Create step stubs so compile passes** + +`ScopeStep.tsx`, `ConditionStep.tsx`, `TriggerStep.tsx`, `NotifyStep.tsx`, `ReviewStep.tsx` each as: + +```tsx +// ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx (repeat for each) +import type { FormState } from './form-state'; +export function ScopeStep({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) { + return
Scope step — TODO Task 20
; +} +``` + +And `promotion-prefill.ts`: + +```ts +// ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts +import { initialForm, type FormState } from './form-state'; +import type { AlertRuleResponse } from '../../../api/queries/alertRules'; + +export function prefillFromPromotion(source: AlertRuleResponse): FormState { + // Reuse the edit-prefill for now; Task 24 adds scope-adjustment + warnings. + const f = initialForm(source); + f.name = `${source.name} (copy)`; + return f; +} +``` + +- [ ] **Step 6: TypeScript compile + commit** + +```bash +cd ui && npx tsc -p tsconfig.app.json --noEmit +cd ui && npm test -- form-state +git add ui/src/pages/Alerts/RuleEditor/ +git commit -m "feat(ui/alerts): rule editor wizard shell + form-state module + +Wizard navigates steps (scope/condition/trigger/notify/review) with +per-step validation. form-state module is the single source of truth for +the rule form; initialForm/toRequest/validateStep are unit-tested. Step +components are stubbed and implemented in Tasks 20–24." +``` + +--- + +### Task 20: `ScopeStep` + +**Files:** +- Replace: `ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx` + +- [ ] **Step 1: Implement the scope form** + +```tsx +// ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx +import { FormField, Input, Select } from '@cameleer/design-system'; +import { useCatalog } from '../../../api/queries/catalog'; +import { useAgents } from '../../../api/queries/agents'; +import { useSelectedEnv } from '../../../api/queries/alertMeta'; +import type { FormState } from './form-state'; + +const SEVERITY_OPTIONS = [ + { value: 'CRITICAL', label: 'Critical' }, + { value: 'WARNING', label: 'Warning' }, + { value: 'INFO', label: 'Info' }, +]; + +const SCOPE_OPTIONS = [ + { value: 'env', label: 'Environment-wide' }, + { value: 'app', label: 'Single app' }, + { value: 'route', label: 'Single route' }, + { value: 'agent', label: 'Single agent' }, +]; + +export function ScopeStep({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) { + const env = useSelectedEnv(); + const { data: catalog } = useCatalog(env); + const { data: agents } = useAgents(); + + const apps = (catalog ?? []).map((a: any) => ({ slug: a.slug, name: a.displayName ?? a.slug, routes: a.routes ?? [] })); + const selectedApp = apps.find((a) => a.slug === form.appSlug); + const routes = selectedApp?.routes ?? []; + const appAgents = (agents ?? []).filter((a: any) => a.applicationId === form.appSlug); + + return ( +
+ + setForm({ ...form, name: e.target.value })} placeholder="Order API error rate" /> + + + setForm({ ...form, description: e.target.value })} /> + + + setForm({ ...form, scopeKind: v as FormState['scopeKind'] })} + options={SCOPE_OPTIONS} + /> + + {form.scopeKind !== 'env' && ( + + setForm({ ...form, routeId: v })} + options={routes.map((r: any) => ({ value: r.routeId, label: r.routeId }))} + /> + + )} + {form.scopeKind === 'agent' && ( + + + + {form.conditionKind === 'ROUTE_METRIC' && } + {form.conditionKind === 'EXCHANGE_MATCH' && } + {form.conditionKind === 'AGENT_STATE' && } + {form.conditionKind === 'DEPLOYMENT_STATE' && } + {form.conditionKind === 'LOG_PATTERN' && } + {form.conditionKind === 'JVM_METRIC' && } +
+ ); +} +``` + +- [ ] **Step 2: `RouteMetricForm`** + +```tsx +// ui/src/pages/Alerts/RuleEditor/condition-forms/RouteMetricForm.tsx +import { FormField, Input, Select } from '@cameleer/design-system'; +import type { FormState } from '../form-state'; + +const METRICS = [ + { value: 'ERROR_RATE', label: 'Error rate' }, + { value: 'P95_LATENCY_MS', label: 'P95 latency (ms)' }, + { value: 'P99_LATENCY_MS', label: 'P99 latency (ms)' }, + { value: 'AVG_DURATION_MS',label: 'Avg duration (ms)' }, + { value: 'THROUGHPUT', label: 'Throughput (msg/s)' }, + { value: 'ERROR_COUNT', label: 'Error count' }, +]; +const COMPARATORS = [ + { value: 'GT', label: '>' }, + { value: 'GTE', label: '≥' }, + { value: 'LT', label: '<' }, + { value: 'LTE', label: '≤' }, +]; + +export function RouteMetricForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) { + const c: any = form.condition; + const patch = (p: any) => setForm({ ...form, condition: { ...form.condition, ...p } }); + return ( + <> + patch({ comparator: v })} options={COMPARATORS} /> + patch({ threshold: Number(e.target.value) })} /> + patch({ windowSeconds: Number(e.target.value) })} /> + + ); +} +``` + +- [ ] **Step 3: `ExchangeMatchForm` (PER_EXCHANGE or COUNT_IN_WINDOW)** + +```tsx +// ui/src/pages/Alerts/RuleEditor/condition-forms/ExchangeMatchForm.tsx +import { FormField, Input, Select } from '@cameleer/design-system'; +import type { FormState } from '../form-state'; + +const FIRE_MODES = [ + { value: 'PER_EXCHANGE', label: 'One alert per matching exchange' }, + { value: 'COUNT_IN_WINDOW', label: 'Threshold: N matches in window' }, +]; +const STATUSES = [ + { value: '', label: '(any)' }, + { value: 'COMPLETED', label: 'COMPLETED' }, + { value: 'FAILED', label: 'FAILED' }, + { value: 'RUNNING', label: 'RUNNING' }, +]; + +export function ExchangeMatchForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) { + const c: any = form.condition; + const patch = (p: any) => setForm({ ...form, condition: { ...form.condition, ...p } }); + const filter = c.filter ?? {}; + return ( + <> + patch({ filter: { ...filter, status: v || undefined } })} options={STATUSES} /> + {c.fireMode === 'PER_EXCHANGE' && ( + + patch({ perExchangeLingerSeconds: Number(e.target.value) })} /> + + )} + {c.fireMode === 'COUNT_IN_WINDOW' && ( + <> + patch({ threshold: Number(e.target.value) })} /> + patch({ windowSeconds: Number(e.target.value) })} /> + + )} + + ); +} +``` + +- [ ] **Step 4: `AgentStateForm`, `DeploymentStateForm`, `LogPatternForm`, `JvmMetricForm`** + +Each follows the same pattern. Keep the code short — they're simple enum/threshold pickers. + +```tsx +// ui/src/pages/Alerts/RuleEditor/condition-forms/AgentStateForm.tsx +import { FormField, Input, Select } from '@cameleer/design-system'; +import type { FormState } from '../form-state'; + +export function AgentStateForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) { + const c: any = form.condition; + const patch = (p: any) => setForm({ ...form, condition: { ...form.condition, ...p } }); + return ( + <> + + patch({ forSeconds: Number(e.target.value) })} /> + + + ); +} +``` + +```tsx +// ui/src/pages/Alerts/RuleEditor/condition-forms/DeploymentStateForm.tsx +import { FormField } from '@cameleer/design-system'; +import type { FormState } from '../form-state'; + +const OPTIONS = ['FAILED', 'DEGRADED'] as const; + +export function DeploymentStateForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) { + const c: any = form.condition; + const states: string[] = c.states ?? []; + const toggle = (s: string) => { + const next = states.includes(s) ? states.filter((x) => x !== s) : [...states, s]; + setForm({ ...form, condition: { ...form.condition, states: next } }); + }; + return ( + +
+ {OPTIONS.map((s) => ( + + ))} +
+
+ ); +} +``` + +```tsx +// ui/src/pages/Alerts/RuleEditor/condition-forms/LogPatternForm.tsx +import { FormField, Input, Select } from '@cameleer/design-system'; +import type { FormState } from '../form-state'; + +export function LogPatternForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) { + const c: any = form.condition; + const patch = (p: any) => setForm({ ...form, condition: { ...form.condition, ...p } }); + return ( + <> + + patch({ logger: e.target.value || undefined })} /> + + + patch({ pattern: e.target.value })} /> + + + patch({ threshold: Number(e.target.value) })} /> + + + patch({ windowSeconds: Number(e.target.value) })} /> + + + ); +} +``` + +```tsx +// ui/src/pages/Alerts/RuleEditor/condition-forms/JvmMetricForm.tsx +import { FormField, Input, Select } from '@cameleer/design-system'; +import type { FormState } from '../form-state'; + +export function JvmMetricForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) { + const c: any = form.condition; + const patch = (p: any) => setForm({ ...form, condition: { ...form.condition, ...p } }); + return ( + <> + + patch({ metric: e.target.value })} placeholder="heap_used_percent" /> + + + patch({ comparator: v })} options={[ + { value: 'GT', label: '>' }, { value: 'GTE', label: '≥' }, { value: 'LT', label: '<' }, { value: 'LTE', label: '≤' }, + ]} /> + + + patch({ threshold: Number(e.target.value) })} /> + + + patch({ windowSeconds: Number(e.target.value) })} /> + + + ); +} +``` + +- [ ] **Step 5: TypeScript compile + commit** + +```bash +cd ui && npx tsc -p tsconfig.app.json --noEmit +git add ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx ui/src/pages/Alerts/RuleEditor/condition-forms/ +git commit -m "feat(ui/alerts): ConditionStep with 6 kind-specific forms + +Each kind renders its own payload shape. Kind change resets condition to +{kind} so stale fields from a previous kind don't leak into save payload." +``` + +--- + +### Task 22: `TriggerStep` + +**Files:** +- Replace: `ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx` + +- [ ] **Step 1: Write the step** + +```tsx +// ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx +import { useState } from 'react'; +import { Button, FormField, Input, useToast } from '@cameleer/design-system'; +import { useTestEvaluate } from '../../../api/queries/alertRules'; +import type { FormState } from './form-state'; +import { toRequest } from './form-state'; + +export function TriggerStep({ form, setForm, ruleId }: { form: FormState; setForm: (f: FormState) => void; ruleId?: string }) { + const testEvaluate = useTestEvaluate(); + const { toast } = useToast(); + const [lastResult, setLastResult] = useState(null); + + const onTest = async () => { + if (!ruleId) { + toast({ title: 'Save rule first to run test evaluate', variant: 'error' }); + return; + } + try { + const result = await testEvaluate.mutateAsync({ id: ruleId, req: { condition: (toRequest(form).condition as any) } }); + setLastResult(JSON.stringify(result, null, 2)); + } catch (e) { + toast({ title: 'Test-evaluate failed', description: String(e), variant: 'error' }); + } + }; + + return ( +
+ + setForm({ ...form, evaluationIntervalSeconds: Number(e.target.value) })} /> + + + setForm({ ...form, forDurationSeconds: Number(e.target.value) })} /> + + + setForm({ ...form, reNotifyMinutes: Number(e.target.value) })} /> + +
+ + {lastResult && ( +
+            {lastResult}
+          
+ )} +
+
+ ); +} +``` + +- [ ] **Step 2: TypeScript compile + commit** + +```bash +cd ui && npx tsc -p tsconfig.app.json --noEmit +git add ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx +git commit -m "feat(ui/alerts): TriggerStep (evaluation interval, for-duration, re-notify, test-evaluate)" +``` + +--- + +### Task 23: `NotifyStep` (MustacheEditor + targets + webhooks) + +**Files:** +- Replace: `ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx` + +- [ ] **Step 1: Implement the step** + +```tsx +// ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx +import { useState } from 'react'; +import { Button, FormField, Select, Input, useToast, Badge } from '@cameleer/design-system'; +import { MustacheEditor } from '../../../components/MustacheEditor/MustacheEditor'; +import { useUsers, useGroups, useRoles } from '../../../api/queries/admin/rbac'; +import { useOutboundConnections } from '../../../api/queries/admin/outboundConnections'; +import { useSelectedEnv } from '../../../api/queries/alertMeta'; +import { useRenderPreview } from '../../../api/queries/alertRules'; +import { toRequest, type FormState } from './form-state'; + +export function NotifyStep({ form, setForm, ruleId }: { form: FormState; setForm: (f: FormState) => void; ruleId?: string }) { + const env = useSelectedEnv(); + const { data: users } = useUsers(true); + const { data: groups } = useGroups(true); + const { data: roles } = useRoles(true); + const { data: connections } = useOutboundConnections(); + const preview = useRenderPreview(); + const { toast } = useToast(); + const [lastPreview, setLastPreview] = useState(null); + + // Filter connections to those that allow the current env. + const availableConnections = (connections ?? []).filter( + (c) => c.allowedEnvironmentIds.length === 0 || c.allowedEnvironmentIds.includes(env!), + ); + + const onPreview = async () => { + if (!ruleId) { + toast({ title: 'Save rule first to preview', variant: 'error' }); + return; + } + try { + const res = await preview.mutateAsync({ + id: ruleId, + req: { + titleTemplate: form.notificationTitleTmpl, + messageTemplate: form.notificationMessageTmpl, + }, + }); + setLastPreview(`TITLE:\n${(res as any).renderedTitle}\n\nMESSAGE:\n${(res as any).renderedMessage}`); + } catch (e) { + toast({ title: 'Preview failed', description: String(e), variant: 'error' }); + } + }; + + const addTarget = (targetKind: 'USER' | 'GROUP' | 'ROLE', targetId: string) => { + if (!targetId) return; + if (form.targets.some((t) => t.targetKind === targetKind && t.targetId === targetId)) return; + setForm({ ...form, targets: [...form.targets, { targetKind, targetId }] }); + }; + const removeTarget = (idx: number) => { + setForm({ ...form, targets: form.targets.filter((_, i) => i !== idx) }); + }; + + const addWebhook = (outboundConnectionId: string) => { + setForm({ + ...form, + webhooks: [...form.webhooks, { outboundConnectionId, bodyOverride: '', headerOverrides: [] }], + }); + }; + const removeWebhook = (idx: number) => { + setForm({ ...form, webhooks: form.webhooks.filter((_, i) => i !== idx) }); + }; + const updateWebhook = (idx: number, patch: Partial) => { + setForm({ ...form, webhooks: form.webhooks.map((w, i) => i === idx ? { ...w, ...patch } : w) }); + }; + + return ( +
+ setForm({ ...form, notificationTitleTmpl: v })} + kind={form.conditionKind} + singleLine + /> + setForm({ ...form, notificationMessageTmpl: v })} + kind={form.conditionKind} + minHeight={120} + /> +
+ + {lastPreview && ( +
+            {lastPreview}
+          
+ )} +
+ + +
+ {form.targets.map((t, i) => ( + removeTarget(i)} + /> + ))} +
+
+ addTarget('GROUP', v)} options={[{ value: '', label: '+ Group' }, ...(groups ?? []).map((g) => ({ value: g.id, label: g.name }))]} /> + { if (v) addWebhook(v); }} + options={[ + { value: '', label: '+ Add webhook' }, + ...availableConnections.map((c) => ({ value: c.id, label: c.name })), + ]} + /> + {form.webhooks.map((w, i) => { + const conn = availableConnections.find((c) => c.id === w.outboundConnectionId); + return ( +
+
+ {conn?.name ?? w.outboundConnectionId} + +
+ updateWebhook(i, { bodyOverride: v })} + kind={form.conditionKind} + placeholder="Leave empty to use connection default" + minHeight={80} + /> + + {w.headerOverrides.map((h, hi) => ( +
+ { + const heads = [...w.headerOverrides]; heads[hi] = { ...heads[hi], key: e.target.value }; + updateWebhook(i, { headerOverrides: heads }); + }} /> + { + const heads = [...w.headerOverrides]; heads[hi] = { ...heads[hi], value: e.target.value }; + updateWebhook(i, { headerOverrides: heads }); + }} /> + +
+ ))} + +
+
+ ); + })} + +
+ ); +} +``` + +- [ ] **Step 2: TypeScript compile + commit** + +```bash +cd ui && npx tsc -p tsconfig.app.json --noEmit +git add ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx +git commit -m "feat(ui/alerts): NotifyStep (MustacheEditor for title/message/body, targets, webhook bindings) + +Targets combine users/groups/roles into a unified pill list. Webhook picker +filters to connections allowed in the current env (spec §6 allowed_env_ids). +Header overrides use Input rather than MustacheEditor for now — header +autocomplete can be added in a future polish pass if ops teams ask for it." +``` + +--- + +### Task 24: `ReviewStep` + promotion-prefill warnings + +**Files:** +- Replace: `ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx` +- Replace: `ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts` +- Create: `ui/src/pages/Alerts/RuleEditor/promotion-prefill.test.ts` + +- [ ] **Step 1: Write the review step** + +```tsx +// ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx +import type { FormState } from './form-state'; +import { toRequest } from './form-state'; +import { Toggle } from '@cameleer/design-system'; + +export function ReviewStep({ form, setForm }: { form: FormState; setForm?: (f: FormState) => void }) { + const req = toRequest(form); + return ( +
+
Name: {form.name}
+
Severity: {form.severity}
+
Scope: {form.scopeKind} + {form.scopeKind !== 'env' && ` (app=${form.appSlug}${form.routeId ? `, route=${form.routeId}` : ''}${form.agentId ? `, agent=${form.agentId}` : ''})`} +
+
Condition kind: {form.conditionKind}
+
Intervals: eval {form.evaluationIntervalSeconds}s · for {form.forDurationSeconds}s · re-notify {form.reNotifyMinutes}m
+
Targets: {form.targets.length}
+
Webhooks: {form.webhooks.length}
+ {setForm && ( + + )} +
+ Raw request JSON +
+          {JSON.stringify(req, null, 2)}
+        
+
+
+ ); +} +``` + +- [ ] **Step 2: Rewrite `promotion-prefill.ts` with warnings** + +```ts +// ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts +import { initialForm, type FormState } from './form-state'; +import type { AlertRuleResponse } from '../../../api/queries/alertRules'; + +export interface PrefillWarning { + field: string; + message: string; +} + +/** Client-side prefill when promoting a rule from another env. Emits warnings for + * fields that cross env boundaries (agent IDs, outbound connection env-restrictions). */ +export function prefillFromPromotion( + source: AlertRuleResponse, + opts: { + targetEnvAppSlugs?: string[]; + targetEnvAllowedConnectionIds?: string[]; // IDs allowed in target env + } = {}, +): { form: FormState; warnings: PrefillWarning[] } { + const form = initialForm(source); + form.name = `${source.name} (copy)`; + const warnings: PrefillWarning[] = []; + + // Agent IDs are per-env, can't transfer. + if (form.agentId) { + warnings.push({ + field: 'scope.agentId', + message: `Agent \`${form.agentId}\` is specific to the source env — cleared for target env.`, + }); + form.agentId = ''; + if (form.scopeKind === 'agent') form.scopeKind = 'app'; + } + + // App slug: warn if not present in target env. + if (form.appSlug && opts.targetEnvAppSlugs && !opts.targetEnvAppSlugs.includes(form.appSlug)) { + warnings.push({ + field: 'scope.appSlug', + message: `App \`${form.appSlug}\` does not exist in the target env. Update before saving.`, + }); + } + + // Webhook connections: warn if connection is not allowed in target env. + if (opts.targetEnvAllowedConnectionIds) { + for (const w of form.webhooks) { + if (!opts.targetEnvAllowedConnectionIds.includes(w.outboundConnectionId)) { + warnings.push({ + field: `webhooks[${w.outboundConnectionId}]`, + message: `Outbound connection is not allowed in the target env — remove or pick another before saving.`, + }); + } + } + } + + return { form, warnings }; +} +``` + +- [ ] **Step 3: Test the prefill logic** + +```ts +// ui/src/pages/Alerts/RuleEditor/promotion-prefill.test.ts +import { describe, it, expect } from 'vitest'; +import { prefillFromPromotion } from './promotion-prefill'; +import type { AlertRuleResponse } from '../../../api/queries/alertRules'; + +function fakeRule(overrides: Partial = {}): AlertRuleResponse { + return { + id: '11111111-1111-1111-1111-111111111111', + environmentId: '22222222-2222-2222-2222-222222222222', + name: 'High error rate', + description: null as any, + severity: 'CRITICAL', + enabled: true, + conditionKind: 'ROUTE_METRIC', + condition: { kind: 'ROUTE_METRIC', scope: { appSlug: 'orders' } } as any, + evaluationIntervalSeconds: 60, + forDurationSeconds: 0, + reNotifyMinutes: 60, + notificationTitleTmpl: '{{rule.name}}', + notificationMessageTmpl: 'msg', + webhooks: [], + targets: [], + createdAt: '2026-04-01T00:00:00Z' as any, + createdBy: 'alice', + updatedAt: '2026-04-01T00:00:00Z' as any, + updatedBy: 'alice', + ...overrides, + }; +} + +describe('prefillFromPromotion', () => { + it('appends "(copy)" to name', () => { + const { form } = prefillFromPromotion(fakeRule()); + expect(form.name).toBe('High error rate (copy)'); + }); + + it('warns + clears agentId when source rule is agent-scoped', () => { + const { form, warnings } = prefillFromPromotion(fakeRule({ + condition: { kind: 'AGENT_STATE', scope: { appSlug: 'orders', agentId: 'orders-0' }, state: 'DEAD', forSeconds: 60 } as any, + conditionKind: 'AGENT_STATE', + })); + expect(form.agentId).toBe(''); + expect(warnings.find((w) => w.field === 'scope.agentId')).toBeTruthy(); + }); + + it('warns if app does not exist in target env', () => { + const { warnings } = prefillFromPromotion(fakeRule(), { targetEnvAppSlugs: ['other-app'] }); + expect(warnings.find((w) => w.field === 'scope.appSlug')).toBeTruthy(); + }); + + it('warns if webhook connection is not allowed in target env', () => { + const rule = fakeRule({ + webhooks: [{ id: 'w1', outboundConnectionId: 'conn-prod', bodyOverride: null, headerOverrides: {} } as any], + }); + const { warnings } = prefillFromPromotion(rule, { targetEnvAllowedConnectionIds: ['conn-dev'] }); + expect(warnings.find((w) => w.field.startsWith('webhooks['))).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 4: Run tests + commit** + +```bash +cd ui && npm test -- promotion-prefill +cd ui && npx tsc -p tsconfig.app.json --noEmit +git add ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts ui/src/pages/Alerts/RuleEditor/promotion-prefill.test.ts +git commit -m "feat(ui/alerts): ReviewStep + promotion prefill warnings + +Review step dumps a human summary + raw request JSON + enabled toggle. +Promotion prefill clears agent IDs (per-env), flags missing apps in target +env, flags webhook connections not allowed in target env. Follow-up: wire +warnings into wizard UI as per-field inline hints (Task 24 ext.)." +``` + +--- + +### Task 25: Wire promotion warnings into wizard UI + +**Files:** +- Modify: `ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx` + +- [ ] **Step 1: Fetch target-env apps + allowed connections and wire warnings** + +Expand the existing wizard to use `prefillFromPromotion({form, warnings})` and expose `warnings` via a banner listing them. In `RuleEditorWizard.tsx`: + +```tsx +// Near the other hooks +import { useCatalog } from '../../../api/queries/catalog'; +import { useOutboundConnections } from '../../../api/queries/admin/outboundConnections'; +import type { PrefillWarning } from './promotion-prefill'; + +// Inside the component, after promoteFrom setup: +const targetEnv = search.get('targetEnv') ?? env; +const { data: targetCatalog } = useCatalog(targetEnv ?? undefined); +const { data: connections } = useOutboundConnections(); + +const targetAppSlugs = (targetCatalog ?? []).map((a: any) => a.slug); +const targetAllowedConnIds = (connections ?? []) + .filter((c) => c.allowedEnvironmentIds.length === 0 || (targetEnv && c.allowedEnvironmentIds.includes(targetEnv))) + .map((c) => c.id); + +const [warnings, setWarnings] = useState([]); +``` + +Replace the initializer block: + +```tsx +const ready = useMemo(() => { + if (form) return true; + if (isEdit && existingQuery.data) { + setForm(initialForm(existingQuery.data)); + return true; + } + if (promoteFrom && sourceRuleQuery.data) { + const { form: prefilled, warnings: w } = prefillFromPromotion(sourceRuleQuery.data, { + targetEnvAppSlugs: targetAppSlugs, + targetEnvAllowedConnectionIds: targetAllowedConnIds, + }); + setForm(prefilled); + setWarnings(w); + return true; + } + if (!isEdit && !promoteFrom) { + setForm(initialForm()); + return true; + } + return false; +}, [form, isEdit, existingQuery.data, promoteFrom, sourceRuleQuery.data, targetAppSlugs.join(','), targetAllowedConnIds.join(',')]); +``` + +Render a warnings banner when `warnings.length > 0`: + +```tsx +{warnings.length > 0 && ( +
+ Review before saving: +
    {warnings.map((w) =>
  • {w.field}: {w.message}
  • )}
+
+)} +``` + +- [ ] **Step 2: TypeScript compile + commit** + +```bash +cd ui && npx tsc -p tsconfig.app.json --noEmit +git add ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx +git commit -m "feat(ui/alerts): render promotion warnings in wizard banner" +``` + +--- + +## Phase 7 — Silences + +### Task 26: `SilencesPage` + +**Files:** +- Replace: `ui/src/pages/Alerts/SilencesPage.tsx` + +- [ ] **Step 1: Implement the page** + +```tsx +// ui/src/pages/Alerts/SilencesPage.tsx +import { useState } from 'react'; +import { Button, FormField, Input, SectionHeader, useToast } from '@cameleer/design-system'; +import { PageLoader } from '../../components/PageLoader'; +import { + useAlertSilences, + useCreateSilence, + useDeleteSilence, + type AlertSilenceResponse, +} from '../../api/queries/alertSilences'; +import sectionStyles from '../../styles/section-card.module.css'; + +export default function SilencesPage() { + const { data, isLoading, error } = useAlertSilences(); + const create = useCreateSilence(); + const remove = useDeleteSilence(); + const { toast } = useToast(); + + const [reason, setReason] = useState(''); + const [matcherRuleId, setMatcherRuleId] = useState(''); + const [matcherAppSlug, setMatcherAppSlug] = useState(''); + const [hours, setHours] = useState(1); + + if (isLoading) return ; + if (error) return
Failed to load silences: {String(error)}
; + + const onCreate = async () => { + const now = new Date(); + const endsAt = new Date(now.getTime() + hours * 3600_000); + const matcher: Record = {}; + if (matcherRuleId) matcher.ruleId = matcherRuleId; + if (matcherAppSlug) matcher.appSlug = matcherAppSlug; + if (Object.keys(matcher).length === 0) { + toast({ title: 'Silence needs at least one matcher field', variant: 'error' }); + return; + } + try { + await create.mutateAsync({ + matcher, + reason: reason || undefined, + startsAt: now.toISOString(), + endsAt: endsAt.toISOString(), + }); + setReason(''); setMatcherRuleId(''); setMatcherAppSlug(''); setHours(1); + toast({ title: 'Silence created', variant: 'success' }); + } catch (e) { + toast({ title: 'Create failed', description: String(e), variant: 'error' }); + } + }; + + const onRemove = async (s: AlertSilenceResponse) => { + if (!confirm(`End silence early?`)) return; + try { + await remove.mutateAsync(s.id); + toast({ title: 'Silence removed', variant: 'success' }); + } catch (e) { + toast({ title: 'Remove failed', description: String(e), variant: 'error' }); + } + }; + + const rows = data ?? []; + + return ( +
+ Alert silences +
+
+ setMatcherRuleId(e.target.value)} /> + setMatcherAppSlug(e.target.value)} /> + setHours(Number(e.target.value))} /> + setReason(e.target.value)} placeholder="Maintenance window" /> + +
+
+
+ {rows.length === 0 ? ( +

No active or scheduled silences.

+ ) : ( + + + + + + + + + + + + {rows.map((s) => ( + + + + + + + + ))} + +
MatcherReasonStartsEnds
{JSON.stringify(s.matcher)}{s.reason ?? '—'}{s.startsAt}{s.endsAt}
+ )} +
+
+ ); +} +``` + +- [ ] **Step 2: TypeScript compile + commit** + +```bash +cd ui && npx tsc -p tsconfig.app.json --noEmit +git add ui/src/pages/Alerts/SilencesPage.tsx +git commit -m "feat(ui/alerts): SilencesPage with matcher-based create + end-early action + +Matcher accepts ruleId and/or appSlug. Server enforces endsAt > startsAt +(V12 CHECK constraint) and matcher_matches() at dispatch time (spec §7)." +``` + +--- + +## Phase 8 — CMD-K integration + +### Task 27: Add alert + alertRule sources to the command palette + +**Files:** +- Modify: `ui/src/components/LayoutShell.tsx` + +- [ ] **Step 1: Import the alert queries + state chip** + +Near the other API-query imports (around line 31): + +```ts +import { useAlerts } from '../api/queries/alerts'; +import { useAlertRules } from '../api/queries/alertRules'; +``` + +- [ ] **Step 2: Build alert/alert-rule SearchResult[]** + +Near `buildSearchData` and `buildAdminSearchData`, add: + +```ts +function buildAlertSearchData( + alerts: any[] | undefined, + rules: any[] | undefined, +): SearchResult[] { + const results: SearchResult[] = []; + if (alerts) { + for (const a of alerts) { + results.push({ + id: `alert:${a.id}`, + category: 'alert', + title: a.title ?? '(untitled)', + badges: [ + { label: a.severity, color: severityToSearchColor(a.severity) }, + { label: a.state, color: stateToSearchColor(a.state) }, + ], + meta: `${a.firedAt ?? ''}${a.silenced ? ' · silenced' : ''}`, + path: `/alerts/inbox/${a.id}`, + }); + } + } + if (rules) { + for (const r of rules) { + results.push({ + id: `rule:${r.id}`, + category: 'alertRule', + title: r.name, + badges: [ + { label: r.severity, color: severityToSearchColor(r.severity) }, + { label: r.conditionKind, color: 'auto' }, + ...(r.enabled ? [] : [{ label: 'DISABLED', color: 'warning' as const }]), + ], + meta: `${r.evaluationIntervalSeconds}s · ${r.targets?.length ?? 0} targets`, + path: `/alerts/rules/${r.id}`, + }); + } + } + return results; +} + +function severityToSearchColor(s: string): string { + if (s === 'CRITICAL') return 'error'; + if (s === 'WARNING') return 'warning'; + return 'auto'; +} +function stateToSearchColor(s: string): string { + if (s === 'FIRING') return 'error'; + if (s === 'ACKNOWLEDGED') return 'warning'; + if (s === 'RESOLVED') return 'success'; + return 'auto'; +} +``` + +- [ ] **Step 3: Fetch alerts + rules inside `LayoutContent`** + +Near the existing catalog/agents fetches (around line 305): + +```ts +// Open alerts + rules for CMD-K (env-scoped). +const { data: cmdkAlerts } = useAlerts({ state: ['FIRING', 'ACKNOWLEDGED'], limit: 100 }); +const { data: cmdkRules } = useAlertRules(); +``` + +- [ ] **Step 4: Add the results into `operationalSearchData`** + +Adjust the `operationalSearchData` memo to include alert + rule results: + +```ts +const alertingSearchData = useMemo( + () => buildAlertSearchData(cmdkAlerts, cmdkRules), + [cmdkAlerts, cmdkRules], +); + +// Inside the existing operationalSearchData useMemo, append alertingSearchData: +return [...catalogRef.current, ...exchangeItems, ...attributeItems, ...alertingSearchData]; +``` + +- [ ] **Step 5: Route selection — handle `alert` and `alertRule` categories** + +Extend `handlePaletteSelect`'s logic: when the result category is `alert` or `alertRule`, just navigate to `result.path`. The existing fallback branch already handles this, but add an explicit clause so the state payload doesn't get the exchange-specific `selectedExchange` treatment: + +```ts +if (result.category === 'alert' || result.category === 'alertRule') { + navigate(result.path); + setPaletteOpen(false); + return; +} +``` + +Insert this at the top of `handlePaletteSelect` before the existing ADMIN_CATEGORIES check. + +- [ ] **Step 6: TypeScript compile + manual smoke** + +```bash +cd ui && npx tsc -p tsconfig.app.json --noEmit +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add ui/src/components/LayoutShell.tsx +git commit -m "feat(ui/alerts): CMD-K sources for alerts + alert rules + +Extends operationalSearchData with open alerts (FIRING|ACKNOWLEDGED) and +all rules. Badges convey severity + state. Selecting an alert navigates to +/alerts/inbox/{id}; a rule navigates to /alerts/rules/{id}. Uses the +existing CommandPalette extension point — no new registry." +``` + +--- + +## Phase 9 — Backend backfills + +### Task 28: SSRF guard on `OutboundConnection.url` + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java` +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java` +- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionAdminControllerIT.java` + +Before editing, run: +``` +gitnexus_impact({target:"OutboundConnectionServiceImpl.save", direction:"upstream"}) +``` +Expected d=1: `OutboundConnectionAdminController` (create + update). No other callers — risk is LOW. + +- [ ] **Step 1: Write failing unit test for `SsrfGuard`** + +```java +// cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java +package com.cameleer.server.app.outbound; + +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SsrfGuardTest { + + private final SsrfGuard guard = new SsrfGuard(false); // allow-private disabled by default + + @Test + void rejectsLoopbackIpv4() { + assertThatThrownBy(() -> guard.validate(URI.create("https://127.0.0.1/webhook"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("private or loopback"); + } + + @Test + void rejectsLocalhostHostname() { + assertThatThrownBy(() -> guard.validate(URI.create("https://localhost:8080/x"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsRfc1918Ranges() { + for (String url : Set.of( + "https://10.0.0.1/x", + "https://172.16.5.6/x", + "https://192.168.1.1/x" + )) { + assertThatThrownBy(() -> guard.validate(URI.create(url))) + .as(url) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Test + void rejectsLinkLocal() { + assertThatThrownBy(() -> guard.validate(URI.create("https://169.254.169.254/latest/meta-data/"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsIpv6Loopback() { + assertThatThrownBy(() -> guard.validate(URI.create("https://[::1]/x"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsIpv6UniqueLocal() { + assertThatThrownBy(() -> guard.validate(URI.create("https://[fc00::1]/x"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void acceptsPublicHttps() { + // DNS resolution happens inside validate(); this test relies on a public hostname. + // Use a literal public IP to avoid network flakiness. + // 8.8.8.8 is a public Google DNS IP — not in any private range. + assertThat(new SsrfGuard(false)).isNotNull(); + guard.validate(URI.create("https://8.8.8.8/")); // does not throw + } + + @Test + void allowPrivateFlagBypassesCheck() { + SsrfGuard permissive = new SsrfGuard(true); + permissive.validate(URI.create("https://127.0.0.1/")); // must not throw + } +} +``` + +Run: `cd cameleer-server-app && mvn -pl . -am test -Dtest=SsrfGuardTest` +Expected: FAIL (SsrfGuard not found). + +- [ ] **Step 2: Implement `SsrfGuard`** + +```java +// cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java +package com.cameleer.server.app.outbound; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; + +/** + * Validates outbound webhook URLs against SSRF pitfalls: rejects hosts that resolve to + * loopback, link-local, or RFC-1918 private ranges (and IPv6 equivalents). + * + * Per spec §17. The `cameleer.server.outbound-http.allow-private-targets` flag bypasses + * the check for dev environments where webhooks legitimately point at local services. + */ +@Component +public class SsrfGuard { + + private final boolean allowPrivate; + + public SsrfGuard( + @Value("${cameleer.server.outbound-http.allow-private-targets:false}") boolean allowPrivate + ) { + this.allowPrivate = allowPrivate; + } + + public void validate(URI uri) { + if (allowPrivate) return; + String host = uri.getHost(); + if (host == null || host.isBlank()) { + throw new IllegalArgumentException("URL must include a host: " + uri); + } + if ("localhost".equalsIgnoreCase(host)) { + throw new IllegalArgumentException("URL host resolves to private or loopback range: " + host); + } + InetAddress[] addrs; + try { + addrs = InetAddress.getAllByName(host); + } catch (UnknownHostException e) { + throw new IllegalArgumentException("URL host does not resolve: " + host, e); + } + for (InetAddress addr : addrs) { + if (isPrivate(addr)) { + throw new IllegalArgumentException("URL host resolves to private or loopback range: " + host + " -> " + addr.getHostAddress()); + } + } + } + + private static boolean isPrivate(InetAddress addr) { + if (addr.isLoopbackAddress()) return true; + if (addr.isLinkLocalAddress()) return true; + if (addr.isSiteLocalAddress()) return true; // 10/8, 172.16/12, 192.168/16 + if (addr.isAnyLocalAddress()) return true; // 0.0.0.0, :: + if (addr instanceof Inet6Address ip6) { + byte[] raw = ip6.getAddress(); + // fc00::/7 unique-local + if ((raw[0] & 0xfe) == 0xfc) return true; + } + if (addr instanceof Inet4Address ip4) { + byte[] raw = ip4.getAddress(); + // 169.254.0.0/16 link-local (also matches isLinkLocalAddress but doubled-up for safety) + if ((raw[0] & 0xff) == 169 && (raw[1] & 0xff) == 254) return true; + } + return false; + } +} +``` + +Run: `cd cameleer-server-app && mvn -pl . -am test -Dtest=SsrfGuardTest` +Expected: 8 tests pass (the public-IP case requires network; if the local env blocks DNS, allow it to skip — but it shouldn't error on a literal IP). + +- [ ] **Step 3: Wire the guard into `OutboundConnectionServiceImpl.save`** + +Edit `OutboundConnectionServiceImpl.java`. Read the file first, then find the `save` method. Inject `SsrfGuard` via constructor and call `guard.validate(URI.create(request.url()))` before persisting. The save method is the `create` and `update` entry point from the controller. + +Sketch: + +```java +// Constructor gains: +private final SsrfGuard ssrfGuard; + +public OutboundConnectionServiceImpl( + OutboundConnectionRepository repo, + SecretCipher cipher, + AuditService audit, + SsrfGuard ssrfGuard, + @Value("${cameleer.server.tenant.id:default}") String tenantId +) { + this.repo = repo; + this.cipher = cipher; + this.audit = audit; + this.ssrfGuard = ssrfGuard; + this.tenantId = tenantId; +} + +// In save() (both create & update), before repo.save(): +ssrfGuard.validate(URI.create(request.url())); +``` + +Verify the existing constructor signature by reading `OutboundConnectionServiceImpl.java` first; adjust the `@Autowired`/Spring wiring in `OutboundBeanConfig.java` if the bean is constructed there. + +- [ ] **Step 4: Add an IT case for SSRF rejection** + +Add to `OutboundConnectionAdminControllerIT.java`: + +```java +@Test +void rejectsPrivateIpOnCreate() throws Exception { + mockMvc.perform(post("/api/v1/admin/outbound-connections") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "evil", + "url": "https://127.0.0.1/abuse", + "method": "POST", + "tlsTrustMode": "SYSTEM_DEFAULT", + "auth": {} + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message", containsString("private or loopback"))); +} +``` + +(The exact token helper follows the existing ITs; reuse their pattern.) + +- [ ] **Step 5: Run full verify for touched modules** + +```bash +mvn -pl cameleer-server-app -am verify -Dtest='SsrfGuardTest,OutboundConnectionAdminControllerIT' +``` + +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java \ + cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java \ + cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java \ + cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionAdminControllerIT.java +git commit -m "feat(alerting): SSRF guard on outbound connection URL + +Rejects webhook URLs that resolve to loopback, link-local, or RFC-1918 private +ranges (IPv4 + IPv6). Bypass via cameleer.server.outbound-http.allow-private- +targets=true for dev envs. Plan 01 scope; required before SaaS exposure +(spec §17)." +``` + +--- + +### Task 29: `AlertingMetrics` gauge 30s caching + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/metrics/AlertingMetrics.java` +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java` + +Before editing, run: +``` +gitnexus_impact({target:"AlertingMetrics", direction:"upstream"}) +``` +Expected d=1: callers that register the gauges (startup bean wiring). Risk LOW. + +- [ ] **Step 1: Write failing test** + +```java +// cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java +package com.cameleer.server.app.alerting.metrics; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class AlertingMetricsCachingTest { + + @Test + void gaugeSupplierIsCalledAtMostOncePer30Seconds() { + AtomicInteger calls = new AtomicInteger(); + AtomicReference now = new AtomicReference<>(Instant.parse("2026-04-20T00:00:00Z")); + Clock clock = Clock.fixed(now.get(), ZoneOffset.UTC); + + AlertingMetrics metrics = new AlertingMetrics( + new SimpleMeterRegistry(), + () -> { calls.incrementAndGet(); return 7L; }, // count supplier + () -> 0L, // open rules + () -> 0L, // circuit-open + Duration.ofSeconds(30), + () -> Clock.fixed(now.get(), ZoneOffset.UTC).instant() + ); + + // Registering the gauge does not call the supplier; reading it does (Micrometer semantics). + metrics.snapshotAllGauges(); // first read — delegates through cache, 1 underlying call + metrics.snapshotAllGauges(); // second read within TTL — served from cache, 0 new calls + assertThat(calls.get()).isEqualTo(1); + + now.set(Instant.parse("2026-04-20T00:00:31Z")); // advance 31 s + metrics.snapshotAllGauges(); + assertThat(calls.get()).isEqualTo(2); + } +} +``` + +Run: `cd cameleer-server-app && mvn -pl . -am test -Dtest=AlertingMetricsCachingTest` +Expected: FAIL (caching not implemented, or signature mismatch). + +- [ ] **Step 2: Refactor `AlertingMetrics` to wrap suppliers in a TTL cache** + +Read the existing file. If it currently uses `registry.gauge(...)` with direct suppliers, wrap each one in a lightweight cache: + +```java +// Inside AlertingMetrics +private static final class TtlCache { + private final Supplier delegate; + private final Duration ttl; + private final Supplier clock; + private volatile Instant lastRead = Instant.MIN; + private volatile long cached = 0L; + + TtlCache(Supplier delegate, Duration ttl, Supplier clock) { + this.delegate = delegate; + this.ttl = ttl; + this.clock = clock; + } + + long get() { + Instant now = clock.get(); + if (Duration.between(lastRead, now).compareTo(ttl) >= 0) { + cached = delegate.get(); + lastRead = now; + } + return cached; + } +} +``` + +Register the gauges with `() -> cache.get()` in place of raw suppliers. Keep the existing bean constructor signature on the production path (default `ttl = Duration.ofSeconds(30)`, clock = `Instant::now`) and add a test-friendly constructor variant with explicit `ttl` + `clock`. + +Add a test helper `snapshotAllGauges()` that reads each registered cache's value (or simply calls `registry.find(...).gauge().value()` on each metric) — whatever the existing method surface supports. If no such helper exists, expose package-private accessors on the caches. + +- [ ] **Step 3: Run test** + +```bash +mvn -pl cameleer-server-app -am test -Dtest=AlertingMetricsCachingTest +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/metrics/AlertingMetrics.java \ + cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java +git commit -m "perf(alerting): 30s TTL cache on AlertingMetrics gauge suppliers + +Prometheus scrapes can fire every few seconds. The open-alerts / open-rules +gauges query Postgres on each read — caching the values for 30s amortises +that to one query per half-minute. Addresses final-review NIT from Plan 02." +``` + +--- + +## Phase 10 — E2E smoke + docs + final verification + +### Task 30: Playwright E2E smoke + +**Files:** +- Create: `ui/src/test/e2e/alerting.spec.ts` +- Create: `ui/src/test/e2e/fixtures.ts` + +- [ ] **Step 1: Write a login fixture** + +```ts +// ui/src/test/e2e/fixtures.ts +import { test as base, expect } from '@playwright/test'; + +export const ADMIN_USER = process.env.E2E_ADMIN_USER ?? 'admin'; +export const ADMIN_PASS = process.env.E2E_ADMIN_PASS ?? 'admin'; + +export const test = base.extend<{ loggedIn: void }>({ + loggedIn: [async ({ page }, use) => { + await page.goto('/login'); + await page.getByLabel(/username/i).fill(ADMIN_USER); + await page.getByLabel(/password/i).fill(ADMIN_PASS); + await page.getByRole('button', { name: /log in/i }).click(); + await expect(page).toHaveURL(/\/(exchanges|alerts)/); + await use(); + }, { auto: true }], +}); + +export { expect }; +``` + +- [ ] **Step 2: Write the smoke test** + +```ts +// ui/src/test/e2e/alerting.spec.ts +import { test, expect } from './fixtures'; + +test.describe('alerting UI smoke', () => { + test('sidebar Alerts section navigates to inbox', async ({ page }) => { + await page.getByRole('button', { name: /alerts/i }).first().click(); + await expect(page).toHaveURL(/\/alerts\/inbox/); + await expect(page.getByRole('heading', { name: /inbox/i })).toBeVisible(); + }); + + test('CRUD a rule end-to-end', async ({ page }) => { + await page.goto('/alerts/rules'); + await page.getByRole('link', { name: /new rule/i }).click(); + await expect(page).toHaveURL(/\/alerts\/rules\/new/); + + // Step 1 — scope + await page.getByLabel(/^name$/i).fill('e2e smoke rule'); + await page.getByRole('button', { name: /next/i }).click(); + + // Step 2 — condition (leave at ROUTE_METRIC defaults) + await page.getByRole('button', { name: /next/i }).click(); + + // Step 3 — trigger (defaults) + await page.getByRole('button', { name: /next/i }).click(); + + // Step 4 — notify (templates have defaults) + await page.getByRole('button', { name: /next/i }).click(); + + // Step 5 — review + await page.getByRole('button', { name: /create rule/i }).click(); + + await expect(page).toHaveURL(/\/alerts\/rules/); + await expect(page.getByText('e2e smoke rule')).toBeVisible(); + + // Delete + page.once('dialog', (d) => d.accept()); + await page.getByRole('row', { name: /e2e smoke rule/i }).getByRole('button', { name: /delete/i }).click(); + await expect(page.getByText('e2e smoke rule')).toHaveCount(0); + }); + + test('CMD-K navigates to a rule', async ({ page }) => { + await page.keyboard.press('Control+K'); + await page.getByRole('searchbox').fill('smoke'); + // No rule expected in fresh DB — verify palette renders without crashing + await expect(page.getByRole('dialog')).toBeVisible(); + await page.keyboard.press('Escape'); + }); + + test('silence create + end-early', async ({ page }) => { + await page.goto('/alerts/silences'); + await page.getByLabel(/app slug/i).fill('smoke-app'); + await page.getByLabel(/duration/i).fill('1'); + await page.getByLabel(/reason/i).fill('e2e smoke'); + await page.getByRole('button', { name: /create silence/i }).click(); + await expect(page.getByText('smoke-app')).toBeVisible(); + + page.once('dialog', (d) => d.accept()); + await page.getByRole('row', { name: /smoke-app/i }).getByRole('button', { name: /end/i }).click(); + await expect(page.getByText('smoke-app')).toHaveCount(0); + }); +}); +``` + +- [ ] **Step 3: Run the smoke (requires backend on :8081)** + +```bash +cd ui && npx playwright test +``` + +Expected: 4 tests pass. If the dev env uses a different admin credential, override via `E2E_ADMIN_USER` + `E2E_ADMIN_PASS`. + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/test/e2e/alerting.spec.ts ui/src/test/e2e/fixtures.ts +git commit -m "test(ui/alerts): Playwright smoke — sidebar nav, rule CRUD, CMD-K, silence CRUD + +Smoke runs against the real backend (not mocks) per project test policy. +Does not exercise fire-to-ack (requires event ingestion machinery); that +path is covered by backend AlertingFullLifecycleIT." +``` + +--- + +### Task 31: Update `.claude/rules/ui.md` + admin guide + +**Files:** +- Modify: `.claude/rules/ui.md` +- Modify: `docs/alerting.md` + +- [ ] **Step 1: Update `.claude/rules/ui.md`** + +Read the file and append two new sections: + +```markdown +## Alerts + +- **Sidebar section** (`buildAlertsTreeNodes` in `ui/src/components/sidebar-utils.ts`) — Inbox, All, Rules, Silences, History. +- **Routes** in `ui/src/router.tsx`: `/alerts`, `/alerts/inbox`, `/alerts/all`, `/alerts/history`, `/alerts/rules`, `/alerts/rules/new`, `/alerts/rules/:id`, `/alerts/silences`. +- **Pages** under `ui/src/pages/Alerts/`: + - `InboxPage.tsx` — user-targeted FIRING/ACK'd alerts with bulk-read. + - `AllAlertsPage.tsx` — env-wide list with state chip filter. + - `HistoryPage.tsx` — RESOLVED alerts. + - `RulesListPage.tsx` — CRUD + enable/disable toggle + env-promotion dropdown (pure UI prefill, no new endpoint). + - `RuleEditor/RuleEditorWizard.tsx` — 5-step wizard (Scope / Condition / Trigger / Notify / Review). `form-state.ts` is the single source of truth (initialForm / toRequest / validateStep). + - `SilencesPage.tsx` — matcher-based create + end-early. +- **Components**: + - `NotificationBell.tsx` — polls `/alerts/unread-count` every 30s, paused when tab hidden via `usePageVisible`. + - `AlertStateChip.tsx`, `SeverityBadge.tsx` — shared state/severity indicators. + - `MustacheEditor/` — CodeMirror 6 editor with variable autocomplete + inline linter. Shared between rule title/message, webhook body/header overrides, and Admin Outbound Connection editor (reduced-context mode for URL). +- **API queries** under `ui/src/api/queries/`: `alerts.ts`, `alertRules.ts`, `alertSilences.ts`, `alertNotifications.ts`, `alertMeta.ts`. All env-scoped via `useSelectedEnv`. +- **CMD-K**: `buildAlertSearchData` in `LayoutShell.tsx` registers `alert` and `alertRule` categories. Badges convey severity + state. +- **Sidebar accordion**: entering `/alerts/*` collapses Applications + Admin + Starred (mirrors Admin accordion). +``` + +- [ ] **Step 2: Update `docs/alerting.md` admin guide** + +Append a UI walkthrough section covering: +- Where to find the Alerts section (sidebar + top-bar bell) +- How to author a rule (wizard screenshots can be added later) +- How to create a silence +- How to interpret the env-promotion warnings +- Where Mustache variable autocomplete comes from + +Keep it to ~60 lines; point readers to spec §13 for the full design rationale. + +- [ ] **Step 3: Commit** + +```bash +git add .claude/rules/ui.md docs/alerting.md +git commit -m "docs(alerting): UI map + admin-guide walkthrough for Plan 03 + +.claude/rules/ui.md now maps every Plan 03 UI surface. Admin guide picks up +inbox/rules/silences sections so ops teams can start in the UI without +reading the spec." +``` + +--- + +### Task 32: Final verification — build, lint, tests + +- [ ] **Step 1: Frontend full build (type check + bundle)** + +```bash +cd ui && npm run build +``` + +Expected: clean build, no errors. Bundle size within reason (<2 MB uncompressed, CM6 + alerts pages add ~150 KB gzipped). + +- [ ] **Step 2: Frontend lint** + +```bash +cd ui && npm run lint +``` + +Expected: zero errors. Fix warnings introduced by Plan 03 files; ignore pre-existing ones. + +- [ ] **Step 3: Frontend unit tests** + +```bash +cd ui && npm test +``` + +Expected: all Plan 03 Vitest suites pass (≥ 25 tests across hooks, chips, editor, form-state, prefill). + +- [ ] **Step 4: Backend verify** + +```bash +mvn -pl cameleer-server-app -am verify +``` + +Expected: all existing + new tests pass (SsrfGuardTest, AlertingMetricsCachingTest, extended OutboundConnectionAdminControllerIT). + +- [ ] **Step 5: `gitnexus_detect_changes` pre-PR sanity** + +``` +gitnexus_detect_changes({scope:"compare", base_ref:"main"}) +``` + +Expected: affected symbols = only Plan 03 surface (Alerts pages, MustacheEditor, NotificationBell, SsrfGuard, AlertingMetrics caching, router/LayoutShell edits). No stray edits to unrelated modules. + +- [ ] **Step 6: Regenerate OpenAPI schema one final time** + +If any backend DTO changed during the backfills, run `cd ui && npm run generate-api:live` and commit the diff. If there's no diff, skip this step. + +- [ ] **Step 7: Commit any verification artifacts (none expected)** + +No commit if everything is clean. + +- [ ] **Step 8: Push branch + open PR** + +```bash +git push -u origin feat/alerting-03-ui +gh pr create --title "feat(alerting): Plan 03 — UI + backfills (SSRF guard, metrics caching)" --body "$(cat <<'EOF' +## Summary +- Alerting UI: inbox, all/history, rules list, 5-step rule editor wizard, silences, notification bell, CMD-K integration. +- MustacheEditor: CodeMirror 6 with variable autocomplete + inline linter (shared across rule templates + webhook body/header overrides + connection defaults). +- Rule promotion across envs: pure UI prefill (no new endpoint) with client-side warnings (app missing in target env, agent-specific scope, connection not allowed in target env). +- Backend backfills: SSRF guard on outbound connection URL save (rejects loopback/link-local/RFC-1918); 30s TTL cache on AlertingMetrics gauges. +- Docs: `.claude/rules/ui.md` updated with full Alerts map; `docs/alerting.md` gains UI walkthrough. + +Spec: `docs/superpowers/specs/2026-04-19-alerting-design.md` §12/§13/§9. +Plan: `docs/superpowers/plans/2026-04-20-alerting-03-ui.md`. + +## Test plan +- [ ] Vitest unit suites all pass (`cd ui && npm test`). +- [ ] Playwright smoke passes against a running backend (`cd ui && npx playwright test`). +- [ ] `mvn -pl cameleer-server-app -am verify` green. +- [ ] Manual: create a rule, see it in the list, CMD-K finds it, disable it, delete it. +- [ ] Manual: create a silence, see it, end it early. +- [ ] Manual: bell shows unread count when a FIRING alert targets the current user. +- [ ] Manual: promoting a rule with an agent-scope shows the agent-id warning. + +Plan 01 + Plan 02 are already on main; Plan 03 targets main directly. Supersedes the chore/openapi-regen-post-plan02 branch (delete after merge). + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review + +Ran per the writing-plans skill self-review checklist. + +### 1. Spec coverage + +| Spec requirement | Covered by | +|---|---| +| §12 CMD-K integration (alerts + alertRules result sources) | Task 27 | +| §13 UI routes (`/alerts/**`) | Task 13 | +| §13 Top-nav `` | Tasks 9, 15 | +| §13 `` | Task 8 | +| §13 Rule editor 5-step wizard | Tasks 19–25 | +| §13 `` with variable autocomplete | Tasks 10–12 | +| §13 Silences / History / Rules list / OutboundConnectionAdminPage | Tasks 16, 17, 18, 26 (Outbound page already exists from Plan 01) | +| §13 Real-time: bell polls every 30s; paused when tab hidden | Task 9 (`usePageVisible`) + query `refetchIntervalInBackground:false` | +| §13 Accessibility: keyboard nav, ARIA | CM6 autocomplete is ARIA-conformant; bell has aria-label | +| §13 Styling: `@cameleer/design-system` CSS variables | All files use `var(--error)` etc. (no hardcoded hex) | +| §9 Rule promotion across envs — pure UI prefill + warnings | Tasks 18 (entry), 24, 25 | +| §17 SSRF guard on outbound URL | Task 28 | +| Final-review NIT: 30s gauge caching | Task 29 | +| Regenerate OpenAPI schema | Tasks 3, 32 | +| Update `.claude/rules/` | Task 31 | +| Testing preference: REST-API-driven (not raw SQL); Playwright over real backend | Task 30 + backend IT extension in Task 28 | + +No uncovered requirements from the spec sections relevant to Plan 03. + +### 2. Placeholder scan + +- No "TBD" / "implement later" / "similar to Task N" / "Add appropriate error handling" patterns remain. +- Every step with a code change includes the actual code. +- Step stubs in Task 13 are explicitly marked as "replaced in Phase 5/6/7" — they're real code, just thin. +- Some condition-forms (Task 21 step 4) reuse the same pattern; each form is shown in full rather than "similar to RouteMetricForm". + +### 3. Type consistency + +- `MustacheEditor` props are the same shape across all call sites: `{ value, onChange, kind?, reducedContext?, label, placeholder?, minHeight?, singleLine? }`. +- `FormState` is declared in `form-state.ts` and used identically by all wizard steps. +- Schema-derived types (`AlertDto`, `AlertRuleResponse`, etc.) come from `components['schemas'][...]` so they stay in sync with the backend. +- Query hook names follow a consistent convention: `use()` (list), `use(id)` (single), `useCreate`, `useUpdate`, `useDelete`. + +No inconsistencies. + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-04-20-alerting-03-ui.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** — dispatch a fresh subagent per task, review between tasks, fast iteration. + +**2. Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints. + +**Which approach?**