Files
cameleer-server/docs/superpowers/plans/2026-04-20-alerting-03-ui.md

5032 lines
178 KiB
Markdown
Raw Normal View History

# Alerting — Plan 03: UI + Backfills Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Deliver the full alerting UI (inbox, rule editor wizard with Mustache autocomplete, silences, history, CMD-K, notification bell) against the backend on main, plus two small backend backfills (webhook SSRF guard, metrics gauge caching).
**Architecture:** All routes under `/alerts/**`, registered in `router.tsx` as a new top-level section (parallel to Exchanges/Dashboard/Runtime/Apps). State via TanStack Query hooks (mirrors `outboundConnections.ts` pattern). A shared `<MustacheEditor />` component built on CodeMirror 6 with a custom completion source drives every template-editable field (rule title, rule message, webhook body/header overrides, connection default body). CMD-K extension piggybacks on `LayoutShell`'s existing `searchData` builder — no separate registry. Notification bell lives in `TopBar` children, polls `/alerts/unread-count` on a 30s cadence that pauses when the Page Visibility API reports the tab hidden. Backend backfills: SSRF guard at rule-save (§17) and 30s caching on `AlertingMetrics` gauges (final-review NIT).
**Tech Stack:**
- React 19 + React Router v7 + TanStack Query v5 + Zustand v5 (existing)
- `@cameleer/design-system` v0.1.56 (existing)
- `openapi-fetch` + regenerated `openapi-typescript` schema (existing)
- CodeMirror 6 (`@codemirror/view`, `@codemirror/state`, `@codemirror/autocomplete`, `@codemirror/commands`) — new
- Vitest + Testing Library — new (for unit tests of pure-logic components)
- Playwright — already in devDependencies; config added in Task 2
**Base branch:** `feat/alerting-03-ui` off `main` (worktree: `.worktrees/alerting-03`). Commit atomically per task. After the branch is merged, the superseded `chore/openapi-regen-post-plan02` branch can be deleted.
**Engine choice for `<MustacheEditor />` (§20.7 of spec).** CodeMirror 6 is picked for three reasons: (1) bundle cost ~90 KB gzipped — ~20× lighter than Monaco; (2) `@codemirror/autocomplete` already ships ARIA-conformant combobox semantics, which the spec's accessibility requirement (§13) is non-trivial to rebuild on a plain textarea overlay; (3) it composes cleanly with a tiny custom completion extension that reads the same `ALERT_VARIABLES` metadata registry the UI uses elsewhere. Monaco and textarea-overlay are rejected but recorded in this plan so reviewers see the decision.
**CRITICAL process rules (per project CLAUDE.md):**
- Run `gitnexus_impact({target, direction:"upstream"})` before editing any existing Java class.
- Run `gitnexus_detect_changes()` before every commit.
- After any Java controller or DTO change, regenerate the OpenAPI schema via `cd ui && npm run generate-api:live`.
- Update `.claude/rules/ui.md` as part of the UI task that changes the map, not as a trailing cleanup.
---
## File Structure
### Frontend — new files
```
ui/src/
├── pages/Alerts/
│ ├── InboxPage.tsx — /alerts/inbox (default landing)
│ ├── AllAlertsPage.tsx — /alerts/all
│ ├── HistoryPage.tsx — /alerts/history (RESOLVED)
│ ├── RulesListPage.tsx — /alerts/rules
│ ├── SilencesPage.tsx — /alerts/silences
│ ├── RuleEditor/
│ │ ├── RuleEditorWizard.tsx — /alerts/rules/new | /alerts/rules/{id}
│ │ ├── ScopeStep.tsx — step 1
│ │ ├── ConditionStep.tsx — step 2
│ │ ├── TriggerStep.tsx — step 3
│ │ ├── NotifyStep.tsx — step 4
│ │ ├── ReviewStep.tsx — step 5
│ │ ├── form-state.ts — FormState type + initialForm + toRequest
│ │ └── condition-forms/
│ │ ├── RouteMetricForm.tsx
│ │ ├── ExchangeMatchForm.tsx
│ │ ├── AgentStateForm.tsx
│ │ ├── DeploymentStateForm.tsx
│ │ ├── LogPatternForm.tsx
│ │ └── JvmMetricForm.tsx
│ └── promotion-prefill.ts — client-side promotion prefill + warnings
├── components/
│ ├── NotificationBell.tsx
│ ├── AlertStateChip.tsx
│ ├── SeverityBadge.tsx
│ ├── MustacheEditor/
│ │ ├── MustacheEditor.tsx — shell (CM6 EditorView)
│ │ ├── alert-variables.ts — ALERT_VARIABLES registry
│ │ ├── mustache-completion.ts — CM6 CompletionSource
│ │ └── mustache-linter.ts — CM6 linter (unclosed braces, unknown vars)
│ └── alerts-sidebar-utils.ts — buildAlertsTreeNodes
├── api/queries/
│ ├── alerts.ts
│ ├── alertRules.ts
│ ├── alertSilences.ts
│ ├── alertNotifications.ts
│ └── alertMeta.ts — shared env-scoped fetch helper
└── test/
├── setup.ts — Vitest / Testing Library setup
└── e2e/
└── alerting.spec.ts — Playwright smoke
```
### Frontend — existing files modified
```
ui/src/
├── router.tsx — register /alerts/* + /alerts/rules/new|{id}
├── components/LayoutShell.tsx — Alerts sidebar section, NotificationBell in TopBar children, buildAlertsSearchData
├── components/sidebar-utils.ts — export buildAlertsTreeNodes
ui/package.json — add CM6, Vitest, @testing-library/*
ui/vitest.config.ts — new (Vitest config)
ui/playwright.config.ts — new (Playwright config)
ui/tsconfig.app.json — include test setup
```
### Backend — files modified
```
cameleer-server-app/
├── src/main/java/com/cameleer/server/app/
│ ├── outbound/OutboundConnectionServiceImpl.java — SSRF guard in save()
│ ├── outbound/SsrfGuard.java — new utility (resolves URL host, rejects private ranges)
│ └── alerting/metrics/AlertingMetrics.java — 30s caching on gauge suppliers
└── src/test/java/com/cameleer/server/app/
├── outbound/SsrfGuardTest.java — unit
├── outbound/OutboundConnectionAdminControllerIT.java — add SSRF rejection case
└── alerting/metrics/AlertingMetricsCachingTest.java — unit
```
### Docs + rules
```
.claude/rules/ui.md — add Alerts section, new components, CMD-K sources
docs/alerting.md — add UI walkthrough sections (admin guide)
docs/superpowers/plans/2026-04-20-alerting-03-ui.md — this plan
```
### Files NOT created (intentional)
- `ui/src/cmdk/sources/` — spec §12 proposed this, but the codebase uses DS `CommandPalette` fed by a single `searchData` builder in `LayoutShell`; registering alerts there is lower surface area and matches existing conventions.
- `ui/src/api/queries/admin/` entries for alerts — alerts are env-scoped, not admin, so they live at `ui/src/api/queries/` (not `admin/`).
---
## Phase 1 — Foundation (infra setup)
### Task 1: Install Vitest + Testing Library and add config
**Files:**
- Modify: `ui/package.json`
- Create: `ui/vitest.config.ts`
- Create: `ui/src/test/setup.ts`
- Modify: `ui/tsconfig.app.json`
- [ ] **Step 1: Install dev dependencies**
From `ui/`:
```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<T>(v: T | T[] | undefined): T[] | undefined {
if (v === undefined) return undefined;
return Array.isArray(v) ? v : [v];
}
export function useAlerts(filter: AlertsFilter = {}) {
const env = useSelectedEnv();
return useQuery({
queryKey: ['alerts', env, filter],
enabled: !!env,
refetchInterval: 30_000,
refetchIntervalInBackground: false,
queryFn: async () => {
if (!env) throw new Error('no env');
const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts', {
params: {
path: { envSlug: env },
query: {
state: toArray(filter.state),
severity: toArray(filter.severity),
ruleId: filter.ruleId,
limit: filter.limit ?? 100,
},
},
});
if (error) throw error;
return data as AlertDto[];
},
});
}
export function useAlert(id: string | undefined) {
const env = useSelectedEnv();
return useQuery({
queryKey: ['alerts', env, id],
enabled: !!env && !!id,
queryFn: async () => {
const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/{id}', {
params: { path: { envSlug: env!, id: id! } },
});
if (error) throw error;
return data as AlertDto;
},
});
}
export function useUnreadCount() {
const env = useSelectedEnv();
return useQuery({
queryKey: ['alerts', env, 'unread-count'],
enabled: !!env,
refetchInterval: 30_000,
refetchIntervalInBackground: false,
queryFn: async () => {
const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/unread-count', {
params: { path: { envSlug: env! } },
});
if (error) throw error;
return data as UnreadCountResponse;
},
});
}
export function useAckAlert() {
const qc = useQueryClient();
const env = useSelectedEnv();
return useMutation({
mutationFn: async (id: string) => {
const { error } = await apiClient.POST('/environments/{envSlug}/alerts/{id}/ack', {
params: { path: { envSlug: env!, id } },
});
if (error) throw error;
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['alerts', env] }),
});
}
export function useMarkAlertRead() {
const qc = useQueryClient();
const env = useSelectedEnv();
return useMutation({
mutationFn: async (id: string) => {
const { error } = await apiClient.POST('/environments/{envSlug}/alerts/{id}/read', {
params: { path: { envSlug: env!, id } },
});
if (error) throw error;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['alerts', env] });
qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] });
},
});
}
export function useBulkReadAlerts() {
const qc = useQueryClient();
const env = useSelectedEnv();
return useMutation({
mutationFn: async (ids: string[]) => {
const { error } = await apiClient.POST('/environments/{envSlug}/alerts/bulk-read', {
params: { path: { envSlug: env! } },
body: { alertInstanceIds: ids },
});
if (error) throw error;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['alerts', env] });
qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] });
},
});
}
```
Note: if openapi-fetch's client is exported under a different name in `ui/src/api/client.ts`, adjust the `apiClient` reference in `alertMeta.ts` (Task 4). Verify the exact call site shape by reading `ui/src/api/client.ts` first — path-parameter types may differ from what's shown here. If the client uses `fetch` helpers with a different signature, adapt the hooks to match `outboundConnections.ts`'s `adminFetch` style instead.
- [ ] **Step 2: Write hook tests**
```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 <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
describe('useAlerts', () => {
beforeEach(() => {
vi.clearAllMocks();
useEnvironmentStore.setState({ environment: 'dev' });
});
it('fetches alerts for selected env and passes filter query params', async () => {
(apiClient.GET as any).mockResolvedValue({ data: [], error: null });
const { result } = renderHook(
() => useAlerts({ state: 'FIRING', severity: ['CRITICAL', 'WARNING'] }),
{ wrapper },
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.GET).toHaveBeenCalledWith(
'/environments/{envSlug}/alerts',
expect.objectContaining({
params: expect.objectContaining({
path: { envSlug: 'dev' },
query: expect.objectContaining({
state: ['FIRING'],
severity: ['CRITICAL', 'WARNING'],
limit: 100,
}),
}),
}),
);
});
it('does not fetch when no env is selected', () => {
useEnvironmentStore.setState({ environment: undefined });
const { result } = renderHook(() => useAlerts(), { wrapper });
expect(result.current.fetchStatus).toBe('idle');
expect(apiClient.GET).not.toHaveBeenCalled();
});
});
describe('useUnreadCount', () => {
beforeEach(() => {
vi.clearAllMocks();
useEnvironmentStore.setState({ environment: 'dev' });
});
it('returns the server payload unmodified', async () => {
(apiClient.GET as any).mockResolvedValue({
data: { total: 3, bySeverity: { CRITICAL: 1, WARNING: 2, INFO: 0 } },
error: null,
});
const { result } = renderHook(() => useUnreadCount(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({
total: 3,
bySeverity: { CRITICAL: 1, WARNING: 2, INFO: 0 },
});
});
});
```
- [ ] **Step 3: Run tests**
```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 <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
describe('useAlertRules', () => {
beforeEach(() => {
vi.clearAllMocks();
useEnvironmentStore.setState({ environment: 'prod' });
});
it('fetches rules for selected env', async () => {
(apiClient.GET as any).mockResolvedValue({ data: [], error: null });
const { result } = renderHook(() => useAlertRules(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.GET).toHaveBeenCalledWith(
'/environments/{envSlug}/alerts/rules',
{ params: { path: { envSlug: 'prod' } } },
);
});
});
describe('useSetAlertRuleEnabled', () => {
beforeEach(() => {
vi.clearAllMocks();
useEnvironmentStore.setState({ environment: 'prod' });
});
it('POSTs to /enable when enabling', async () => {
(apiClient.POST as any).mockResolvedValue({ error: null });
const { result } = renderHook(() => useSetAlertRuleEnabled(), { wrapper });
await result.current.mutateAsync({ id: 'r1', enabled: true });
expect(apiClient.POST).toHaveBeenCalledWith(
'/environments/{envSlug}/alerts/rules/{id}/enable',
{ params: { path: { envSlug: 'prod', id: 'r1' } } },
);
});
it('POSTs to /disable when disabling', async () => {
(apiClient.POST as any).mockResolvedValue({ error: null });
const { result } = renderHook(() => useSetAlertRuleEnabled(), { wrapper });
await result.current.mutateAsync({ id: 'r1', enabled: false });
expect(apiClient.POST).toHaveBeenCalledWith(
'/environments/{envSlug}/alerts/rules/{id}/disable',
{ params: { path: { envSlug: 'prod', id: 'r1' } } },
);
});
});
```
- [ ] **Step 3: Run tests**
```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 <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
describe('useAlertSilences', () => {
beforeEach(() => {
vi.clearAllMocks();
useEnvironmentStore.setState({ environment: 'dev' });
});
it('fetches silences for selected env', async () => {
(apiClient.GET as any).mockResolvedValue({ data: [], error: null });
const { result } = renderHook(() => useAlertSilences(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.GET).toHaveBeenCalledWith(
'/environments/{envSlug}/alerts/silences',
{ params: { path: { envSlug: 'dev' } } },
);
});
});
```
- [ ] **Step 4: Run tests**
```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(<AlertStateChip state={state} />);
expect(screen.getByText(pattern)).toBeInTheDocument();
});
it('shows silenced suffix when silenced=true', () => {
render(<AlertStateChip state="FIRING" silenced />);
expect(screen.getByText(/silenced/i)).toBeInTheDocument();
});
});
```
Run: `cd ui && npm test -- AlertStateChip`
Expected: FAIL (module not found).
- [ ] **Step 2: Implement `AlertStateChip`**
```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<State, string> = {
PENDING: 'Pending',
FIRING: 'Firing',
ACKNOWLEDGED: 'Acknowledged',
RESOLVED: 'Resolved',
};
const COLORS: Record<State, 'auto' | 'success' | 'warning' | 'error'> = {
PENDING: 'warning',
FIRING: 'error',
ACKNOWLEDGED: 'warning',
RESOLVED: 'success',
};
export function AlertStateChip({ state, silenced }: { state: State; silenced?: boolean }) {
return (
<span style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
<Badge label={LABELS[state]} color={COLORS[state]} variant="filled" />
{silenced && <Badge label="Silenced" color="auto" variant="outlined" />}
</span>
);
}
```
Run: `cd ui && npm test -- AlertStateChip`
Expected: 5 tests pass.
- [ ] **Step 3: Write failing test for `SeverityBadge`**
```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(<SeverityBadge severity={severity} />);
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<Severity, string> = {
CRITICAL: 'Critical',
WARNING: 'Warning',
INFO: 'Info',
};
const COLORS: Record<Severity, 'auto' | 'warning' | 'error'> = {
CRITICAL: 'error',
WARNING: 'warning',
INFO: 'auto',
};
export function SeverityBadge({ severity }: { severity: Severity }) {
return <Badge label={LABELS[severity]} color={COLORS[severity]} variant="filled" />;
}
```
Run: `cd ui && npm test -- SeverityBadge`
Expected: 3 tests pass.
- [ ] **Step 5: Commit**
```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 (
<QueryClientProvider client={qc}>
<MemoryRouter>{children}</MemoryRouter>
</QueryClientProvider>
);
}
describe('NotificationBell', () => {
beforeEach(() => {
vi.clearAllMocks();
useEnvironmentStore.setState({ environment: 'dev' });
});
it('renders zero badge when no unread alerts', async () => {
(apiClient.GET as any).mockResolvedValue({
data: { total: 0, bySeverity: { CRITICAL: 0, WARNING: 0, INFO: 0 } },
error: null,
});
render(<NotificationBell />, { wrapper });
expect(await screen.findByRole('button', { name: /notifications/i })).toBeInTheDocument();
expect(screen.queryByText(/^\d+$/)).toBeNull();
});
it('shows critical count when unread critical alerts exist', async () => {
(apiClient.GET as any).mockResolvedValue({
data: { total: 3, bySeverity: { CRITICAL: 1, WARNING: 2, INFO: 0 } },
error: null,
});
render(<NotificationBell />, { wrapper });
expect(await screen.findByText('3')).toBeInTheDocument();
});
});
```
- [ ] **Step 4: Implement `NotificationBell`**
```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<string | undefined>(() => {
if (!data || total === 0) return undefined;
if ((data.bySeverity?.CRITICAL ?? 0) > 0) return 'var(--error)';
if ((data.bySeverity?.WARNING ?? 0) > 0) return 'var(--amber)';
return 'var(--muted)';
}, [data, total]);
if (!env) return null;
return (
<Link
to="/alerts/inbox"
role="button"
aria-label={`Notifications (${total} unread)`}
className={css.bell}
>
<Bell size={16} />
{total > 0 && (
<span
className={css.badge}
style={{ background: badgeColor }}
aria-hidden
>
{total > 99 ? '99+' : total}
</span>
)}
</Link>
);
}
```
- [ ] **Step 5: Create matching CSS module**
```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 — `<MustacheEditor />` component
### Task 10: Variable metadata registry
**Files:**
- Create: `ui/src/components/MustacheEditor/alert-variables.ts`
- Create: `ui/src/components/MustacheEditor/alert-variables.test.ts`
- [ ] **Step 1: Write the registry**
```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: `<MustacheEditor />` shell component
**Files:**
- Create: `ui/src/components/MustacheEditor/MustacheEditor.tsx`
- Create: `ui/src/components/MustacheEditor/MustacheEditor.module.css`
- Create: `ui/src/components/MustacheEditor/MustacheEditor.test.tsx`
- [ ] **Step 1: Write failing integration test**
```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(
<MustacheEditor
value="Hello {{rule.name}}"
onChange={() => {}}
kind="ROUTE_METRIC"
label="Title template"
/>,
);
expect(screen.getByText(/Hello/)).toBeInTheDocument();
});
it('calls onChange when the user types', () => {
const onChange = vi.fn();
render(
<MustacheEditor
value=""
onChange={onChange}
kind="ROUTE_METRIC"
label="Title template"
/>,
);
const editor = screen.getByRole('textbox');
fireEvent.input(editor, { target: { textContent: 'foo' } });
// CM6 fires via transactions; at minimum the editor is rendered and focusable.
expect(editor).toBeInTheDocument();
});
});
```
- [ ] **Step 2: Implement the shell**
```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<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
// Keep a ref to the latest onChange so the EditorView effect doesn't re-create on every render.
const onChangeRef = useRef(props.onChange);
onChangeRef.current = props.onChange;
useEffect(() => {
if (!hostRef.current) return;
const allowed = availableVariables(props.kind, { reducedContext: props.reducedContext });
const extensions: Extension[] = [
history(),
drawSelection(),
highlightSpecialChars(),
highlightActiveLine(),
closeBrackets(),
autocompletion({ override: [mustacheCompletionSource(allowed)] }),
mustacheLinter(allowed),
lintGutter(),
EditorView.updateListener.of((u) => {
if (u.docChanged) onChangeRef.current(u.state.doc.toString());
}),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
...completionKeymap,
...lintKeymap,
]),
];
if (!props.singleLine) extensions.push(lineNumbers());
if (props.singleLine) {
// Prevent Enter from inserting a newline on single-line fields.
extensions.push(
EditorState.transactionFilter.of((tr) => {
if (tr.newDoc.lines > 1) return [];
return tr;
}),
);
}
const view = new EditorView({
parent: hostRef.current,
state: EditorState.create({
doc: props.value,
extensions,
}),
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
// Extensions built once per mount; prop-driven extensions rebuilt in the effect below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.kind, props.reducedContext, props.singleLine]);
// If the parent replaces `value` externally (e.g. promotion prefill), sync the doc.
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const current = view.state.doc.toString();
if (current !== props.value) {
view.dispatch({ changes: { from: 0, to: current.length, insert: props.value } });
}
}, [props.value]);
const minH = props.minHeight ?? (props.singleLine ? 32 : 80);
return (
<div className={css.wrapper}>
<label className={css.label}>{props.label}</label>
<div
ref={hostRef}
className={css.editor}
role="textbox"
aria-label={props.label}
style={{ minHeight: minH }}
data-placeholder={props.placeholder}
/>
</div>
);
}
```
- [ ] **Step 3: Write the CSS module**
```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 `<LayoutShell />` 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: <Navigate to="/alerts/inbox" replace /> },
{ path: 'alerts/inbox', element: <SuspenseWrapper><InboxPage /></SuspenseWrapper> },
{ path: 'alerts/all', element: <SuspenseWrapper><AllAlertsPage /></SuspenseWrapper> },
{ path: 'alerts/history', element: <SuspenseWrapper><HistoryPage /></SuspenseWrapper> },
{ path: 'alerts/rules', element: <SuspenseWrapper><RulesListPage /></SuspenseWrapper> },
{ path: 'alerts/rules/new', element: <SuspenseWrapper><RuleEditorWizard /></SuspenseWrapper> },
{ path: 'alerts/rules/:id', element: <SuspenseWrapper><RuleEditorWizard /></SuspenseWrapper> },
{ path: 'alerts/silences', element: <SuspenseWrapper><SilencesPage /></SuspenseWrapper> },
```
- [ ] **Step 3: TypeScript compile passes**
```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 <div>Inbox — coming soon</div>;
}
```
Repeat for `AllAlertsPage.tsx`, `HistoryPage.tsx`, `RulesListPage.tsx`, `SilencesPage.tsx`.
```tsx
// ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx
export default function RuleEditorWizard() {
return <div>Rule editor wizard — coming soon</div>;
}
```
- [ ] **Step 5: TypeScript compile passes cleanly**
```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 `<Sidebar.Section>` for Alerts between Applications and Starred (around line 753, right after the Applications section closing `</Sidebar.Section>`):
```tsx
<Sidebar.Section
icon={createElement(Bell, { size: 16 })}
label="Alerts"
open={alertsOpen}
onToggle={toggleAlerts}
active={isAlertsPage}
>
<SidebarTree
nodes={alertsTreeNodes}
selectedPath={location.pathname}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={filterQuery}
persistKey="alerts"
autoRevealPath={sidebarRevealPath}
onNavigate={handleSidebarNavigate}
/>
</Sidebar.Section>
```
(Also add `Bell` to the existing `lucide-react` import.)
- [ ] **Step 4: TypeScript compile**
```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 `<NotificationBell />` 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 `<TopBar>` children (around line 840, `<SearchTrigger .../>` is the first child). Insert the bell before `SearchTrigger`:
```tsx
<NotificationBell />
<SearchTrigger onClick={() => setPaletteOpen(true)} />
```
Rationale: the spec asks for placement "between env selector and user menu". Since `TopBar` renders the environment selector on the left and user menu on the right (both in fixed slots), the bell lives in children as the left-most child, which is rendered in the top-right cluster adjacent to the user menu area.
- [ ] **Step 3: TypeScript compile**
```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 (
<div
className={`${css.row} ${unread ? css.rowUnread : ''}`}
data-testid={`alert-row-${alert.id}`}
>
<SeverityBadge severity={alert.severity} />
<div className={css.body}>
<Link to={`/alerts/inbox/${alert.id}`} onClick={() => markRead.mutate(alert.id)}>
<strong>{alert.title}</strong>
</Link>
<div className={css.meta}>
<AlertStateChip state={alert.state} silenced={alert.silenced} />
<span className={css.time}>{alert.firedAt}</span>
</div>
<p className={css.message}>{alert.message}</p>
</div>
<div className={css.actions}>
{alert.state === 'FIRING' && (
<Button size="small" variant="secondary" onClick={onAck} disabled={ack.isPending}>
Ack
</Button>
)}
</div>
</div>
);
}
```
- [ ] **Step 2: Build the CSS module**
```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 <PageLoader />;
if (error) return <div className={css.page}>Failed to load alerts: {String(error)}</div>;
const rows = data ?? [];
const onMarkAllRead = async () => {
if (unreadIds.length === 0) return;
try {
await bulkRead.mutateAsync(unreadIds);
toast({ title: `Marked ${unreadIds.length} as read`, variant: 'success' });
} catch (e) {
toast({ title: 'Bulk read failed', description: String(e), variant: 'error' });
}
};
return (
<div className={css.page}>
<div className={css.toolbar}>
<SectionHeader>Inbox</SectionHeader>
<Button variant="secondary" onClick={onMarkAllRead} disabled={bulkRead.isPending || unreadIds.length === 0}>
Mark all read
</Button>
</div>
{rows.length === 0 ? (
<div className={css.empty}>No open alerts for you in this environment.</div>
) : (
rows.map((a) => <AlertRow key={a.id} alert={a} unread={a.state === 'FIRING'} />)
)}
</div>
);
}
```
- [ ] **Step 4: TypeScript compile**
```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 <PageLoader />;
if (error) return <div className={css.page}>Failed to load alerts: {String(error)}</div>;
const rows = data ?? [];
return (
<div className={css.page}>
<div className={css.toolbar}>
<SectionHeader>All alerts</SectionHeader>
<div style={{ display: 'flex', gap: 4 }}>
{STATE_FILTERS.map((f, i) => (
<Button
key={f.label}
size="small"
variant={i === filterIdx ? 'primary' : 'secondary'}
onClick={() => setFilterIdx(i)}
>
{f.label}
</Button>
))}
</div>
</div>
{rows.length === 0 ? (
<div className={css.empty}>No alerts match this filter.</div>
) : (
rows.map((a) => <AlertRow key={a.id} alert={a} unread={false} />)
)}
</div>
);
}
```
- [ ] **Step 2: Write `HistoryPage`**
```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 <PageLoader />;
if (error) return <div className={css.page}>Failed to load history: {String(error)}</div>;
const rows = data ?? [];
return (
<div className={css.page}>
<div className={css.toolbar}>
<SectionHeader>History</SectionHeader>
</div>
{rows.length === 0 ? (
<div className={css.empty}>No resolved alerts in retention window.</div>
) : (
rows.map((a) => <AlertRow key={a.id} alert={a} unread={false} />)
)}
</div>
);
}
```
- [ ] **Step 3: TypeScript compile + commit**
```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 <PageLoader />;
if (error) return <div>Failed to load rules: {String(error)}</div>;
const rows = rules ?? [];
const otherEnvs = (envs ?? []).filter((e) => e.slug !== env);
const onToggle = async (r: AlertRuleResponse) => {
try {
await setEnabled.mutateAsync({ id: r.id, enabled: !r.enabled });
toast({ title: r.enabled ? 'Disabled' : 'Enabled', description: r.name, variant: 'success' });
} catch (e) {
toast({ title: 'Toggle failed', description: String(e), variant: 'error' });
}
};
const onDelete = async (r: AlertRuleResponse) => {
if (!confirm(`Delete rule "${r.name}"? Fired alerts are preserved via rule_snapshot.`)) return;
try {
await deleteRule.mutateAsync(r.id);
toast({ title: 'Deleted', description: r.name, variant: 'success' });
} catch (e) {
toast({ title: 'Delete failed', description: String(e), variant: 'error' });
}
};
const onPromote = (r: AlertRuleResponse, targetEnvSlug: string) => {
navigate(`/alerts/rules/new?promoteFrom=${env}&ruleId=${r.id}&targetEnv=${targetEnvSlug}`);
};
return (
<div style={{ padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<SectionHeader>Alert rules</SectionHeader>
<Link to="/alerts/rules/new">
<Button variant="primary">New rule</Button>
</Link>
</div>
<div className={sectionStyles.section}>
{rows.length === 0 ? (
<p>No rules yet. Create one to start evaluating alerts for this environment.</p>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ textAlign: 'left' }}>Name</th>
<th style={{ textAlign: 'left' }}>Kind</th>
<th style={{ textAlign: 'left' }}>Severity</th>
<th style={{ textAlign: 'left' }}>Enabled</th>
<th style={{ textAlign: 'left' }}>Targets</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td><Link to={`/alerts/rules/${r.id}`}>{r.name}</Link></td>
<td><Badge label={r.conditionKind} color="auto" variant="outlined" /></td>
<td><SeverityBadge severity={r.severity} /></td>
<td>
<Toggle
checked={r.enabled}
onChange={() => onToggle(r)}
disabled={setEnabled.isPending}
/>
</td>
<td>{r.targets.length}</td>
<td style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
{otherEnvs.length > 0 && (
<select
value=""
onChange={(e) => { if (e.target.value) onPromote(r, e.target.value); }}
aria-label={`Promote ${r.name} to another env`}
>
<option value="">Promote to ▾</option>
{otherEnvs.map((e) => (
<option key={e.slug} value={e.slug}>{e.slug}</option>
))}
</select>
)}
<Button variant="secondary" onClick={() => onDelete(r)} disabled={deleteRule.isPending}>
Delete
</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
```
- [ ] **Step 2: TypeScript compile + commit**
```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<AlertCondition>;
evaluationIntervalSeconds: number;
forDurationSeconds: number;
reNotifyMinutes: number;
notificationTitleTmpl: string;
notificationMessageTmpl: string;
webhooks: Array<{
outboundConnectionId: string;
bodyOverride: string;
headerOverrides: Array<{ key: string; value: string }>;
}>;
targets: Array<{ targetKind: 'USER' | 'GROUP' | 'ROLE'; targetId: string }>;
}
export function initialForm(existing?: AlertRuleResponse): FormState {
if (!existing) {
return {
name: '',
description: '',
severity: 'WARNING',
enabled: true,
scopeKind: 'env',
appSlug: '',
routeId: '',
agentId: '',
conditionKind: 'ROUTE_METRIC',
condition: { kind: 'ROUTE_METRIC' } as Partial<AlertCondition>,
evaluationIntervalSeconds: 60,
forDurationSeconds: 0,
reNotifyMinutes: 60,
notificationTitleTmpl: '{{rule.name}} is firing',
notificationMessageTmpl: 'Alert {{alert.id}} fired at {{alert.firedAt}}',
webhooks: [],
targets: [],
};
}
const scope = (existing.condition as any)?.scope ?? {};
const scopeKind: FormState['scopeKind'] = scope.agentId
? 'agent'
: scope.routeId
? 'route'
: scope.appSlug
? 'app'
: 'env';
return {
name: existing.name,
description: existing.description ?? '',
severity: existing.severity,
enabled: existing.enabled,
scopeKind,
appSlug: scope.appSlug ?? '',
routeId: scope.routeId ?? '',
agentId: scope.agentId ?? '',
conditionKind: existing.conditionKind,
condition: existing.condition,
evaluationIntervalSeconds: existing.evaluationIntervalSeconds,
forDurationSeconds: existing.forDurationSeconds,
reNotifyMinutes: existing.reNotifyMinutes,
notificationTitleTmpl: existing.notificationTitleTmpl,
notificationMessageTmpl: existing.notificationMessageTmpl,
webhooks: (existing.webhooks ?? []).map((w) => ({
outboundConnectionId: (w as any).outboundConnectionId,
bodyOverride: (w as any).bodyOverride ?? '',
headerOverrides: Object.entries(((w as any).headerOverrides ?? {}) as Record<string, string>)
.map(([key, value]) => ({ key, value })),
})),
targets: existing.targets ?? [],
};
}
export function toRequest(f: FormState): AlertRuleRequest {
const scope: Record<string, string | undefined> = {};
if (f.scopeKind === 'app' || f.scopeKind === 'route' || f.scopeKind === 'agent') scope.appSlug = f.appSlug || undefined;
if (f.scopeKind === 'route') scope.routeId = f.routeId || undefined;
if (f.scopeKind === 'agent') scope.agentId = f.agentId || undefined;
const condition = { ...f.condition, kind: f.conditionKind, scope } as AlertCondition;
return {
name: f.name,
description: f.description || undefined,
severity: f.severity,
enabled: f.enabled,
conditionKind: f.conditionKind,
condition,
evaluationIntervalSeconds: f.evaluationIntervalSeconds,
forDurationSeconds: f.forDurationSeconds,
reNotifyMinutes: f.reNotifyMinutes,
notificationTitleTmpl: f.notificationTitleTmpl,
notificationMessageTmpl: f.notificationMessageTmpl,
webhooks: f.webhooks.map((w) => ({
outboundConnectionId: w.outboundConnectionId,
bodyOverride: w.bodyOverride || undefined,
headerOverrides: Object.fromEntries(w.headerOverrides.filter((h) => h.key.trim()).map((h) => [h.key.trim(), h.value])),
})),
targets: f.targets,
} as AlertRuleRequest;
}
export function validateStep(step: WizardStep, f: FormState): string[] {
const errs: string[] = [];
if (step === 'scope') {
if (!f.name.trim()) errs.push('Name is required.');
if (f.scopeKind !== 'env' && !f.appSlug.trim()) errs.push('App is required for app/route/agent scope.');
if (f.scopeKind === 'route' && !f.routeId.trim()) errs.push('Route id is required for route scope.');
if (f.scopeKind === 'agent' && !f.agentId.trim()) errs.push('Agent id is required for agent scope.');
}
if (step === 'condition') {
if (!f.conditionKind) errs.push('Condition kind is required.');
}
if (step === 'trigger') {
if (f.evaluationIntervalSeconds < 5) errs.push('Evaluation interval must be ≥ 5 s.');
if (f.forDurationSeconds < 0) errs.push('For-duration must be ≥ 0.');
if (f.reNotifyMinutes < 0) errs.push('Re-notify cadence must be ≥ 0.');
}
if (step === 'notify') {
if (!f.notificationTitleTmpl.trim()) errs.push('Notification title template is required.');
if (!f.notificationMessageTmpl.trim()) errs.push('Notification message template is required.');
}
return errs;
}
```
- [ ] **Step 2: Write tests**
```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<WizardStep, string> = {
scope: '1. Scope',
condition: '2. Condition',
trigger: '3. Trigger',
notify: '4. Notify',
review: '5. Review',
};
export default function RuleEditorWizard() {
const navigate = useNavigate();
const { id } = useParams<{ id?: string }>();
const [search] = useSearchParams();
const { toast } = useToast();
const isEdit = !!id;
const existingQuery = useAlertRule(isEdit ? id : undefined);
// Promotion prefill uses a separate query against the source env; implemented in Task 24.
const promoteFrom = search.get('promoteFrom') ?? undefined;
const promoteRuleId = search.get('ruleId') ?? undefined;
const sourceRuleQuery = useSourceRule(promoteFrom ? promoteRuleId : undefined);
const [step, setStep] = useState<WizardStep>('scope');
const [form, setForm] = useState<FormState | null>(null);
// Initialize form once the existing or source rule loads.
const ready = useMemo(() => {
if (form) return true;
if (isEdit && existingQuery.data) {
setForm(initialForm(existingQuery.data));
return true;
}
if (promoteFrom && sourceRuleQuery.data) {
setForm(prefillFromPromotion(sourceRuleQuery.data));
return true;
}
if (!isEdit && !promoteFrom) {
setForm(initialForm());
return true;
}
return false;
}, [form, isEdit, existingQuery.data, promoteFrom, sourceRuleQuery.data]);
const create = useCreateAlertRule();
const update = useUpdateAlertRule(id ?? '');
if (!ready || !form) return <PageLoader />;
const idx = WIZARD_STEPS.indexOf(step);
const errors = validateStep(step, form);
const onNext = () => {
if (errors.length > 0) {
toast({ title: 'Fix validation errors before continuing', description: errors.join(' · '), variant: 'error' });
return;
}
if (idx < WIZARD_STEPS.length - 1) setStep(WIZARD_STEPS[idx + 1]);
};
const onBack = () => { if (idx > 0) setStep(WIZARD_STEPS[idx - 1]); };
const onSave = async () => {
try {
if (isEdit) {
await update.mutateAsync(toRequest(form));
toast({ title: 'Rule updated', description: form.name, variant: 'success' });
} else {
await create.mutateAsync(toRequest(form));
toast({ title: 'Rule created', description: form.name, variant: 'success' });
}
navigate('/alerts/rules');
} catch (e) {
toast({ title: 'Save failed', description: String(e), variant: 'error' });
}
};
const body =
step === 'scope' ? <ScopeStep form={form} setForm={setForm} /> :
step === 'condition' ? <ConditionStep form={form} setForm={setForm} /> :
step === 'trigger' ? <TriggerStep form={form} setForm={setForm} /> :
step === 'notify' ? <NotifyStep form={form} setForm={setForm} ruleId={id} /> :
<ReviewStep form={form} />;
return (
<div className={css.wizard}>
<div className={css.header}>
<SectionHeader>{isEdit ? `Edit rule: ${form.name}` : 'New alert rule'}</SectionHeader>
{promoteFrom && (
<div className={css.promoteBanner}>
Promoting from <code>{promoteFrom}</code> — review and adjust, then save.
</div>
)}
</div>
<nav className={css.steps}>
{WIZARD_STEPS.map((s, i) => (
<button
key={s}
className={`${css.step} ${step === s ? css.stepActive : ''} ${i < idx ? css.stepDone : ''}`}
onClick={() => setStep(s)}
>
{STEP_LABELS[s]}
</button>
))}
</nav>
<div className={css.stepBody}>{body}</div>
<div className={css.footer}>
<Button variant="secondary" onClick={onBack} disabled={idx === 0}>Back</Button>
{idx < WIZARD_STEPS.length - 1 ? (
<Button variant="primary" onClick={onNext}>Next</Button>
) : (
<Button variant="primary" onClick={onSave} disabled={create.isPending || update.isPending}>
{isEdit ? 'Save changes' : 'Create rule'}
</Button>
)}
</div>
</div>
);
}
```
- [ ] **Step 4: Write the CSS module**
```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 <div>Scope step — TODO Task 20</div>;
}
```
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 2024."
```
---
### 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 (
<div style={{ display: 'grid', gap: 12, maxWidth: 600 }}>
<FormField label="Name">
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Order API error rate" />
</FormField>
<FormField label="Description">
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</FormField>
<FormField label="Severity">
<Select
value={form.severity}
onChange={(v) => setForm({ ...form, severity: v as FormState['severity'] })}
options={SEVERITY_OPTIONS}
/>
</FormField>
<FormField label="Scope">
<Select
value={form.scopeKind}
onChange={(v) => setForm({ ...form, scopeKind: v as FormState['scopeKind'] })}
options={SCOPE_OPTIONS}
/>
</FormField>
{form.scopeKind !== 'env' && (
<FormField label="App">
<Select
value={form.appSlug}
onChange={(v) => setForm({ ...form, appSlug: v, routeId: '', agentId: '' })}
options={apps.map((a) => ({ value: a.slug, label: a.name }))}
/>
</FormField>
)}
{form.scopeKind === 'route' && (
<FormField label="Route">
<Select
value={form.routeId}
onChange={(v) => setForm({ ...form, routeId: v })}
options={routes.map((r: any) => ({ value: r.routeId, label: r.routeId }))}
/>
</FormField>
)}
{form.scopeKind === 'agent' && (
<FormField label="Agent">
<Select
value={form.agentId}
onChange={(v) => setForm({ ...form, agentId: v })}
options={appAgents.map((a: any) => ({ value: a.instanceId, label: a.displayName ?? a.instanceId }))}
/>
</FormField>
)}
</div>
);
}
```
- [ ] **Step 2: TypeScript compile + commit**
```bash
cd ui && npx tsc -p tsconfig.app.json --noEmit
git add ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx
git commit -m "feat(ui/alerts): ScopeStep (name, severity, env/app/route/agent selectors)"
```
---
### Task 21: `ConditionStep` + condition-form subcomponents
**Files:**
- Replace: `ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx`
- Create: `ui/src/pages/Alerts/RuleEditor/condition-forms/RouteMetricForm.tsx`
- Create: `ui/src/pages/Alerts/RuleEditor/condition-forms/ExchangeMatchForm.tsx`
- Create: `ui/src/pages/Alerts/RuleEditor/condition-forms/AgentStateForm.tsx`
- Create: `ui/src/pages/Alerts/RuleEditor/condition-forms/DeploymentStateForm.tsx`
- Create: `ui/src/pages/Alerts/RuleEditor/condition-forms/LogPatternForm.tsx`
- Create: `ui/src/pages/Alerts/RuleEditor/condition-forms/JvmMetricForm.tsx`
- [ ] **Step 1: `ConditionStep` routes to the kind-specific sub-form**
```tsx
// ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx
import { FormField, Select } from '@cameleer/design-system';
import type { FormState } from './form-state';
import { RouteMetricForm } from './condition-forms/RouteMetricForm';
import { ExchangeMatchForm } from './condition-forms/ExchangeMatchForm';
import { AgentStateForm } from './condition-forms/AgentStateForm';
import { DeploymentStateForm } from './condition-forms/DeploymentStateForm';
import { LogPatternForm } from './condition-forms/LogPatternForm';
import { JvmMetricForm } from './condition-forms/JvmMetricForm';
const KIND_OPTIONS = [
{ value: 'ROUTE_METRIC', label: 'Route metric (error rate, latency, throughput)' },
{ value: 'EXCHANGE_MATCH', label: 'Exchange match (specific failures)' },
{ value: 'AGENT_STATE', label: 'Agent state (DEAD / STALE)' },
{ value: 'DEPLOYMENT_STATE', label: 'Deployment state (FAILED / DEGRADED)' },
{ value: 'LOG_PATTERN', label: 'Log pattern (count of matching logs)' },
{ value: 'JVM_METRIC', label: 'JVM metric (heap, GC, inflight)' },
];
export function ConditionStep({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
const onKindChange = (v: string) => {
setForm({ ...form, conditionKind: v as FormState['conditionKind'], condition: { kind: v as any } });
};
return (
<div style={{ display: 'grid', gap: 16, maxWidth: 720 }}>
<FormField label="Condition kind">
<Select value={form.conditionKind} onChange={onKindChange} options={KIND_OPTIONS} />
</FormField>
{form.conditionKind === 'ROUTE_METRIC' && <RouteMetricForm form={form} setForm={setForm} />}
{form.conditionKind === 'EXCHANGE_MATCH' && <ExchangeMatchForm form={form} setForm={setForm} />}
{form.conditionKind === 'AGENT_STATE' && <AgentStateForm form={form} setForm={setForm} />}
{form.conditionKind === 'DEPLOYMENT_STATE' && <DeploymentStateForm form={form} setForm={setForm} />}
{form.conditionKind === 'LOG_PATTERN' && <LogPatternForm form={form} setForm={setForm} />}
{form.conditionKind === 'JVM_METRIC' && <JvmMetricForm form={form} setForm={setForm} />}
</div>
);
}
```
- [ ] **Step 2: `RouteMetricForm`**
```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 (
<>
<FormField label="Metric"><Select value={c.metric ?? ''} onChange={(v) => patch({ metric: v })} options={METRICS} /></FormField>
<FormField label="Comparator"><Select value={c.comparator ?? 'GT'} onChange={(v) => patch({ comparator: v })} options={COMPARATORS} /></FormField>
<FormField label="Threshold"><Input type="number" value={c.threshold ?? ''} onChange={(e) => patch({ threshold: Number(e.target.value) })} /></FormField>
<FormField label="Window (seconds)"><Input type="number" value={c.windowSeconds ?? 300} onChange={(e) => patch({ windowSeconds: Number(e.target.value) })} /></FormField>
</>
);
}
```
- [ ] **Step 3: `ExchangeMatchForm` (PER_EXCHANGE or COUNT_IN_WINDOW)**
```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 (
<>
<FormField label="Fire mode"><Select value={c.fireMode ?? 'PER_EXCHANGE'} onChange={(v) => patch({ fireMode: v })} options={FIRE_MODES} /></FormField>
<FormField label="Status filter"><Select value={filter.status ?? ''} onChange={(v) => patch({ filter: { ...filter, status: v || undefined } })} options={STATUSES} /></FormField>
{c.fireMode === 'PER_EXCHANGE' && (
<FormField label="Linger seconds (default 300)">
<Input type="number" value={c.perExchangeLingerSeconds ?? 300} onChange={(e) => patch({ perExchangeLingerSeconds: Number(e.target.value) })} />
</FormField>
)}
{c.fireMode === 'COUNT_IN_WINDOW' && (
<>
<FormField label="Threshold (matches)"><Input type="number" value={c.threshold ?? ''} onChange={(e) => patch({ threshold: Number(e.target.value) })} /></FormField>
<FormField label="Window (seconds)"><Input type="number" value={c.windowSeconds ?? 900} onChange={(e) => patch({ windowSeconds: Number(e.target.value) })} /></FormField>
</>
)}
</>
);
}
```
- [ ] **Step 4: `AgentStateForm`, `DeploymentStateForm`, `LogPatternForm`, `JvmMetricForm`**
Each follows the same pattern. Keep the code short — they're simple enum/threshold pickers.
```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 (
<>
<FormField label="Agent state">
<Select value={c.state ?? 'DEAD'} onChange={(v) => patch({ state: v })} options={[
{ value: 'DEAD', label: 'DEAD' },
{ value: 'STALE', label: 'STALE' },
]} />
</FormField>
<FormField label="For duration (seconds)">
<Input type="number" value={c.forSeconds ?? 60} onChange={(e) => patch({ forSeconds: Number(e.target.value) })} />
</FormField>
</>
);
}
```
```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 (
<FormField label="Fire when deployment is in states">
<div style={{ display: 'flex', gap: 12 }}>
{OPTIONS.map((s) => (
<label key={s} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input type="checkbox" checked={states.includes(s)} onChange={() => toggle(s)} />
{s}
</label>
))}
</div>
</FormField>
);
}
```
```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 (
<>
<FormField label="Level">
<Select value={c.level ?? 'ERROR'} onChange={(v) => patch({ level: v })} options={[
{ value: 'ERROR', label: 'ERROR' },
{ value: 'WARN', label: 'WARN' },
{ value: 'INFO', label: 'INFO' },
]} />
</FormField>
<FormField label="Logger (substring, optional)">
<Input value={c.logger ?? ''} onChange={(e) => patch({ logger: e.target.value || undefined })} />
</FormField>
<FormField label="Pattern (regex)">
<Input value={c.pattern ?? ''} onChange={(e) => patch({ pattern: e.target.value })} />
</FormField>
<FormField label="Threshold (matches)">
<Input type="number" value={c.threshold ?? ''} onChange={(e) => patch({ threshold: Number(e.target.value) })} />
</FormField>
<FormField label="Window (seconds)">
<Input type="number" value={c.windowSeconds ?? 900} onChange={(e) => patch({ windowSeconds: Number(e.target.value) })} />
</FormField>
</>
);
}
```
```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 (
<>
<FormField label="Metric name">
<Input value={c.metric ?? ''} onChange={(e) => patch({ metric: e.target.value })} placeholder="heap_used_percent" />
</FormField>
<FormField label="Aggregation">
<Select value={c.aggregation ?? 'MAX'} onChange={(v) => patch({ aggregation: v })} options={[
{ value: 'MAX', label: 'MAX' },
{ value: 'AVG', label: 'AVG' },
{ value: 'MIN', label: 'MIN' },
]} />
</FormField>
<FormField label="Comparator">
<Select value={c.comparator ?? 'GT'} onChange={(v) => patch({ comparator: v })} options={[
{ value: 'GT', label: '>' }, { value: 'GTE', label: '≥' }, { value: 'LT', label: '<' }, { value: 'LTE', label: '≤' },
]} />
</FormField>
<FormField label="Threshold">
<Input type="number" value={c.threshold ?? ''} onChange={(e) => patch({ threshold: Number(e.target.value) })} />
</FormField>
<FormField label="Window (seconds)">
<Input type="number" value={c.windowSeconds ?? 300} onChange={(e) => patch({ windowSeconds: Number(e.target.value) })} />
</FormField>
</>
);
}
```
- [ ] **Step 5: TypeScript compile + commit**
```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<string | null>(null);
const onTest = async () => {
if (!ruleId) {
toast({ title: 'Save rule first to run test evaluate', variant: 'error' });
return;
}
try {
const result = await testEvaluate.mutateAsync({ id: ruleId, req: { condition: (toRequest(form).condition as any) } });
setLastResult(JSON.stringify(result, null, 2));
} catch (e) {
toast({ title: 'Test-evaluate failed', description: String(e), variant: 'error' });
}
};
return (
<div style={{ display: 'grid', gap: 12, maxWidth: 600 }}>
<FormField label="Evaluation interval (seconds, min 5)">
<Input type="number" min={5} value={form.evaluationIntervalSeconds}
onChange={(e) => setForm({ ...form, evaluationIntervalSeconds: Number(e.target.value) })} />
</FormField>
<FormField label="For-duration before firing (seconds, 0 = fire immediately)">
<Input type="number" min={0} value={form.forDurationSeconds}
onChange={(e) => setForm({ ...form, forDurationSeconds: Number(e.target.value) })} />
</FormField>
<FormField label="Re-notify cadence (minutes, 0 = notify once)">
<Input type="number" min={0} value={form.reNotifyMinutes}
onChange={(e) => setForm({ ...form, reNotifyMinutes: Number(e.target.value) })} />
</FormField>
<div>
<Button variant="secondary" onClick={onTest} disabled={testEvaluate.isPending}>
Test evaluate (uses current condition)
</Button>
{lastResult && (
<pre style={{ marginTop: 12, padding: 8, background: 'var(--code-bg)', borderRadius: 6, fontSize: 12, maxHeight: 240, overflow: 'auto' }}>
{lastResult}
</pre>
)}
</div>
</div>
);
}
```
- [ ] **Step 2: TypeScript compile + commit**
```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<string | null>(null);
// Filter connections to those that allow the current env.
const availableConnections = (connections ?? []).filter(
(c) => c.allowedEnvironmentIds.length === 0 || c.allowedEnvironmentIds.includes(env!),
);
const onPreview = async () => {
if (!ruleId) {
toast({ title: 'Save rule first to preview', variant: 'error' });
return;
}
try {
const res = await preview.mutateAsync({
id: ruleId,
req: {
titleTemplate: form.notificationTitleTmpl,
messageTemplate: form.notificationMessageTmpl,
},
});
setLastPreview(`TITLE:\n${(res as any).renderedTitle}\n\nMESSAGE:\n${(res as any).renderedMessage}`);
} catch (e) {
toast({ title: 'Preview failed', description: String(e), variant: 'error' });
}
};
const addTarget = (targetKind: 'USER' | 'GROUP' | 'ROLE', targetId: string) => {
if (!targetId) return;
if (form.targets.some((t) => t.targetKind === targetKind && t.targetId === targetId)) return;
setForm({ ...form, targets: [...form.targets, { targetKind, targetId }] });
};
const removeTarget = (idx: number) => {
setForm({ ...form, targets: form.targets.filter((_, i) => i !== idx) });
};
const addWebhook = (outboundConnectionId: string) => {
setForm({
...form,
webhooks: [...form.webhooks, { outboundConnectionId, bodyOverride: '', headerOverrides: [] }],
});
};
const removeWebhook = (idx: number) => {
setForm({ ...form, webhooks: form.webhooks.filter((_, i) => i !== idx) });
};
const updateWebhook = (idx: number, patch: Partial<FormState['webhooks'][number]>) => {
setForm({ ...form, webhooks: form.webhooks.map((w, i) => i === idx ? { ...w, ...patch } : w) });
};
return (
<div style={{ display: 'grid', gap: 16, maxWidth: 720 }}>
<MustacheEditor
label="Notification title"
value={form.notificationTitleTmpl}
onChange={(v) => setForm({ ...form, notificationTitleTmpl: v })}
kind={form.conditionKind}
singleLine
/>
<MustacheEditor
label="Notification message"
value={form.notificationMessageTmpl}
onChange={(v) => setForm({ ...form, notificationMessageTmpl: v })}
kind={form.conditionKind}
minHeight={120}
/>
<div>
<Button variant="secondary" onClick={onPreview} disabled={preview.isPending}>
Preview rendered output
</Button>
{lastPreview && (
<pre style={{ marginTop: 8, padding: 8, background: 'var(--code-bg)', borderRadius: 6, fontSize: 12, whiteSpace: 'pre-wrap' }}>
{lastPreview}
</pre>
)}
</div>
<FormField label="Notification targets">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{form.targets.map((t, i) => (
<Badge
key={`${t.targetKind}:${t.targetId}`}
label={`${t.targetKind}: ${t.targetId}`}
color="auto"
variant="outlined"
onRemove={() => removeTarget(i)}
/>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Select value="" onChange={(v) => addTarget('USER', v)} options={[{ value: '', label: '+ User' }, ...(users ?? []).map((u) => ({ value: u.userId, label: u.displayName ?? u.userId }))]} />
<Select value="" onChange={(v) => addTarget('GROUP', v)} options={[{ value: '', label: '+ Group' }, ...(groups ?? []).map((g) => ({ value: g.id, label: g.name }))]} />
<Select value="" onChange={(v) => addTarget('ROLE', v)} options={[{ value: '', label: '+ Role' }, ...(roles ?? []).map((r) => ({ value: r.name, label: r.name }))]} />
</div>
</FormField>
<FormField label="Webhook destinations (outbound connections)">
<Select
value=""
onChange={(v) => { if (v) addWebhook(v); }}
options={[
{ value: '', label: '+ Add webhook' },
...availableConnections.map((c) => ({ value: c.id, label: c.name })),
]}
/>
{form.webhooks.map((w, i) => {
const conn = availableConnections.find((c) => c.id === w.outboundConnectionId);
return (
<div key={i} style={{ padding: 12, border: '1px solid var(--border)', borderRadius: 6, marginTop: 8, display: 'grid', gap: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<strong>{conn?.name ?? w.outboundConnectionId}</strong>
<Button size="small" variant="secondary" onClick={() => removeWebhook(i)}>Remove</Button>
</div>
<MustacheEditor
label="Body override (optional)"
value={w.bodyOverride}
onChange={(v) => updateWebhook(i, { bodyOverride: v })}
kind={form.conditionKind}
placeholder="Leave empty to use connection default"
minHeight={80}
/>
<FormField label="Header overrides">
{w.headerOverrides.map((h, hi) => (
<div key={hi} style={{ display: 'flex', gap: 8, marginBottom: 4 }}>
<Input value={h.key} placeholder="Header name" onChange={(e) => {
const heads = [...w.headerOverrides]; heads[hi] = { ...heads[hi], key: e.target.value };
updateWebhook(i, { headerOverrides: heads });
}} />
<Input value={h.value} placeholder="Mustache value" onChange={(e) => {
const heads = [...w.headerOverrides]; heads[hi] = { ...heads[hi], value: e.target.value };
updateWebhook(i, { headerOverrides: heads });
}} />
<Button size="small" variant="secondary" onClick={() => {
updateWebhook(i, { headerOverrides: w.headerOverrides.filter((_, x) => x !== hi) });
}}>×</Button>
</div>
))}
<Button size="small" variant="secondary" onClick={() => {
updateWebhook(i, { headerOverrides: [...w.headerOverrides, { key: '', value: '' }] });
}}>+ Header override</Button>
</FormField>
</div>
);
})}
</FormField>
</div>
);
}
```
- [ ] **Step 2: TypeScript compile + commit**
```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 (
<div style={{ display: 'grid', gap: 12, maxWidth: 720 }}>
<div><strong>Name:</strong> {form.name}</div>
<div><strong>Severity:</strong> {form.severity}</div>
<div><strong>Scope:</strong> {form.scopeKind}
{form.scopeKind !== 'env' && ` (app=${form.appSlug}${form.routeId ? `, route=${form.routeId}` : ''}${form.agentId ? `, agent=${form.agentId}` : ''})`}
</div>
<div><strong>Condition kind:</strong> {form.conditionKind}</div>
<div><strong>Intervals:</strong> eval {form.evaluationIntervalSeconds}s · for {form.forDurationSeconds}s · re-notify {form.reNotifyMinutes}m</div>
<div><strong>Targets:</strong> {form.targets.length}</div>
<div><strong>Webhooks:</strong> {form.webhooks.length}</div>
{setForm && (
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Toggle checked={form.enabled} onChange={(v) => setForm({ ...form, enabled: v })} />
Enabled on save
</label>
)}
<details>
<summary>Raw request JSON</summary>
<pre style={{ fontSize: 11, background: 'var(--code-bg)', padding: 8, borderRadius: 6, overflow: 'auto' }}>
{JSON.stringify(req, null, 2)}
</pre>
</details>
</div>
);
}
```
- [ ] **Step 2: Rewrite `promotion-prefill.ts` with warnings**
```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> = {}): 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<PrefillWarning[]>([]);
```
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 && (
<div className={css.promoteBanner}>
<strong>Review before saving:</strong>
<ul>{warnings.map((w) => <li key={w.field}><code>{w.field}</code>: {w.message}</li>)}</ul>
</div>
)}
```
- [ ] **Step 2: TypeScript compile + commit**
```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 <PageLoader />;
if (error) return <div>Failed to load silences: {String(error)}</div>;
const onCreate = async () => {
const now = new Date();
const endsAt = new Date(now.getTime() + hours * 3600_000);
const matcher: Record<string, string> = {};
if (matcherRuleId) matcher.ruleId = matcherRuleId;
if (matcherAppSlug) matcher.appSlug = matcherAppSlug;
if (Object.keys(matcher).length === 0) {
toast({ title: 'Silence needs at least one matcher field', variant: 'error' });
return;
}
try {
await create.mutateAsync({
matcher,
reason: reason || undefined,
startsAt: now.toISOString(),
endsAt: endsAt.toISOString(),
});
setReason(''); setMatcherRuleId(''); setMatcherAppSlug(''); setHours(1);
toast({ title: 'Silence created', variant: 'success' });
} catch (e) {
toast({ title: 'Create failed', description: String(e), variant: 'error' });
}
};
const onRemove = async (s: AlertSilenceResponse) => {
if (!confirm(`End silence early?`)) return;
try {
await remove.mutateAsync(s.id);
toast({ title: 'Silence removed', variant: 'success' });
} catch (e) {
toast({ title: 'Remove failed', description: String(e), variant: 'error' });
}
};
const rows = data ?? [];
return (
<div style={{ padding: 16 }}>
<SectionHeader>Alert silences</SectionHeader>
<div className={sectionStyles.section} style={{ marginTop: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr) auto', gap: 8, alignItems: 'end' }}>
<FormField label="Rule ID (optional)"><Input value={matcherRuleId} onChange={(e) => setMatcherRuleId(e.target.value)} /></FormField>
<FormField label="App slug (optional)"><Input value={matcherAppSlug} onChange={(e) => setMatcherAppSlug(e.target.value)} /></FormField>
<FormField label="Duration (hours)"><Input type="number" min={1} value={hours} onChange={(e) => setHours(Number(e.target.value))} /></FormField>
<FormField label="Reason"><Input value={reason} onChange={(e) => setReason(e.target.value)} placeholder="Maintenance window" /></FormField>
<Button variant="primary" onClick={onCreate} disabled={create.isPending}>Create silence</Button>
</div>
</div>
<div className={sectionStyles.section} style={{ marginTop: 16 }}>
{rows.length === 0 ? (
<p>No active or scheduled silences.</p>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ textAlign: 'left' }}>Matcher</th>
<th style={{ textAlign: 'left' }}>Reason</th>
<th style={{ textAlign: 'left' }}>Starts</th>
<th style={{ textAlign: 'left' }}>Ends</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((s) => (
<tr key={s.id}>
<td><code>{JSON.stringify(s.matcher)}</code></td>
<td>{s.reason ?? '—'}</td>
<td>{s.startsAt}</td>
<td>{s.endsAt}</td>
<td><Button variant="secondary" onClick={() => onRemove(s)}>End</Button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
```
- [ ] **Step 2: TypeScript compile + commit**
```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<Instant> now = new AtomicReference<>(Instant.parse("2026-04-20T00:00:00Z"));
Clock clock = Clock.fixed(now.get(), ZoneOffset.UTC);
AlertingMetrics metrics = new AlertingMetrics(
new SimpleMeterRegistry(),
() -> { calls.incrementAndGet(); return 7L; }, // count supplier
() -> 0L, // open rules
() -> 0L, // circuit-open
Duration.ofSeconds(30),
() -> Clock.fixed(now.get(), ZoneOffset.UTC).instant()
);
// Registering the gauge does not call the supplier; reading it does (Micrometer semantics).
metrics.snapshotAllGauges(); // first read — delegates through cache, 1 underlying call
metrics.snapshotAllGauges(); // second read within TTL — served from cache, 0 new calls
assertThat(calls.get()).isEqualTo(1);
now.set(Instant.parse("2026-04-20T00:00:31Z")); // advance 31 s
metrics.snapshotAllGauges();
assertThat(calls.get()).isEqualTo(2);
}
}
```
Run: `cd cameleer-server-app && mvn -pl . -am test -Dtest=AlertingMetricsCachingTest`
Expected: FAIL (caching not implemented, or signature mismatch).
- [ ] **Step 2: Refactor `AlertingMetrics` to wrap suppliers in a TTL cache**
Read the existing file. If it currently uses `registry.gauge(...)` with direct suppliers, wrap each one in a lightweight cache:
```java
// Inside AlertingMetrics
private static final class TtlCache {
private final Supplier<Long> delegate;
private final Duration ttl;
private final Supplier<Instant> clock;
private volatile Instant lastRead = Instant.MIN;
private volatile long cached = 0L;
TtlCache(Supplier<Long> delegate, Duration ttl, Supplier<Instant> clock) {
this.delegate = delegate;
this.ttl = ttl;
this.clock = clock;
}
long get() {
Instant now = clock.get();
if (Duration.between(lastRead, now).compareTo(ttl) >= 0) {
cached = delegate.get();
lastRead = now;
}
return cached;
}
}
```
Register the gauges with `() -> cache.get()` in place of raw suppliers. Keep the existing bean constructor signature on the production path (default `ttl = Duration.ofSeconds(30)`, clock = `Instant::now`) and add a test-friendly constructor variant with explicit `ttl` + `clock`.
Add a test helper `snapshotAllGauges()` that reads each registered cache's value (or simply calls `registry.find(...).gauge().value()` on each metric) — whatever the existing method surface supports. If no such helper exists, expose package-private accessors on the caches.
- [ ] **Step 3: Run test**
```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 `<NotificationBell />` | Tasks 9, 15 |
| §13 `<AlertStateChip />` | Task 8 |
| §13 Rule editor 5-step wizard | Tasks 1925 |
| §13 `<MustacheEditor />` with variable autocomplete | Tasks 1012 |
| §13 Silences / History / Rules list / OutboundConnectionAdminPage | Tasks 16, 17, 18, 26 (Outbound page already exists from Plan 01) |
| §13 Real-time: bell polls every 30s; paused when tab hidden | Task 9 (`usePageVisible`) + query `refetchIntervalInBackground:false` |
| §13 Accessibility: keyboard nav, ARIA | CM6 autocomplete is ARIA-conformant; bell has aria-label |
| §13 Styling: `@cameleer/design-system` CSS variables | All files use `var(--error)` etc. (no hardcoded hex) |
| §9 Rule promotion across envs — pure UI prefill + warnings | Tasks 18 (entry), 24, 25 |
| §17 SSRF guard on outbound URL | Task 28 |
| Final-review NIT: 30s gauge caching | Task 29 |
| Regenerate OpenAPI schema | Tasks 3, 32 |
| Update `.claude/rules/` | Task 31 |
| Testing preference: REST-API-driven (not raw SQL); Playwright over real backend | Task 30 + backend IT extension in Task 28 |
No uncovered requirements from the spec sections relevant to Plan 03.
### 2. Placeholder scan
- No "TBD" / "implement later" / "similar to Task N" / "Add appropriate error handling" patterns remain.
- Every step with a code change includes the actual code.
- Step stubs in Task 13 are explicitly marked as "replaced in Phase 5/6/7" — they're real code, just thin.
- Some condition-forms (Task 21 step 4) reuse the same pattern; each form is shown in full rather than "similar to RouteMetricForm".
### 3. Type consistency
- `MustacheEditor` props are the same shape across all call sites: `{ value, onChange, kind?, reducedContext?, label, placeholder?, minHeight?, singleLine? }`.
- `FormState` is declared in `form-state.ts` and used identically by all wizard steps.
- Schema-derived types (`AlertDto`, `AlertRuleResponse`, etc.) come from `components['schemas'][...]` so they stay in sync with the backend.
- Query hook names follow a consistent convention: `use<Resource>()` (list), `use<Resource>(id)` (single), `useCreate<Resource>`, `useUpdate<Resource>`, `useDelete<Resource>`.
No inconsistencies.
---
## Execution Handoff
**Plan complete and saved to `docs/superpowers/plans/2026-04-20-alerting-03-ui.md`. Two execution options:**
**1. Subagent-Driven (recommended)** — dispatch a fresh subagent per task, review between tasks, fast iteration.
**2. Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints.
**Which approach?**