32 tasks across 10 phases: - Foundation: Vitest, CodeMirror 6, Playwright scaffolding + schema regen. - API: env-scoped query hooks for alerts/rules/silences/notifications. - Components: AlertStateChip, SeverityBadge, NotificationBell (with tab-hidden poll pause), MustacheEditor (CM6 with variable autocomplete + linter). - Routes: /alerts/* section with sidebar accordion; bell mounted in TopBar. - Pages: Inbox / All / History / Rules (with env promotion) / Silences. - Wizard: 5-step editor with kind-specific condition forms + test-evaluate + render-preview + prefill warnings. - CMD-K: alerts + rules sources via LayoutShell extension. - Backend backfills: SSRF guard on outbound URL + 30s AlertingMetrics gauge cache. - Final: Playwright smoke, .claude/rules/ui.md + admin-guide updates, full build/test/PR. Decisions: CM6 over Monaco/textarea (90KB gzipped, ARIA-conformant); CMD-K extension via existing LayoutShell searchData (not a new registry); REST-API-driven tests per project test policy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5032 lines
178 KiB
Markdown
5032 lines
178 KiB
Markdown
# 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 20–24."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 20: `ScopeStep`
|
||
|
||
**Files:**
|
||
- Replace: `ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx`
|
||
|
||
- [ ] **Step 1: Implement the scope form**
|
||
|
||
```tsx
|
||
// ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx
|
||
import { FormField, Input, Select } from '@cameleer/design-system';
|
||
import { useCatalog } from '../../../api/queries/catalog';
|
||
import { useAgents } from '../../../api/queries/agents';
|
||
import { useSelectedEnv } from '../../../api/queries/alertMeta';
|
||
import type { FormState } from './form-state';
|
||
|
||
const SEVERITY_OPTIONS = [
|
||
{ value: 'CRITICAL', label: 'Critical' },
|
||
{ value: 'WARNING', label: 'Warning' },
|
||
{ value: 'INFO', label: 'Info' },
|
||
];
|
||
|
||
const SCOPE_OPTIONS = [
|
||
{ value: 'env', label: 'Environment-wide' },
|
||
{ value: 'app', label: 'Single app' },
|
||
{ value: 'route', label: 'Single route' },
|
||
{ value: 'agent', label: 'Single agent' },
|
||
];
|
||
|
||
export function ScopeStep({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
|
||
const env = useSelectedEnv();
|
||
const { data: catalog } = useCatalog(env);
|
||
const { data: agents } = useAgents();
|
||
|
||
const apps = (catalog ?? []).map((a: any) => ({ slug: a.slug, name: a.displayName ?? a.slug, routes: a.routes ?? [] }));
|
||
const selectedApp = apps.find((a) => a.slug === form.appSlug);
|
||
const routes = selectedApp?.routes ?? [];
|
||
const appAgents = (agents ?? []).filter((a: any) => a.applicationId === form.appSlug);
|
||
|
||
return (
|
||
<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 19–25 |
|
||
| §13 `<MustacheEditor />` with variable autocomplete | Tasks 10–12 |
|
||
| §13 Silences / History / Rules list / OutboundConnectionAdminPage | Tasks 16, 17, 18, 26 (Outbound page already exists from Plan 01) |
|
||
| §13 Real-time: bell polls every 30s; paused when tab hidden | Task 9 (`usePageVisible`) + query `refetchIntervalInBackground:false` |
|
||
| §13 Accessibility: keyboard nav, ARIA | CM6 autocomplete is ARIA-conformant; bell has aria-label |
|
||
| §13 Styling: `@cameleer/design-system` CSS variables | All files use `var(--error)` etc. (no hardcoded hex) |
|
||
| §9 Rule promotion across envs — pure UI prefill + warnings | Tasks 18 (entry), 24, 25 |
|
||
| §17 SSRF guard on outbound URL | Task 28 |
|
||
| Final-review NIT: 30s gauge caching | Task 29 |
|
||
| Regenerate OpenAPI schema | Tasks 3, 32 |
|
||
| Update `.claude/rules/` | Task 31 |
|
||
| Testing preference: REST-API-driven (not raw SQL); Playwright over real backend | Task 30 + backend IT extension in Task 28 |
|
||
|
||
No uncovered requirements from the spec sections relevant to Plan 03.
|
||
|
||
### 2. Placeholder scan
|
||
|
||
- No "TBD" / "implement later" / "similar to Task N" / "Add appropriate error handling" patterns remain.
|
||
- Every step with a code change includes the actual code.
|
||
- Step stubs in Task 13 are explicitly marked as "replaced in Phase 5/6/7" — they're real code, just thin.
|
||
- Some condition-forms (Task 21 step 4) reuse the same pattern; each form is shown in full rather than "similar to RouteMetricForm".
|
||
|
||
### 3. Type consistency
|
||
|
||
- `MustacheEditor` props are the same shape across all call sites: `{ value, onChange, kind?, reducedContext?, label, placeholder?, minHeight?, singleLine? }`.
|
||
- `FormState` is declared in `form-state.ts` and used identically by all wizard steps.
|
||
- Schema-derived types (`AlertDto`, `AlertRuleResponse`, etc.) come from `components['schemas'][...]` so they stay in sync with the backend.
|
||
- Query hook names follow a consistent convention: `use<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?**
|