# 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) => ( ))}
Name Kind Severity Enabled Targets
{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) => ( ))}
Matcher Reason Starts Ends
{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?**