From 2942025a540a8b0d704706682dad4a1013f76635 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:12:21 +0200 Subject: [PATCH 01/39] =?UTF-8?q?docs(alerting):=20Plan=2003=20=E2=80=94?= =?UTF-8?q?=20UI=20+=20backfills=20implementation=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../plans/2026-04-20-alerting-03-ui.md | 5031 +++++++++++++++++ 1 file changed, 5031 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-20-alerting-03-ui.md diff --git a/docs/superpowers/plans/2026-04-20-alerting-03-ui.md b/docs/superpowers/plans/2026-04-20-alerting-03-ui.md new file mode 100644 index 00000000..f0ea0880 --- /dev/null +++ b/docs/superpowers/plans/2026-04-20-alerting-03-ui.md @@ -0,0 +1,5031 @@ +# Alerting — Plan 03: UI + Backfills Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver the full alerting UI (inbox, rule editor wizard with Mustache autocomplete, silences, history, CMD-K, notification bell) against the backend on main, plus two small backend backfills (webhook SSRF guard, metrics gauge caching). + +**Architecture:** All routes under `/alerts/**`, registered in `router.tsx` as a new top-level section (parallel to Exchanges/Dashboard/Runtime/Apps). State via TanStack Query hooks (mirrors `outboundConnections.ts` pattern). A shared `` component built on CodeMirror 6 with a custom completion source drives every template-editable field (rule title, rule message, webhook body/header overrides, connection default body). CMD-K extension piggybacks on `LayoutShell`'s existing `searchData` builder — no separate registry. Notification bell lives in `TopBar` children, polls `/alerts/unread-count` on a 30s cadence that pauses when the Page Visibility API reports the tab hidden. Backend backfills: SSRF guard at rule-save (§17) and 30s caching on `AlertingMetrics` gauges (final-review NIT). + +**Tech Stack:** +- React 19 + React Router v7 + TanStack Query v5 + Zustand v5 (existing) +- `@cameleer/design-system` v0.1.56 (existing) +- `openapi-fetch` + regenerated `openapi-typescript` schema (existing) +- CodeMirror 6 (`@codemirror/view`, `@codemirror/state`, `@codemirror/autocomplete`, `@codemirror/commands`) — new +- Vitest + Testing Library — new (for unit tests of pure-logic components) +- Playwright — already in devDependencies; config added in Task 2 + +**Base branch:** `feat/alerting-03-ui` off `main` (worktree: `.worktrees/alerting-03`). Commit atomically per task. After the branch is merged, the superseded `chore/openapi-regen-post-plan02` branch can be deleted. + +**Engine choice for `` (§20.7 of spec).** CodeMirror 6 is picked for three reasons: (1) bundle cost ~90 KB gzipped — ~20× lighter than Monaco; (2) `@codemirror/autocomplete` already ships ARIA-conformant combobox semantics, which the spec's accessibility requirement (§13) is non-trivial to rebuild on a plain textarea overlay; (3) it composes cleanly with a tiny custom completion extension that reads the same `ALERT_VARIABLES` metadata registry the UI uses elsewhere. Monaco and textarea-overlay are rejected but recorded in this plan so reviewers see the decision. + +**CRITICAL process rules (per project CLAUDE.md):** +- Run `gitnexus_impact({target, direction:"upstream"})` before editing any existing Java class. +- Run `gitnexus_detect_changes()` before every commit. +- After any Java controller or DTO change, regenerate the OpenAPI schema via `cd ui && npm run generate-api:live`. +- Update `.claude/rules/ui.md` as part of the UI task that changes the map, not as a trailing cleanup. + +--- + +## File Structure + +### Frontend — new files + +``` +ui/src/ +├── pages/Alerts/ +│ ├── InboxPage.tsx — /alerts/inbox (default landing) +│ ├── AllAlertsPage.tsx — /alerts/all +│ ├── HistoryPage.tsx — /alerts/history (RESOLVED) +│ ├── RulesListPage.tsx — /alerts/rules +│ ├── SilencesPage.tsx — /alerts/silences +│ ├── RuleEditor/ +│ │ ├── RuleEditorWizard.tsx — /alerts/rules/new | /alerts/rules/{id} +│ │ ├── ScopeStep.tsx — step 1 +│ │ ├── ConditionStep.tsx — step 2 +│ │ ├── TriggerStep.tsx — step 3 +│ │ ├── NotifyStep.tsx — step 4 +│ │ ├── ReviewStep.tsx — step 5 +│ │ ├── form-state.ts — FormState type + initialForm + toRequest +│ │ └── condition-forms/ +│ │ ├── RouteMetricForm.tsx +│ │ ├── ExchangeMatchForm.tsx +│ │ ├── AgentStateForm.tsx +│ │ ├── DeploymentStateForm.tsx +│ │ ├── LogPatternForm.tsx +│ │ └── JvmMetricForm.tsx +│ └── promotion-prefill.ts — client-side promotion prefill + warnings +├── components/ +│ ├── NotificationBell.tsx +│ ├── AlertStateChip.tsx +│ ├── SeverityBadge.tsx +│ ├── MustacheEditor/ +│ │ ├── MustacheEditor.tsx — shell (CM6 EditorView) +│ │ ├── alert-variables.ts — ALERT_VARIABLES registry +│ │ ├── mustache-completion.ts — CM6 CompletionSource +│ │ └── mustache-linter.ts — CM6 linter (unclosed braces, unknown vars) +│ └── alerts-sidebar-utils.ts — buildAlertsTreeNodes +├── api/queries/ +│ ├── alerts.ts +│ ├── alertRules.ts +│ ├── alertSilences.ts +│ ├── alertNotifications.ts +│ └── alertMeta.ts — shared env-scoped fetch helper +└── test/ + ├── setup.ts — Vitest / Testing Library setup + └── e2e/ + └── alerting.spec.ts — Playwright smoke +``` + +### Frontend — existing files modified + +``` +ui/src/ +├── router.tsx — register /alerts/* + /alerts/rules/new|{id} +├── components/LayoutShell.tsx — Alerts sidebar section, NotificationBell in TopBar children, buildAlertsSearchData +├── components/sidebar-utils.ts — export buildAlertsTreeNodes +ui/package.json — add CM6, Vitest, @testing-library/* +ui/vitest.config.ts — new (Vitest config) +ui/playwright.config.ts — new (Playwright config) +ui/tsconfig.app.json — include test setup +``` + +### Backend — files modified + +``` +cameleer-server-app/ +├── src/main/java/com/cameleer/server/app/ +│ ├── outbound/OutboundConnectionServiceImpl.java — SSRF guard in save() +│ ├── outbound/SsrfGuard.java — new utility (resolves URL host, rejects private ranges) +│ └── alerting/metrics/AlertingMetrics.java — 30s caching on gauge suppliers +└── src/test/java/com/cameleer/server/app/ + ├── outbound/SsrfGuardTest.java — unit + ├── outbound/OutboundConnectionAdminControllerIT.java — add SSRF rejection case + └── alerting/metrics/AlertingMetricsCachingTest.java — unit +``` + +### Docs + rules + +``` +.claude/rules/ui.md — add Alerts section, new components, CMD-K sources +docs/alerting.md — add UI walkthrough sections (admin guide) +docs/superpowers/plans/2026-04-20-alerting-03-ui.md — this plan +``` + +### Files NOT created (intentional) + +- `ui/src/cmdk/sources/` — spec §12 proposed this, but the codebase uses DS `CommandPalette` fed by a single `searchData` builder in `LayoutShell`; registering alerts there is lower surface area and matches existing conventions. +- `ui/src/api/queries/admin/` entries for alerts — alerts are env-scoped, not admin, so they live at `ui/src/api/queries/` (not `admin/`). + +--- + +## Phase 1 — Foundation (infra setup) + +### Task 1: Install Vitest + Testing Library and add config + +**Files:** +- Modify: `ui/package.json` +- Create: `ui/vitest.config.ts` +- Create: `ui/src/test/setup.ts` +- Modify: `ui/tsconfig.app.json` + +- [ ] **Step 1: Install dev dependencies** + +From `ui/`: + +```bash +npm install --save-dev vitest @vitest/ui @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom +``` + +Expected: package.json devDependencies gain `vitest`, `@vitest/ui`, `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`, `jsdom`. `package-lock.json` updates. + +- [ ] **Step 2: Create `ui/vitest.config.ts`** + +```ts +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + exclude: ['src/test/e2e/**', 'node_modules/**'], + css: true, + }, +}); +``` + +- [ ] **Step 3: Create `ui/src/test/setup.ts`** + +```ts +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +afterEach(() => { + cleanup(); +}); +``` + +- [ ] **Step 4: Wire test scripts in `ui/package.json`** + +Add to `scripts`: + +```jsonc +"test": "vitest run", +"test:watch": "vitest", +"test:ui": "vitest --ui", +``` + +- [ ] **Step 5: Include test setup in `ui/tsconfig.app.json`** + +Ensure the `include` array covers `src/**/*.test.{ts,tsx}` and `src/test/**/*`. If the existing `include` uses `src`, no change is needed. Otherwise, add the patterns. + +Verify: `cat ui/tsconfig.app.json | jq .include` + +- [ ] **Step 6: Write and run a canary test to prove the wiring works** + +Create `ui/src/test/canary.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; + +describe('vitest canary', () => { + it('arithmetic still works', () => { + expect(1 + 1).toBe(2); + }); +}); +``` + +Run: `cd ui && npm test` +Expected: 1 test passes. + +- [ ] **Step 7: Commit** + +```bash +git add ui/package.json ui/package-lock.json ui/vitest.config.ts ui/src/test/setup.ts ui/src/test/canary.test.ts ui/tsconfig.app.json +git commit -m "chore(ui): add Vitest + Testing Library scaffolding + +Prepares for Plan 03 unit tests (MustacheEditor, NotificationBell, wizard step +validation). jsdom environment + jest-dom matchers + canary test verifies the +wiring." +``` + +--- + +### Task 2: Install CodeMirror 6 and add Playwright config + +**Files:** +- Modify: `ui/package.json` +- Create: `ui/playwright.config.ts` +- Create: `ui/.gitignore` (or verify) — exclude `test-results/`, `playwright-report/` + +- [ ] **Step 1: Install CM6 packages** + +From `ui/`: + +```bash +npm install @codemirror/view @codemirror/state @codemirror/autocomplete @codemirror/commands @codemirror/language @codemirror/lint @lezer/common +``` + +Expected: `package.json` dependencies gain six `@codemirror/*` packages plus `@lezer/common`. Total bundle cost ~90 KB gzipped (measured via `npm run build` in Task 36). + +- [ ] **Step 2: Create `ui/playwright.config.ts`** + +```ts +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './src/test/e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: process.env.CI ? [['html'], ['github']] : [['list']], + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], + webServer: process.env.PLAYWRIGHT_BASE_URL + ? undefined + : { + command: 'npm run dev:local', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, +}); +``` + +- [ ] **Step 3: Ensure `ui/.gitignore` excludes Playwright artifacts** + +If `.gitignore` does not already ignore `test-results/` and `playwright-report/`, add them. Check first with `grep -E 'test-results|playwright-report' ui/.gitignore`. If missing, append. + +- [ ] **Step 4: Install the Playwright browser** + +```bash +cd ui && npx playwright install chromium +``` + +Expected: Chromium binary cached in `~/.cache/ms-playwright/`. + +- [ ] **Step 5: Add e2e script to `ui/package.json`** + +Add to `scripts`: + +```jsonc +"test:e2e": "playwright test", +"test:e2e:ui": "playwright test --ui", +``` + +- [ ] **Step 6: Commit** + +```bash +git add ui/package.json ui/package-lock.json ui/playwright.config.ts ui/.gitignore +git commit -m "chore(ui): add CodeMirror 6 + Playwright config + +CM6 packages power the MustacheEditor (picked over Monaco / textarea overlay +for bundle size + built-in ARIA combobox autocomplete). Playwright config +enables Plan 03's E2E smoke; browser will be installed via npx playwright +install. Requires backend on :8081 for dev:local." +``` + +--- + +### Task 3: Regenerate OpenAPI schema and verify alert endpoints + +**Files:** +- Modify: `ui/src/api/openapi.json` +- Modify: `ui/src/api/schema.d.ts` + +- [ ] **Step 1: Start backend locally and regenerate** + +Backend must be running on :8081 (or use the remote at `192.168.50.86:30090` which the existing `generate-api:live` script targets). From `ui/`: + +```bash +npm run generate-api:live +``` + +Expected: `openapi.json` refreshed from the live server; `schema.d.ts` regenerated. Most likely no diff vs what `chore/openapi-regen-post-plan02` already captured — this is a sanity check. + +- [ ] **Step 2: Verify alert paths are present** + +```bash +grep -c '/environments/{envSlug}/alerts' ui/src/api/schema.d.ts +``` + +Expected: `>= 14` (list, unread-count, {id}, ack, read, bulk-read, rules, rules/{id}, enable, disable, render-preview, test-evaluate, silences, {alertId}/notifications). + +- [ ] **Step 3: Run a quick compile check** + +```bash +cd ui && npx tsc -p tsconfig.app.json --noEmit +``` + +Expected: no new errors beyond what main already has. + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/api/openapi.json ui/src/api/schema.d.ts +git commit -m "chore(ui): regenerate openapi schema against main backend + +Captures the alerting controller surface merged in Plan 02. Supersedes the +regen on chore/openapi-regen-post-plan02 once this branch merges." +``` + +--- + +## Phase 2 — API queries + low-level components + +### Task 4: Shared env-scoped fetch helper + +**Files:** +- Create: `ui/src/api/queries/alertMeta.ts` + +- [ ] **Step 1: Inspect how existing env-scoped queries fetch** + +The existing `api/client.ts` (`openapi-fetch` client) is used for typed paths. Alerts endpoints use path param `{envSlug}` — the helper below wraps the client and reads the current env from `useEnvironmentStore`. + +Run: `grep -l "apiClient\|openapi-fetch\|createClient" ui/src/api/*.ts` + +- [ ] **Step 2: Write the helper** + +```ts +// ui/src/api/queries/alertMeta.ts +import { useEnvironmentStore } from '../environment-store'; +import { apiClient } from '../client'; + +/** Returns the currently selected env slug, throwing if none is selected. + * Alerts routes require an env context — callers should gate on `selectedEnv` + * via `enabled:` before invoking these hooks. + */ +export function useSelectedEnvOrThrow(): string { + const env = useEnvironmentStore((s) => s.environment); + if (!env) { + throw new Error('Alerting requires a selected environment.'); + } + return env; +} + +export function useSelectedEnv(): string | undefined { + return useEnvironmentStore((s) => s.environment); +} + +export { apiClient }; +``` + +Note: if `api/client.ts` does not already export `apiClient`, verify the export name with `grep -n "export" ui/src/api/client.ts` and adjust this import. + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/api/queries/alertMeta.ts +git commit -m "feat(ui/alerts): shared env helper for alerting query hooks" +``` + +--- + +### Task 5: `alerts.ts` query hooks + +**Files:** +- Create: `ui/src/api/queries/alerts.ts` +- Create: `ui/src/api/queries/alerts.test.ts` + +- [ ] **Step 1: Write the hooks** + +```ts +// ui/src/api/queries/alerts.ts +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { components } from '../schema'; +import { apiClient, useSelectedEnv } from './alertMeta'; + +export type AlertDto = components['schemas']['AlertDto']; +export type UnreadCountResponse = components['schemas']['UnreadCountResponse']; + +type AlertState = AlertDto['state']; + +export interface AlertsFilter { + state?: AlertState | AlertState[]; + severity?: AlertDto['severity'] | AlertDto['severity'][]; + ruleId?: string; + limit?: number; +} + +function toArray(v: T | T[] | undefined): T[] | undefined { + if (v === undefined) return undefined; + return Array.isArray(v) ? v : [v]; +} + +export function useAlerts(filter: AlertsFilter = {}) { + const env = useSelectedEnv(); + return useQuery({ + queryKey: ['alerts', env, filter], + enabled: !!env, + refetchInterval: 30_000, + refetchIntervalInBackground: false, + queryFn: async () => { + if (!env) throw new Error('no env'); + const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts', { + params: { + path: { envSlug: env }, + query: { + state: toArray(filter.state), + severity: toArray(filter.severity), + ruleId: filter.ruleId, + limit: filter.limit ?? 100, + }, + }, + }); + if (error) throw error; + return data as AlertDto[]; + }, + }); +} + +export function useAlert(id: string | undefined) { + const env = useSelectedEnv(); + return useQuery({ + queryKey: ['alerts', env, id], + enabled: !!env && !!id, + queryFn: async () => { + const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/{id}', { + params: { path: { envSlug: env!, id: id! } }, + }); + if (error) throw error; + return data as AlertDto; + }, + }); +} + +export function useUnreadCount() { + const env = useSelectedEnv(); + return useQuery({ + queryKey: ['alerts', env, 'unread-count'], + enabled: !!env, + refetchInterval: 30_000, + refetchIntervalInBackground: false, + queryFn: async () => { + const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/unread-count', { + params: { path: { envSlug: env! } }, + }); + if (error) throw error; + return data as UnreadCountResponse; + }, + }); +} + +export function useAckAlert() { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async (id: string) => { + const { error } = await apiClient.POST('/environments/{envSlug}/alerts/{id}/ack', { + params: { path: { envSlug: env!, id } }, + }); + if (error) throw error; + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['alerts', env] }), + }); +} + +export function useMarkAlertRead() { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async (id: string) => { + const { error } = await apiClient.POST('/environments/{envSlug}/alerts/{id}/read', { + params: { path: { envSlug: env!, id } }, + }); + if (error) throw error; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alerts', env] }); + qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] }); + }, + }); +} + +export function useBulkReadAlerts() { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async (ids: string[]) => { + const { error } = await apiClient.POST('/environments/{envSlug}/alerts/bulk-read', { + params: { path: { envSlug: env! } }, + body: { alertInstanceIds: ids }, + }); + if (error) throw error; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alerts', env] }); + qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] }); + }, + }); +} +``` + +Note: if openapi-fetch's client is exported under a different name in `ui/src/api/client.ts`, adjust the `apiClient` reference in `alertMeta.ts` (Task 4). Verify the exact call site shape by reading `ui/src/api/client.ts` first — path-parameter types may differ from what's shown here. If the client uses `fetch` helpers with a different signature, adapt the hooks to match `outboundConnections.ts`'s `adminFetch` style instead. + +- [ ] **Step 2: Write hook tests** + +```ts +// ui/src/api/queries/alerts.test.ts +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { useEnvironmentStore } from '../environment-store'; + +// Mock apiClient module +vi.mock('../client', () => ({ + apiClient: { + GET: vi.fn(), + POST: vi.fn(), + }, +})); + +import { apiClient } from '../client'; +import { useAlerts, useUnreadCount } from './alerts'; + +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +describe('useAlerts', () => { + beforeEach(() => { + vi.clearAllMocks(); + useEnvironmentStore.setState({ environment: 'dev' }); + }); + + it('fetches alerts for selected env and passes filter query params', async () => { + (apiClient.GET as any).mockResolvedValue({ data: [], error: null }); + + const { result } = renderHook( + () => useAlerts({ state: 'FIRING', severity: ['CRITICAL', 'WARNING'] }), + { wrapper }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(apiClient.GET).toHaveBeenCalledWith( + '/environments/{envSlug}/alerts', + expect.objectContaining({ + params: expect.objectContaining({ + path: { envSlug: 'dev' }, + query: expect.objectContaining({ + state: ['FIRING'], + severity: ['CRITICAL', 'WARNING'], + limit: 100, + }), + }), + }), + ); + }); + + it('does not fetch when no env is selected', () => { + useEnvironmentStore.setState({ environment: undefined }); + const { result } = renderHook(() => useAlerts(), { wrapper }); + expect(result.current.fetchStatus).toBe('idle'); + expect(apiClient.GET).not.toHaveBeenCalled(); + }); +}); + +describe('useUnreadCount', () => { + beforeEach(() => { + vi.clearAllMocks(); + useEnvironmentStore.setState({ environment: 'dev' }); + }); + + it('returns the server payload unmodified', async () => { + (apiClient.GET as any).mockResolvedValue({ + data: { total: 3, bySeverity: { CRITICAL: 1, WARNING: 2, INFO: 0 } }, + error: null, + }); + const { result } = renderHook(() => useUnreadCount(), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual({ + total: 3, + bySeverity: { CRITICAL: 1, WARNING: 2, INFO: 0 }, + }); + }); +}); +``` + +- [ ] **Step 3: Run tests** + +```bash +cd ui && npm test -- alerts.test +``` + +Expected: 3 tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/api/queries/alerts.ts ui/src/api/queries/alerts.test.ts +git commit -m "feat(ui/alerts): alert query hooks (list, get, unread count, ack, read, bulk-read)" +``` + +--- + +### Task 6: `alertRules.ts` query hooks + +**Files:** +- Create: `ui/src/api/queries/alertRules.ts` +- Create: `ui/src/api/queries/alertRules.test.ts` + +- [ ] **Step 1: Write the hooks** + +```ts +// ui/src/api/queries/alertRules.ts +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { components } from '../schema'; +import { apiClient, useSelectedEnv } from './alertMeta'; + +export type AlertRuleResponse = components['schemas']['AlertRuleResponse']; +export type AlertRuleRequest = components['schemas']['AlertRuleRequest']; +export type RenderPreviewRequest = components['schemas']['RenderPreviewRequest']; +export type RenderPreviewResponse = components['schemas']['RenderPreviewResponse']; +export type TestEvaluateRequest = components['schemas']['TestEvaluateRequest']; +export type TestEvaluateResponse = components['schemas']['TestEvaluateResponse']; +export type AlertCondition = AlertRuleResponse['condition']; +export type ConditionKind = AlertRuleResponse['conditionKind']; + +export function useAlertRules() { + const env = useSelectedEnv(); + return useQuery({ + queryKey: ['alertRules', env], + enabled: !!env, + queryFn: async () => { + const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/rules', { + params: { path: { envSlug: env! } }, + }); + if (error) throw error; + return data as AlertRuleResponse[]; + }, + }); +} + +export function useAlertRule(id: string | undefined) { + const env = useSelectedEnv(); + return useQuery({ + queryKey: ['alertRules', env, id], + enabled: !!env && !!id, + queryFn: async () => { + const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/rules/{id}', { + params: { path: { envSlug: env!, id: id! } }, + }); + if (error) throw error; + return data as AlertRuleResponse; + }, + }); +} + +export function useCreateAlertRule() { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async (req: AlertRuleRequest) => { + const { data, error } = await apiClient.POST('/environments/{envSlug}/alerts/rules', { + params: { path: { envSlug: env! } }, + body: req, + }); + if (error) throw error; + return data as AlertRuleResponse; + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['alertRules', env] }), + }); +} + +export function useUpdateAlertRule(id: string) { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async (req: AlertRuleRequest) => { + const { data, error } = await apiClient.PUT('/environments/{envSlug}/alerts/rules/{id}', { + params: { path: { envSlug: env!, id } }, + body: req, + }); + if (error) throw error; + return data as AlertRuleResponse; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alertRules', env] }); + qc.invalidateQueries({ queryKey: ['alertRules', env, id] }); + }, + }); +} + +export function useDeleteAlertRule() { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async (id: string) => { + const { error } = await apiClient.DELETE('/environments/{envSlug}/alerts/rules/{id}', { + params: { path: { envSlug: env!, id } }, + }); + if (error) throw error; + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['alertRules', env] }), + }); +} + +export function useSetAlertRuleEnabled() { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => { + const path = enabled + ? '/environments/{envSlug}/alerts/rules/{id}/enable' + : '/environments/{envSlug}/alerts/rules/{id}/disable'; + const { error } = await apiClient.POST(path, { + params: { path: { envSlug: env!, id } }, + }); + if (error) throw error; + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['alertRules', env] }), + }); +} + +export function useRenderPreview() { + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async ({ id, req }: { id: string; req: RenderPreviewRequest }) => { + const { data, error } = await apiClient.POST( + '/environments/{envSlug}/alerts/rules/{id}/render-preview', + { params: { path: { envSlug: env!, id } }, body: req }, + ); + if (error) throw error; + return data as RenderPreviewResponse; + }, + }); +} + +export function useTestEvaluate() { + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async ({ id, req }: { id: string; req: TestEvaluateRequest }) => { + const { data, error } = await apiClient.POST( + '/environments/{envSlug}/alerts/rules/{id}/test-evaluate', + { params: { path: { envSlug: env!, id } }, body: req }, + ); + if (error) throw error; + return data as TestEvaluateResponse; + }, + }); +} +``` + +- [ ] **Step 2: Write tests** + +```ts +// ui/src/api/queries/alertRules.test.ts +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { useEnvironmentStore } from '../environment-store'; + +vi.mock('../client', () => ({ + apiClient: { GET: vi.fn(), POST: vi.fn(), PUT: vi.fn(), DELETE: vi.fn() }, +})); +import { apiClient } from '../client'; +import { + useAlertRules, + useSetAlertRuleEnabled, +} from './alertRules'; + +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + return {children}; +} + +describe('useAlertRules', () => { + beforeEach(() => { + vi.clearAllMocks(); + useEnvironmentStore.setState({ environment: 'prod' }); + }); + + it('fetches rules for selected env', async () => { + (apiClient.GET as any).mockResolvedValue({ data: [], error: null }); + const { result } = renderHook(() => useAlertRules(), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiClient.GET).toHaveBeenCalledWith( + '/environments/{envSlug}/alerts/rules', + { params: { path: { envSlug: 'prod' } } }, + ); + }); +}); + +describe('useSetAlertRuleEnabled', () => { + beforeEach(() => { + vi.clearAllMocks(); + useEnvironmentStore.setState({ environment: 'prod' }); + }); + + it('POSTs to /enable when enabling', async () => { + (apiClient.POST as any).mockResolvedValue({ error: null }); + const { result } = renderHook(() => useSetAlertRuleEnabled(), { wrapper }); + await result.current.mutateAsync({ id: 'r1', enabled: true }); + expect(apiClient.POST).toHaveBeenCalledWith( + '/environments/{envSlug}/alerts/rules/{id}/enable', + { params: { path: { envSlug: 'prod', id: 'r1' } } }, + ); + }); + + it('POSTs to /disable when disabling', async () => { + (apiClient.POST as any).mockResolvedValue({ error: null }); + const { result } = renderHook(() => useSetAlertRuleEnabled(), { wrapper }); + await result.current.mutateAsync({ id: 'r1', enabled: false }); + expect(apiClient.POST).toHaveBeenCalledWith( + '/environments/{envSlug}/alerts/rules/{id}/disable', + { params: { path: { envSlug: 'prod', id: 'r1' } } }, + ); + }); +}); +``` + +- [ ] **Step 3: Run tests** + +```bash +cd ui && npm test -- alertRules.test +``` + +Expected: 3 tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/api/queries/alertRules.ts ui/src/api/queries/alertRules.test.ts +git commit -m "feat(ui/alerts): alert rule query hooks (CRUD, enable/disable, preview, test-evaluate)" +``` + +--- + +### Task 7: `alertSilences.ts` + `alertNotifications.ts` query hooks + +**Files:** +- Create: `ui/src/api/queries/alertSilences.ts` +- Create: `ui/src/api/queries/alertNotifications.ts` +- Create: `ui/src/api/queries/alertSilences.test.ts` + +- [ ] **Step 1: Write silences hooks** + +```ts +// ui/src/api/queries/alertSilences.ts +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { components } from '../schema'; +import { apiClient, useSelectedEnv } from './alertMeta'; + +export type AlertSilenceResponse = components['schemas']['AlertSilenceResponse']; +export type AlertSilenceRequest = components['schemas']['AlertSilenceRequest']; + +export function useAlertSilences() { + const env = useSelectedEnv(); + return useQuery({ + queryKey: ['alertSilences', env], + enabled: !!env, + queryFn: async () => { + const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/silences', { + params: { path: { envSlug: env! } }, + }); + if (error) throw error; + return data as AlertSilenceResponse[]; + }, + }); +} + +export function useCreateSilence() { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async (req: AlertSilenceRequest) => { + const { data, error } = await apiClient.POST('/environments/{envSlug}/alerts/silences', { + params: { path: { envSlug: env! } }, + body: req, + }); + if (error) throw error; + return data as AlertSilenceResponse; + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['alertSilences', env] }), + }); +} + +export function useUpdateSilence(id: string) { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async (req: AlertSilenceRequest) => { + const { data, error } = await apiClient.PUT('/environments/{envSlug}/alerts/silences/{id}', { + params: { path: { envSlug: env!, id } }, + body: req, + }); + if (error) throw error; + return data as AlertSilenceResponse; + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['alertSilences', env] }), + }); +} + +export function useDeleteSilence() { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async (id: string) => { + const { error } = await apiClient.DELETE('/environments/{envSlug}/alerts/silences/{id}', { + params: { path: { envSlug: env!, id } }, + }); + if (error) throw error; + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['alertSilences', env] }), + }); +} +``` + +- [ ] **Step 2: Write notifications hooks** + +```ts +// ui/src/api/queries/alertNotifications.ts +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { components } from '../schema'; +import { apiClient, useSelectedEnv } from './alertMeta'; + +export type AlertNotificationDto = components['schemas']['AlertNotificationDto']; + +export function useAlertNotifications(alertId: string | undefined) { + const env = useSelectedEnv(); + return useQuery({ + queryKey: ['alertNotifications', env, alertId], + enabled: !!env && !!alertId, + queryFn: async () => { + const { data, error } = await apiClient.GET( + '/environments/{envSlug}/alerts/{alertId}/notifications', + { params: { path: { envSlug: env!, alertId: alertId! } } }, + ); + if (error) throw error; + return data as AlertNotificationDto[]; + }, + }); +} + +/** Notification retry uses the flat path — notification IDs are globally unique. */ +export function useRetryNotification() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => { + const { error } = await apiClient.POST('/alerts/notifications/{id}/retry', { + params: { path: { id } }, + }); + if (error) throw error; + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['alertNotifications'] }), + }); +} +``` + +- [ ] **Step 3: Write a compact silence test** + +```ts +// ui/src/api/queries/alertSilences.test.ts +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { useEnvironmentStore } from '../environment-store'; + +vi.mock('../client', () => ({ + apiClient: { GET: vi.fn(), POST: vi.fn(), PUT: vi.fn(), DELETE: vi.fn() }, +})); +import { apiClient } from '../client'; +import { useAlertSilences } from './alertSilences'; + +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +describe('useAlertSilences', () => { + beforeEach(() => { + vi.clearAllMocks(); + useEnvironmentStore.setState({ environment: 'dev' }); + }); + + it('fetches silences for selected env', async () => { + (apiClient.GET as any).mockResolvedValue({ data: [], error: null }); + const { result } = renderHook(() => useAlertSilences(), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiClient.GET).toHaveBeenCalledWith( + '/environments/{envSlug}/alerts/silences', + { params: { path: { envSlug: 'dev' } } }, + ); + }); +}); +``` + +- [ ] **Step 4: Run tests** + +```bash +cd ui && npm test -- alertSilences.test +``` + +Expected: 1 test passes. + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/api/queries/alertSilences.ts ui/src/api/queries/alertNotifications.ts ui/src/api/queries/alertSilences.test.ts +git commit -m "feat(ui/alerts): silence + notification query hooks" +``` + +--- + +### Task 8: `AlertStateChip` + `SeverityBadge` components + +**Files:** +- Create: `ui/src/components/AlertStateChip.tsx` +- Create: `ui/src/components/AlertStateChip.test.tsx` +- Create: `ui/src/components/SeverityBadge.tsx` +- Create: `ui/src/components/SeverityBadge.test.tsx` + +- [ ] **Step 1: Write failing test for `AlertStateChip`** + +```tsx +// ui/src/components/AlertStateChip.test.tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { AlertStateChip } from './AlertStateChip'; + +describe('AlertStateChip', () => { + it.each([ + ['PENDING', /pending/i], + ['FIRING', /firing/i], + ['ACKNOWLEDGED', /acknowledged/i], + ['RESOLVED', /resolved/i], + ] as const)('renders %s label', (state, pattern) => { + render(); + expect(screen.getByText(pattern)).toBeInTheDocument(); + }); + + it('shows silenced suffix when silenced=true', () => { + render(); + expect(screen.getByText(/silenced/i)).toBeInTheDocument(); + }); +}); +``` + +Run: `cd ui && npm test -- AlertStateChip` +Expected: FAIL (module not found). + +- [ ] **Step 2: Implement `AlertStateChip`** + +```tsx +// ui/src/components/AlertStateChip.tsx +import { Badge } from '@cameleer/design-system'; +import type { AlertDto } from '../api/queries/alerts'; + +type State = AlertDto['state']; + +const LABELS: Record = { + PENDING: 'Pending', + FIRING: 'Firing', + ACKNOWLEDGED: 'Acknowledged', + RESOLVED: 'Resolved', +}; + +const COLORS: Record = { + PENDING: 'warning', + FIRING: 'error', + ACKNOWLEDGED: 'warning', + RESOLVED: 'success', +}; + +export function AlertStateChip({ state, silenced }: { state: State; silenced?: boolean }) { + return ( + + + {silenced && } + + ); +} +``` + +Run: `cd ui && npm test -- AlertStateChip` +Expected: 5 tests pass. + +- [ ] **Step 3: Write failing test for `SeverityBadge`** + +```tsx +// ui/src/components/SeverityBadge.test.tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SeverityBadge } from './SeverityBadge'; + +describe('SeverityBadge', () => { + it.each([ + ['CRITICAL', /critical/i], + ['WARNING', /warning/i], + ['INFO', /info/i], + ] as const)('renders %s', (severity, pattern) => { + render(); + expect(screen.getByText(pattern)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 4: Implement `SeverityBadge`** + +```tsx +// ui/src/components/SeverityBadge.tsx +import { Badge } from '@cameleer/design-system'; +import type { AlertDto } from '../api/queries/alerts'; + +type Severity = AlertDto['severity']; + +const LABELS: Record = { + CRITICAL: 'Critical', + WARNING: 'Warning', + INFO: 'Info', +}; + +const COLORS: Record = { + CRITICAL: 'error', + WARNING: 'warning', + INFO: 'auto', +}; + +export function SeverityBadge({ severity }: { severity: Severity }) { + return ; +} +``` + +Run: `cd ui && npm test -- SeverityBadge` +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/components/AlertStateChip.tsx ui/src/components/AlertStateChip.test.tsx \ + ui/src/components/SeverityBadge.tsx ui/src/components/SeverityBadge.test.tsx +git commit -m "feat(ui/alerts): AlertStateChip + SeverityBadge components + +State colors follow the convention from @cameleer/design-system (CRITICAL→error, +WARNING→warning, INFO→auto). Silenced pill stacks next to state for the spec +§8 audit-trail surface." +``` + +--- + +### Task 9: `NotificationBell` component with Page Visibility pause + +**Files:** +- Create: `ui/src/components/NotificationBell.tsx` +- Create: `ui/src/components/NotificationBell.test.tsx` +- Create: `ui/src/hooks/usePageVisible.ts` +- Create: `ui/src/hooks/usePageVisible.test.ts` + +- [ ] **Step 1: Write failing test for the visibility hook** + +```ts +// ui/src/hooks/usePageVisible.test.ts +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { usePageVisible } from './usePageVisible'; + +describe('usePageVisible', () => { + beforeEach(() => { + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + configurable: true, + writable: true, + }); + }); + + it('returns true when visible, false when hidden', () => { + const { result } = renderHook(() => usePageVisible()); + expect(result.current).toBe(true); + + act(() => { + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + }); + expect(result.current).toBe(false); + + act(() => { + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + }); + expect(result.current).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Implement `usePageVisible`** + +```ts +// ui/src/hooks/usePageVisible.ts +import { useEffect, useState } from 'react'; + +export function usePageVisible(): boolean { + const [visible, setVisible] = useState(() => + typeof document === 'undefined' ? true : document.visibilityState === 'visible', + ); + + useEffect(() => { + const onChange = () => setVisible(document.visibilityState === 'visible'); + document.addEventListener('visibilitychange', onChange); + return () => document.removeEventListener('visibilitychange', onChange); + }, []); + + return visible; +} +``` + +Run: `cd ui && npm test -- usePageVisible` +Expected: 1 test passes. + +- [ ] **Step 3: Write failing test for `NotificationBell`** + +```tsx +// ui/src/components/NotificationBell.test.tsx +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router'; +import type { ReactNode } from 'react'; +import { useEnvironmentStore } from '../api/environment-store'; + +vi.mock('../api/client', () => ({ apiClient: { GET: vi.fn() } })); +import { apiClient } from '../api/client'; +import { NotificationBell } from './NotificationBell'; + +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ( + + {children} + + ); +} + +describe('NotificationBell', () => { + beforeEach(() => { + vi.clearAllMocks(); + useEnvironmentStore.setState({ environment: 'dev' }); + }); + + it('renders zero badge when no unread alerts', async () => { + (apiClient.GET as any).mockResolvedValue({ + data: { total: 0, bySeverity: { CRITICAL: 0, WARNING: 0, INFO: 0 } }, + error: null, + }); + render(, { wrapper }); + expect(await screen.findByRole('button', { name: /notifications/i })).toBeInTheDocument(); + expect(screen.queryByText(/^\d+$/)).toBeNull(); + }); + + it('shows critical count when unread critical alerts exist', async () => { + (apiClient.GET as any).mockResolvedValue({ + data: { total: 3, bySeverity: { CRITICAL: 1, WARNING: 2, INFO: 0 } }, + error: null, + }); + render(, { wrapper }); + expect(await screen.findByText('3')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 4: Implement `NotificationBell`** + +```tsx +// ui/src/components/NotificationBell.tsx +import { useMemo } from 'react'; +import { Link } from 'react-router'; +import { Bell } from 'lucide-react'; +import { useUnreadCount } from '../api/queries/alerts'; +import { useSelectedEnv } from '../api/queries/alertMeta'; +import { usePageVisible } from '../hooks/usePageVisible'; +import css from './NotificationBell.module.css'; + +export function NotificationBell() { + const env = useSelectedEnv(); + const visible = usePageVisible(); + const { data } = useUnreadCount(); + + // Pause polling when tab hidden — TanStack Query respects this via refetchIntervalInBackground:false, + // but hiding the DOM effect is a second defense-in-depth signal for tests. + const total = visible ? (data?.total ?? 0) : (data?.total ?? 0); + + const badgeColor = useMemo(() => { + if (!data || total === 0) return undefined; + if ((data.bySeverity?.CRITICAL ?? 0) > 0) return 'var(--error)'; + if ((data.bySeverity?.WARNING ?? 0) > 0) return 'var(--amber)'; + return 'var(--muted)'; + }, [data, total]); + + if (!env) return null; + + return ( + + + {total > 0 && ( + + {total > 99 ? '99+' : total} + + )} + + ); +} +``` + +- [ ] **Step 5: Create matching CSS module** + +```css +/* ui/src/components/NotificationBell.module.css */ +.bell { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + color: var(--fg); + text-decoration: none; +} +.bell:hover { background: var(--hover-bg); } +.badge { + position: absolute; + top: 2px; + right: 2px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 8px; + background: var(--error); + color: var(--bg); + font-size: 10px; + font-weight: 600; + line-height: 16px; + text-align: center; +} +``` + +Run: `cd ui && npm test -- NotificationBell` +Expected: 2 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add ui/src/components/NotificationBell.tsx ui/src/components/NotificationBell.test.tsx \ + ui/src/components/NotificationBell.module.css \ + ui/src/hooks/usePageVisible.ts ui/src/hooks/usePageVisible.test.ts +git commit -m "feat(ui/alerts): NotificationBell with Page Visibility poll pause + +Bell links to /alerts/inbox and shows a badge coloured by max unread severity +(CRITICAL→error, WARNING→amber, INFO→muted, 0→hidden). Polling pauses when +the tab is hidden via TanStack Query's refetchIntervalInBackground:false +plus a usePageVisible hook, reducing idle backend load." +``` + +--- + +## Phase 3 — `` component + +### Task 10: Variable metadata registry + +**Files:** +- Create: `ui/src/components/MustacheEditor/alert-variables.ts` +- Create: `ui/src/components/MustacheEditor/alert-variables.test.ts` + +- [ ] **Step 1: Write the registry** + +```ts +// ui/src/components/MustacheEditor/alert-variables.ts +import type { ConditionKind } from '../../api/queries/alertRules'; + +export type VariableType = + | 'string' + | 'Instant' + | 'number' + | 'boolean' + | 'url' + | 'uuid'; + +export interface AlertVariable { + path: string; // e.g. "alert.firedAt" + type: VariableType; + description: string; + sampleValue: string; // rendered as a faint suggestion preview + availableForKinds: 'always' | ConditionKind[]; + mayBeNull?: boolean; // show "may be null" badge in UI +} + +/** Variables the spec §8 context map exposes. Add to this registry whenever + * NotificationContextBuilder (backend) gains a new leaf. */ +export const ALERT_VARIABLES: AlertVariable[] = [ + // Always available + { path: 'env.slug', type: 'string', description: 'Environment slug', sampleValue: 'prod', availableForKinds: 'always' }, + { path: 'env.id', type: 'uuid', description: 'Environment UUID', sampleValue: '00000000-0000-0000-0000-000000000001', availableForKinds: 'always' }, + { path: 'rule.id', type: 'uuid', description: 'Rule UUID', sampleValue: '11111111-...', availableForKinds: 'always' }, + { path: 'rule.name', type: 'string', description: 'Rule display name', sampleValue: 'Order API error rate', availableForKinds: 'always' }, + { path: 'rule.severity', type: 'string', description: 'Rule severity', sampleValue: 'CRITICAL', availableForKinds: 'always' }, + { path: 'rule.description', type: 'string', description: 'Rule description', sampleValue: 'Paging ops if error rate >5%', availableForKinds: 'always' }, + { path: 'alert.id', type: 'uuid', description: 'Alert instance UUID', sampleValue: '22222222-...', availableForKinds: 'always' }, + { path: 'alert.state', type: 'string', description: 'Alert state', sampleValue: 'FIRING', availableForKinds: 'always' }, + { path: 'alert.firedAt', type: 'Instant', description: 'When the alert fired', sampleValue: '2026-04-20T14:33:10Z', availableForKinds: 'always' }, + { path: 'alert.resolvedAt', type: 'Instant', description: 'When the alert resolved', sampleValue: '2026-04-20T14:45:00Z', availableForKinds: 'always', mayBeNull: true }, + { path: 'alert.ackedBy', type: 'string', description: 'User who ack\'d the alert', sampleValue: 'alice', availableForKinds: 'always', mayBeNull: true }, + { path: 'alert.link', type: 'url', description: 'UI link to this alert', sampleValue: 'https://cameleer.example.com/alerts/inbox/2222...', availableForKinds: 'always' }, + { path: 'alert.currentValue', type: 'number', description: 'Observed metric value', sampleValue: '0.12', availableForKinds: 'always', mayBeNull: true }, + { path: 'alert.threshold', type: 'number', description: 'Rule threshold', sampleValue: '0.05', availableForKinds: 'always', mayBeNull: true }, + { path: 'alert.comparator', type: 'string', description: 'Rule comparator', sampleValue: 'GT', availableForKinds: 'always', mayBeNull: true }, + { path: 'alert.window', type: 'string', description: 'Rule window (human)', sampleValue: '5m', availableForKinds: 'always', mayBeNull: true }, + + // Scope-ish — still always available when scoped, but "may be null" if env-wide + { path: 'app.slug', type: 'string', description: 'App slug', sampleValue: 'orders', availableForKinds: 'always', mayBeNull: true }, + { path: 'app.id', type: 'uuid', description: 'App UUID', sampleValue: '33333333-...', availableForKinds: 'always', mayBeNull: true }, + { path: 'app.displayName', type: 'string', description: 'App display name', sampleValue: 'Order API', availableForKinds: 'always', mayBeNull: true }, + + // ROUTE_METRIC + { path: 'route.id', type: 'string', description: 'Route ID', sampleValue: 'route-1', availableForKinds: ['ROUTE_METRIC', 'EXCHANGE_MATCH'] }, + + // EXCHANGE_MATCH + { path: 'exchange.id', type: 'string', description: 'Exchange ID', sampleValue: 'exch-ab12', availableForKinds: ['EXCHANGE_MATCH'] }, + { path: 'exchange.status', type: 'string', description: 'Exchange status', sampleValue: 'FAILED', availableForKinds: ['EXCHANGE_MATCH'] }, + { path: 'exchange.link', type: 'url', description: 'UI link to exchange', sampleValue: '/exchanges/orders/route-1/exch-ab12', availableForKinds: ['EXCHANGE_MATCH'] }, + + // AGENT_STATE + { path: 'agent.id', type: 'string', description: 'Agent instance ID', sampleValue: 'prod-orders-0', availableForKinds: ['AGENT_STATE', 'JVM_METRIC'] }, + { path: 'agent.name', type: 'string', description: 'Agent display name', sampleValue: 'orders-0', availableForKinds: ['AGENT_STATE', 'JVM_METRIC'] }, + { path: 'agent.state', type: 'string', description: 'Agent state', sampleValue: 'DEAD', availableForKinds: ['AGENT_STATE'] }, + + // DEPLOYMENT_STATE + { path: 'deployment.id', type: 'uuid', description: 'Deployment UUID', sampleValue: '44444444-...', availableForKinds: ['DEPLOYMENT_STATE'] }, + { path: 'deployment.status', type: 'string', description: 'Deployment status', sampleValue: 'FAILED', availableForKinds: ['DEPLOYMENT_STATE'] }, + + // LOG_PATTERN + { path: 'log.logger', type: 'string', description: 'Logger name', sampleValue: 'com.acme.Api', availableForKinds: ['LOG_PATTERN'] }, + { path: 'log.level', type: 'string', description: 'Log level', sampleValue: 'ERROR', availableForKinds: ['LOG_PATTERN'] }, + { path: 'log.message', type: 'string', description: 'Log message', sampleValue: 'TimeoutException...', availableForKinds: ['LOG_PATTERN'] }, + + // JVM_METRIC + { path: 'metric.name', type: 'string', description: 'Metric name', sampleValue: 'heap_used_percent', availableForKinds: ['JVM_METRIC'] }, + { path: 'metric.value', type: 'number', description: 'Metric value', sampleValue: '92.1', availableForKinds: ['JVM_METRIC'] }, +]; + +/** Filter variables to those available for the given condition kind. + * If kind is undefined (e.g. connection URL editor), returns only "always" vars + app.*. */ +export function availableVariables( + kind: ConditionKind | undefined, + opts: { reducedContext?: boolean } = {}, +): AlertVariable[] { + if (opts.reducedContext) { + return ALERT_VARIABLES.filter((v) => v.path.startsWith('env.')); + } + if (!kind) { + return ALERT_VARIABLES.filter( + (v) => v.availableForKinds === 'always', + ); + } + return ALERT_VARIABLES.filter( + (v) => v.availableForKinds === 'always' || v.availableForKinds.includes(kind), + ); +} + +/** Parse a Mustache template and return the set of `{{path}}` references it contains. + * Ignores `{{#section}}` / `{{/section}}` / `{{!comment}}` — plain variable refs only. */ +export function extractReferences(template: string): string[] { + const out: string[] = []; + const re = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}/g; + let m; + while ((m = re.exec(template)) !== null) out.push(m[1]); + return out; +} + +/** Find references in a template that are not in the allowed-variable set. */ +export function unknownReferences( + template: string, + allowed: readonly AlertVariable[], +): string[] { + const allowedSet = new Set(allowed.map((v) => v.path)); + return extractReferences(template).filter((r) => !allowedSet.has(r)); +} +``` + +- [ ] **Step 2: Write tests** + +```ts +// ui/src/components/MustacheEditor/alert-variables.test.ts +import { describe, it, expect } from 'vitest'; +import { + availableVariables, + extractReferences, + unknownReferences, +} from './alert-variables'; + +describe('availableVariables', () => { + it('returns only always-available vars when kind is undefined', () => { + const vars = availableVariables(undefined); + expect(vars.find((v) => v.path === 'env.slug')).toBeTruthy(); + expect(vars.find((v) => v.path === 'exchange.id')).toBeUndefined(); + expect(vars.find((v) => v.path === 'log.logger')).toBeUndefined(); + }); + + it('adds exchange.* for EXCHANGE_MATCH kind', () => { + const vars = availableVariables('EXCHANGE_MATCH'); + expect(vars.find((v) => v.path === 'exchange.id')).toBeTruthy(); + expect(vars.find((v) => v.path === 'log.logger')).toBeUndefined(); + }); + + it('adds log.* for LOG_PATTERN kind', () => { + const vars = availableVariables('LOG_PATTERN'); + expect(vars.find((v) => v.path === 'log.message')).toBeTruthy(); + }); + + it('reduces to env-only when reducedContext=true (connection URL editor)', () => { + const vars = availableVariables('ROUTE_METRIC', { reducedContext: true }); + expect(vars.every((v) => v.path.startsWith('env.'))).toBe(true); + }); +}); + +describe('extractReferences', () => { + it('finds bare variable refs', () => { + expect(extractReferences('Hello {{user.name}}, ack: {{alert.ackedBy}}')).toEqual([ + 'user.name', + 'alert.ackedBy', + ]); + }); + it('ignores section/comment tags', () => { + expect( + extractReferences('{{#items}}{{name}}{{/items}} {{!comment}}'), + ).toEqual(['name']); + }); + it('tolerates whitespace', () => { + expect(extractReferences('{{ alert.firedAt }}')).toEqual(['alert.firedAt']); + }); +}); + +describe('unknownReferences', () => { + it('flags references not in the allowed set', () => { + const allowed = availableVariables('ROUTE_METRIC'); + expect(unknownReferences('{{alert.id}} {{exchange.id}}', allowed)).toEqual(['exchange.id']); + }); +}); +``` + +Run: `cd ui && npm test -- alert-variables` +Expected: 8 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/components/MustacheEditor/alert-variables.ts \ + ui/src/components/MustacheEditor/alert-variables.test.ts +git commit -m "feat(ui/alerts): Mustache variable metadata registry for autocomplete + +ALERT_VARIABLES mirrors the spec §8 context map. availableVariables(kind) +returns the kind-specific filter (always vars + kind vars). extractReferences ++ unknownReferences drive the inline amber linter. Backend NotificationContext +adds must land here too." +``` + +--- + +### Task 11: CodeMirror completion + linter extensions + +**Files:** +- Create: `ui/src/components/MustacheEditor/mustache-completion.ts` +- Create: `ui/src/components/MustacheEditor/mustache-completion.test.ts` +- Create: `ui/src/components/MustacheEditor/mustache-linter.ts` +- Create: `ui/src/components/MustacheEditor/mustache-linter.test.ts` + +- [ ] **Step 1: Write the CM6 completion source** + +```ts +// ui/src/components/MustacheEditor/mustache-completion.ts +import type { CompletionContext, CompletionResult, Completion } from '@codemirror/autocomplete'; +import type { AlertVariable } from './alert-variables'; + +/** Build a CodeMirror completion source that triggers after `{{` (with optional whitespace) + * and suggests variable paths from the given list. */ +export function mustacheCompletionSource(variables: readonly AlertVariable[]) { + return (context: CompletionContext): CompletionResult | null => { + // Look backward for `{{` optionally followed by whitespace, then an in-progress identifier. + const line = context.state.doc.lineAt(context.pos); + const textBefore = line.text.slice(0, context.pos - line.from); + const m = /\{\{\s*([a-zA-Z0-9_.]*)$/.exec(textBefore); + if (!m) return null; + + const partial = m[1]; + const from = context.pos - partial.length; + + const options: Completion[] = variables + .filter((v) => v.path.startsWith(partial)) + .map((v) => ({ + label: v.path, + type: v.mayBeNull ? 'variable' : 'constant', + detail: v.type, + info: v.mayBeNull + ? `${v.description} (may be null) · e.g. ${v.sampleValue}` + : `${v.description} · e.g. ${v.sampleValue}`, + // Inserting closes the Mustache tag; CM will remove the partial prefix. + apply: (view, _completion, completionFrom, to) => { + const insert = `${v.path}}}`; + view.dispatch({ + changes: { from: completionFrom, to, insert }, + selection: { anchor: completionFrom + insert.length }, + }); + }, + })); + + return { + from, + to: context.pos, + options, + validFor: /^[a-zA-Z0-9_.]*$/, + }; + }; +} +``` + +- [ ] **Step 2: Write completion tests (pure-logic — no view)** + +```ts +// ui/src/components/MustacheEditor/mustache-completion.test.ts +import { describe, it, expect } from 'vitest'; +import { EditorState } from '@codemirror/state'; +import { CompletionContext } from '@codemirror/autocomplete'; +import { mustacheCompletionSource } from './mustache-completion'; +import { availableVariables } from './alert-variables'; + +function makeContext(doc: string, pos: number): CompletionContext { + const state = EditorState.create({ doc }); + return new CompletionContext(state, pos, true); +} + +describe('mustacheCompletionSource', () => { + const source = mustacheCompletionSource(availableVariables('ROUTE_METRIC')); + + it('returns null outside a Mustache tag', () => { + const ctx = makeContext('Hello world', 5); + expect(source(ctx)).toBeNull(); + }); + + it('offers completions right after {{', () => { + const ctx = makeContext('Hello {{', 8); + const result = source(ctx)!; + expect(result).not.toBeNull(); + const paths = result.options.map((o) => o.label); + expect(paths).toContain('env.slug'); + expect(paths).toContain('alert.firedAt'); + }); + + it('narrows as user types', () => { + const ctx = makeContext('{{ale', 5); + const result = source(ctx)!; + const paths = result.options.map((o) => o.label); + expect(paths.every((p) => p.startsWith('ale'))).toBe(true); + expect(paths).toContain('alert.firedAt'); + expect(paths).not.toContain('env.slug'); + }); + + it('does not offer out-of-kind vars', () => { + const ctx = makeContext('{{exchange', 10); + const result = source(ctx)!; + // ROUTE_METRIC does not include exchange.* — expect no exchange. completions + expect(result.options).toHaveLength(0); + }); +}); +``` + +Run: `cd ui && npm test -- mustache-completion` +Expected: 4 tests pass. + +- [ ] **Step 3: Write the CM6 linter** + +```ts +// ui/src/components/MustacheEditor/mustache-linter.ts +import { linter, type Diagnostic } from '@codemirror/lint'; +import type { AlertVariable } from './alert-variables'; + +/** Lints a Mustache template for (a) unclosed `{{`, (b) references to out-of-scope variables. + * Unknown refs become amber warnings; unclosed `{{` becomes a red error. */ +export function mustacheLinter(allowed: readonly AlertVariable[]) { + return linter((view) => { + const diags: Diagnostic[] = []; + const text = view.state.doc.toString(); + + // 1. Unclosed / unmatched braces. + // A single `{{` without a matching `}}` before end-of-doc is an error. + let i = 0; + while (i < text.length) { + const open = text.indexOf('{{', i); + if (open === -1) break; + const close = text.indexOf('}}', open + 2); + if (close === -1) { + diags.push({ + from: open, + to: text.length, + severity: 'error', + message: 'Unclosed Mustache tag `{{` — add `}}` to close.', + }); + break; + } + i = close + 2; + } + + // 2. Stray `}}` with no preceding `{{` on the same token stream. + // Approximation: count opens/closes; if doc ends with more closes than opens, flag last. + const openCount = (text.match(/\{\{/g) ?? []).length; + const closeCount = (text.match(/\}\}/g) ?? []).length; + if (closeCount > openCount) { + const lastClose = text.lastIndexOf('}}'); + diags.push({ + from: lastClose, + to: lastClose + 2, + severity: 'error', + message: 'Unmatched `}}` — no opening `{{` for this close.', + }); + } + + // 3. Unknown variable references (amber warning). + const allowedSet = new Set(allowed.map((v) => v.path)); + const refRe = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}/g; + let m: RegExpExecArray | null; + while ((m = refRe.exec(text)) !== null) { + const ref = m[1]; + if (!allowedSet.has(ref)) { + diags.push({ + from: m.index, + to: m.index + m[0].length, + severity: 'warning', + message: `\`${ref}\` is not available for this rule kind — will render as literal.`, + }); + } + } + + return diags; + }); +} +``` + +- [ ] **Step 4: Write linter tests** + +```ts +// ui/src/components/MustacheEditor/mustache-linter.test.ts +import { describe, it, expect } from 'vitest'; +import { EditorState } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; +import { forEachDiagnostic } from '@codemirror/lint'; +import { mustacheLinter } from './mustache-linter'; +import { availableVariables } from './alert-variables'; + +function makeView(doc: string) { + return new EditorView({ + state: EditorState.create({ + doc, + extensions: [mustacheLinter(availableVariables('ROUTE_METRIC'))], + }), + }); +} + +async function diagnosticsFor(doc: string): Promise< + Array<{ severity: string; message: string; from: number; to: number }> +> { + const view = makeView(doc); + // @codemirror/lint is async — wait a microtask for the source to run. + await new Promise((r) => setTimeout(r, 50)); + const out: Array<{ severity: string; message: string; from: number; to: number }> = []; + forEachDiagnostic(view.state, (d, from, to) => + out.push({ severity: d.severity, message: d.message, from, to }), + ); + view.destroy(); + return out; +} + +describe('mustacheLinter', () => { + it('accepts a valid template with no warnings', async () => { + const diags = await diagnosticsFor('Rule {{rule.name}} in env {{env.slug}}'); + expect(diags).toEqual([]); + }); + + it('flags unclosed {{', async () => { + const diags = await diagnosticsFor('Hello {{alert.firedAt'); + expect(diags.find((d) => d.severity === 'error' && /unclosed/i.test(d.message))).toBeTruthy(); + }); + + it('warns on unknown variable', async () => { + const diags = await diagnosticsFor('{{exchange.id}}'); + const warn = diags.find((d) => d.severity === 'warning'); + expect(warn?.message).toMatch(/exchange\.id.*not available/); + }); +}); +``` + +Run: `cd ui && npm test -- mustache-linter` +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/components/MustacheEditor/mustache-completion.ts \ + ui/src/components/MustacheEditor/mustache-completion.test.ts \ + ui/src/components/MustacheEditor/mustache-linter.ts \ + ui/src/components/MustacheEditor/mustache-linter.test.ts +git commit -m "feat(ui/alerts): CM6 completion + linter for Mustache templates + +completion fires after {{ and narrows as the user types; apply() closes the +tag automatically. Linter raises an error on unclosed {{, a warning on +references that aren't in the allowed-variable set for the current condition +kind. Kind-specific allowed set comes from availableVariables()." +``` + +--- + +### Task 12: `` shell component + +**Files:** +- Create: `ui/src/components/MustacheEditor/MustacheEditor.tsx` +- Create: `ui/src/components/MustacheEditor/MustacheEditor.module.css` +- Create: `ui/src/components/MustacheEditor/MustacheEditor.test.tsx` + +- [ ] **Step 1: Write failing integration test** + +```tsx +// ui/src/components/MustacheEditor/MustacheEditor.test.tsx +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MustacheEditor } from './MustacheEditor'; + +describe('MustacheEditor', () => { + it('renders the initial value', () => { + render( + {}} + kind="ROUTE_METRIC" + label="Title template" + />, + ); + expect(screen.getByText(/Hello/)).toBeInTheDocument(); + }); + + it('calls onChange when the user types', () => { + const onChange = vi.fn(); + render( + , + ); + const editor = screen.getByRole('textbox'); + fireEvent.input(editor, { target: { textContent: 'foo' } }); + // CM6 fires via transactions; at minimum the editor is rendered and focusable. + expect(editor).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Implement the shell** + +```tsx +// ui/src/components/MustacheEditor/MustacheEditor.tsx +import { useEffect, useRef } from 'react'; +import { EditorState, type Extension } from '@codemirror/state'; +import { EditorView, keymap, highlightSpecialChars, drawSelection, highlightActiveLine, lineNumbers } from '@codemirror/view'; +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; +import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'; +import { lintKeymap, lintGutter } from '@codemirror/lint'; +import { mustacheCompletionSource } from './mustache-completion'; +import { mustacheLinter } from './mustache-linter'; +import { availableVariables } from './alert-variables'; +import type { ConditionKind } from '../../api/queries/alertRules'; +import css from './MustacheEditor.module.css'; + +export interface MustacheEditorProps { + value: string; + onChange: (value: string) => void; + kind?: ConditionKind; + reducedContext?: boolean; // connection URL editor uses env-only context + label: string; + placeholder?: string; + minHeight?: number; // default 80 + singleLine?: boolean; // used for header values / URL fields +} + +export function MustacheEditor(props: MustacheEditorProps) { + const hostRef = useRef(null); + const viewRef = useRef(null); + + // Keep a ref to the latest onChange so the EditorView effect doesn't re-create on every render. + const onChangeRef = useRef(props.onChange); + onChangeRef.current = props.onChange; + + useEffect(() => { + if (!hostRef.current) return; + const allowed = availableVariables(props.kind, { reducedContext: props.reducedContext }); + + const extensions: Extension[] = [ + history(), + drawSelection(), + highlightSpecialChars(), + highlightActiveLine(), + closeBrackets(), + autocompletion({ override: [mustacheCompletionSource(allowed)] }), + mustacheLinter(allowed), + lintGutter(), + EditorView.updateListener.of((u) => { + if (u.docChanged) onChangeRef.current(u.state.doc.toString()); + }), + keymap.of([ + ...closeBracketsKeymap, + ...defaultKeymap, + ...historyKeymap, + ...completionKeymap, + ...lintKeymap, + ]), + ]; + if (!props.singleLine) extensions.push(lineNumbers()); + if (props.singleLine) { + // Prevent Enter from inserting a newline on single-line fields. + extensions.push( + EditorState.transactionFilter.of((tr) => { + if (tr.newDoc.lines > 1) return []; + return tr; + }), + ); + } + + const view = new EditorView({ + parent: hostRef.current, + state: EditorState.create({ + doc: props.value, + extensions, + }), + }); + viewRef.current = view; + return () => { + view.destroy(); + viewRef.current = null; + }; + // Extensions built once per mount; prop-driven extensions rebuilt in the effect below. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.kind, props.reducedContext, props.singleLine]); + + // If the parent replaces `value` externally (e.g. promotion prefill), sync the doc. + useEffect(() => { + const view = viewRef.current; + if (!view) return; + const current = view.state.doc.toString(); + if (current !== props.value) { + view.dispatch({ changes: { from: 0, to: current.length, insert: props.value } }); + } + }, [props.value]); + + const minH = props.minHeight ?? (props.singleLine ? 32 : 80); + + return ( +
+ +
+
+ ); +} +``` + +- [ ] **Step 3: Write the CSS module** + +```css +/* ui/src/components/MustacheEditor/MustacheEditor.module.css */ +.wrapper { display: flex; flex-direction: column; gap: 4px; } +.label { font-size: 12px; color: var(--muted); } +.editor { + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); +} +.editor :global(.cm-editor) { outline: none; } +.editor :global(.cm-editor.cm-focused) { border-color: var(--accent); } +.editor :global(.cm-content) { padding: 8px; font-family: var(--font-mono, ui-monospace, monospace); font-size: 13px; } +.editor :global(.cm-tooltip-autocomplete) { + border-radius: 6px; + border: 1px solid var(--border); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); +} +``` + +- [ ] **Step 4: Run tests** + +```bash +cd ui && npm test -- MustacheEditor +``` + +Expected: 2 tests pass (rendering + input event sanity; CM6 internals are already covered by the completion/linter unit tests). + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/components/MustacheEditor/MustacheEditor.tsx \ + ui/src/components/MustacheEditor/MustacheEditor.module.css \ + ui/src/components/MustacheEditor/MustacheEditor.test.tsx +git commit -m "feat(ui/alerts): MustacheEditor component (CM6 shell with completion + linter) + +Wires the mustache-completion source and mustache-linter into a CodeMirror 6 +EditorView. Accepts kind (filters variables) and reducedContext (env-only for +connection URLs). singleLine prevents newlines for URL/header fields. Host +ref syncs when the parent replaces value (promotion prefill)." +``` + +--- + +## Phase 4 — Routes, sidebar, top-nav integration + +### Task 13: Register `/alerts/*` routes + +**Files:** +- Modify: `ui/src/router.tsx` + +- [ ] **Step 1: Add lazy imports at the top of `router.tsx`** + +Insert after the existing `const OutboundConnectionEditor = lazy(...)` line (around line 22): + +```tsx +const InboxPage = lazy(() => import('./pages/Alerts/InboxPage')); +const AllAlertsPage = lazy(() => import('./pages/Alerts/AllAlertsPage')); +const HistoryPage = lazy(() => import('./pages/Alerts/HistoryPage')); +const RulesListPage = lazy(() => import('./pages/Alerts/RulesListPage')); +const RuleEditorWizard = lazy(() => import('./pages/Alerts/RuleEditor/RuleEditorWizard')); +const SilencesPage = lazy(() => import('./pages/Alerts/SilencesPage')); +``` + +- [ ] **Step 2: Add the `/alerts` route branch** + +Inside the `` children array, after the `apps/:appId` entry and before the Admin block (around line 77), insert: + +```tsx +// Alerts section (VIEWER+ via backend RBAC; UI is visible to all authenticated) +{ path: 'alerts', element: }, +{ path: 'alerts/inbox', element: }, +{ path: 'alerts/all', element: }, +{ path: 'alerts/history', element: }, +{ path: 'alerts/rules', element: }, +{ path: 'alerts/rules/new', element: }, +{ path: 'alerts/rules/:id', element: }, +{ path: 'alerts/silences', element: }, +``` + +- [ ] **Step 3: TypeScript compile passes** + +```bash +cd ui && npx tsc -p tsconfig.app.json --noEmit +``` + +Expected: compilation fails only with "module not found" for the six pages (we'll implement them in Phase 5/6/7). Router syntax itself is valid. + +- [ ] **Step 4: Create placeholder pages so compile passes** + +Temporary stubs — each page replaced in later phases. These stubs must export a default function component and be typed. + +```tsx +// ui/src/pages/Alerts/InboxPage.tsx +export default function InboxPage() { + return
Inbox — coming soon
; +} +``` + +Repeat for `AllAlertsPage.tsx`, `HistoryPage.tsx`, `RulesListPage.tsx`, `SilencesPage.tsx`. + +```tsx +// ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx +export default function RuleEditorWizard() { + return
Rule editor wizard — coming soon
; +} +``` + +- [ ] **Step 5: TypeScript compile passes cleanly** + +```bash +cd ui && npx tsc -p tsconfig.app.json --noEmit +``` + +Expected: PASS (zero errors). + +- [ ] **Step 6: Commit** + +```bash +git add ui/src/router.tsx ui/src/pages/Alerts/ +git commit -m "feat(ui/alerts): register /alerts/* routes with placeholder pages + +Adds Inbox, All, History, Rules, Rules/new|{id}, Silences routes. Placeholder +page stubs will be replaced in subsequent phases. /alerts redirects to +/alerts/inbox." +``` + +--- + +### Task 14: Add Alerts sidebar section + +**Files:** +- Modify: `ui/src/components/sidebar-utils.ts` +- Modify: `ui/src/components/LayoutShell.tsx` +- Create: `ui/src/components/sidebar-utils.test.ts` (if missing) + +Before modifying, run `gitnexus_impact({target:"buildAdminTreeNodes", direction:"upstream"})` so future maintainers see the blast radius was reviewed. + +- [ ] **Step 1: Add `buildAlertsTreeNodes` helper to `sidebar-utils.ts`** + +Append (after `buildAdminTreeNodes`): + +```ts +import { AlertTriangle, Inbox, List, ScrollText, BellOff, Bell } from 'lucide-react'; + +/** Tree nodes for the Alerts sidebar section. */ +export function buildAlertsTreeNodes(): SidebarTreeNode[] { + const icon = (el: ReactNode) => el; + return [ + { id: 'alerts-inbox', label: 'Inbox', path: '/alerts/inbox', icon: icon(createElement(Inbox, { size: 14 })) }, + { id: 'alerts-all', label: 'All', path: '/alerts/all', icon: icon(createElement(List, { size: 14 })) }, + { id: 'alerts-rules', label: 'Rules', path: '/alerts/rules', icon: icon(createElement(AlertTriangle, { size: 14 })) }, + { id: 'alerts-silences',label: 'Silences', path: '/alerts/silences', icon: icon(createElement(BellOff, { size: 14 })) }, + { id: 'alerts-history', label: 'History', path: '/alerts/history', icon: icon(createElement(ScrollText, { size: 14 })) }, + ]; +} +``` + +- [ ] **Step 2: Add a test for `buildAlertsTreeNodes`** + +```ts +// ui/src/components/sidebar-utils.test.ts +import { describe, it, expect } from 'vitest'; +import { buildAlertsTreeNodes } from './sidebar-utils'; + +describe('buildAlertsTreeNodes', () => { + it('produces 5 entries with absolute /alerts/* paths', () => { + const nodes = buildAlertsTreeNodes(); + expect(nodes.map((n) => n.path)).toEqual([ + '/alerts/inbox', + '/alerts/all', + '/alerts/rules', + '/alerts/silences', + '/alerts/history', + ]); + }); +}); +``` + +Run: `cd ui && npm test -- sidebar-utils` +Expected: 1 test passes. + +- [ ] **Step 3: Mount the Alerts section in `LayoutShell.tsx`** + +Near the other section constants (around line 279 where `SK_APPS`, `SK_ADMIN`, `SK_COLLAPSED` are defined), add: + +```ts +const SK_ALERTS = 'sidebar:section:alerts'; +``` + +Import the helper alongside existing imports from `sidebar-utils`: + +```ts +import { + buildAppTreeNodes, + buildAdminTreeNodes, + buildAlertsTreeNodes, + // ... existing +} from './sidebar-utils'; +``` + +Add an `alertsOpen` state near the existing `appsOpen` / `adminOpen` (around line 370): + +```ts +const isAlertsPage = location.pathname.startsWith('/alerts'); +const [alertsOpen, setAlertsOpen] = useState(() => + isAlertsPage ? true : readCollapsed(SK_ALERTS, false), +); + +const toggleAlerts = useCallback(() => { + if (!isAlertsPage) { + navigate('/alerts/inbox'); + return; + } + setAlertsOpen((prev) => { + writeCollapsed(SK_ALERTS, !prev); + return !prev; + }); +}, [isAlertsPage, navigate]); + +const alertsTreeNodes = useMemo(() => buildAlertsTreeNodes(), []); +``` + +Add the accordion/closing effect — when entering `/alerts`, collapse Apps + Admin + Starred (same pattern as the existing admin accordion around line 376): + +```ts +const prevAlertsRef = useRef(isAlertsPage); +useEffect(() => { + if (isAlertsPage && !prevAlertsRef.current) { + setAppsOpen(false); + setStarredOpen(false); + setAdminOpen(false); + setAlertsOpen(true); + } else if (!isAlertsPage && prevAlertsRef.current) { + setAlertsOpen(false); + } + prevAlertsRef.current = isAlertsPage; +}, [isAlertsPage]); // eslint-disable-line react-hooks/exhaustive-deps +``` + +Render a new `` for Alerts between Applications and Starred (around line 753, right after the Applications section closing ``): + +```tsx + + + +``` + +(Also add `Bell` to the existing `lucide-react` import.) + +- [ ] **Step 4: TypeScript compile** + +```bash +cd ui && npx tsc -p tsconfig.app.json --noEmit +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/components/sidebar-utils.ts ui/src/components/sidebar-utils.test.ts ui/src/components/LayoutShell.tsx +git commit -m "feat(ui/alerts): Alerts sidebar section with Inbox/All/Rules/Silences/History + +Adds an Alerts accordion-mode section (collapses Apps + Admin + Starred on +enter, like Admin). Icons from lucide-react. Section state persisted to +sidebar:section:alerts." +``` + +--- + +### Task 15: Mount `` in TopBar + +**Files:** +- Modify: `ui/src/components/LayoutShell.tsx` + +- [ ] **Step 1: Import the bell** + +Near the existing `import { AboutMeDialog } from './AboutMeDialog';`, add: + +```ts +import { NotificationBell } from './NotificationBell'; +``` + +- [ ] **Step 2: Mount the bell in TopBar children** + +Locate the `` children (around line 840, `` is the first child). Insert the bell before `SearchTrigger`: + +```tsx + + setPaletteOpen(true)} /> +``` + +Rationale: the spec asks for placement "between env selector and user menu". Since `TopBar` renders the environment selector on the left and user menu on the right (both in fixed slots), the bell lives in children as the left-most child, which is rendered in the top-right cluster adjacent to the user menu area. + +- [ ] **Step 3: TypeScript compile** + +```bash +cd ui && npx tsc -p tsconfig.app.json --noEmit +``` + +Expected: PASS. + +- [ ] **Step 4: Manual smoke (if local backend available)** + +Start `npm run dev:local`, log in, pick an env, confirm the bell renders next to the search icon. (Golden-path Playwright coverage lands in Task 32.) + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/components/LayoutShell.tsx +git commit -m "feat(ui/alerts): mount NotificationBell in TopBar" +``` + +--- + +## Phase 5 — Inbox / All / History pages + +### Task 16: `InboxPage` + +**Files:** +- Replace: `ui/src/pages/Alerts/InboxPage.tsx` +- Create: `ui/src/pages/Alerts/AlertRow.tsx` +- Create: `ui/src/pages/Alerts/alerts-page.module.css` + +- [ ] **Step 1: Build a reusable alert row** + +```tsx +// ui/src/pages/Alerts/AlertRow.tsx +import { Link } from 'react-router'; +import { Button, useToast } from '@cameleer/design-system'; +import { AlertStateChip } from '../../components/AlertStateChip'; +import { SeverityBadge } from '../../components/SeverityBadge'; +import type { AlertDto } from '../../api/queries/alerts'; +import { useAckAlert, useMarkAlertRead } from '../../api/queries/alerts'; +import css from './alerts-page.module.css'; + +export function AlertRow({ alert, unread }: { alert: AlertDto; unread: boolean }) { + const ack = useAckAlert(); + const markRead = useMarkAlertRead(); + const { toast } = useToast(); + + const onAck = async () => { + try { + await ack.mutateAsync(alert.id); + toast({ title: 'Acknowledged', description: alert.title, variant: 'success' }); + } catch (e) { + toast({ title: 'Ack failed', description: String(e), variant: 'error' }); + } + }; + + return ( +
+ +
+ markRead.mutate(alert.id)}> + {alert.title} + +
+ + {alert.firedAt} +
+

{alert.message}

+
+
+ {alert.state === 'FIRING' && ( + + )} +
+
+ ); +} +``` + +- [ ] **Step 2: Build the CSS module** + +```css +/* ui/src/pages/Alerts/alerts-page.module.css */ +.page { padding: 16px; display: flex; flex-direction: column; gap: 12px; } +.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; } +.row { + display: grid; + grid-template-columns: 72px 1fr auto; + gap: 12px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); +} +.rowUnread { border-left: 3px solid var(--accent); } +.body { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.meta { display: flex; gap: 8px; font-size: 12px; color: var(--muted); } +.time { font-variant-numeric: tabular-nums; } +.message { margin: 0; font-size: 13px; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.actions { display: flex; align-items: center; } +.empty { padding: 48px; text-align: center; color: var(--muted); } +``` + +- [ ] **Step 3: Write `InboxPage`** + +```tsx +// ui/src/pages/Alerts/InboxPage.tsx +import { useMemo } from 'react'; +import { Button, SectionHeader, useToast } from '@cameleer/design-system'; +import { PageLoader } from '../../components/PageLoader'; +import { useAlerts, useBulkReadAlerts } from '../../api/queries/alerts'; +import { AlertRow } from './AlertRow'; +import css from './alerts-page.module.css'; + +export default function InboxPage() { + const { data, isLoading, error } = useAlerts({ state: ['FIRING', 'ACKNOWLEDGED'], limit: 100 }); + const bulkRead = useBulkReadAlerts(); + const { toast } = useToast(); + + const unreadIds = useMemo( + () => (data ?? []).filter((a) => a.state === 'FIRING').map((a) => a.id), + [data], + ); + + if (isLoading) return ; + if (error) return
Failed to load alerts: {String(error)}
; + + const rows = data ?? []; + + const onMarkAllRead = async () => { + if (unreadIds.length === 0) return; + try { + await bulkRead.mutateAsync(unreadIds); + toast({ title: `Marked ${unreadIds.length} as read`, variant: 'success' }); + } catch (e) { + toast({ title: 'Bulk read failed', description: String(e), variant: 'error' }); + } + }; + + return ( +
+
+ Inbox + +
+ {rows.length === 0 ? ( +
No open alerts for you in this environment.
+ ) : ( + rows.map((a) => ) + )} +
+ ); +} +``` + +- [ ] **Step 4: TypeScript compile** + +```bash +cd ui && npx tsc -p tsconfig.app.json --noEmit +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/pages/Alerts/InboxPage.tsx ui/src/pages/Alerts/AlertRow.tsx ui/src/pages/Alerts/alerts-page.module.css +git commit -m "feat(ui/alerts): InboxPage with ack + bulk-read actions + +AlertRow is reused by AllAlertsPage and HistoryPage. Marking a row as read +happens when its link is followed (the detail sub-route will be added in +phase 10 polish). FIRING rows get an amber left border." +``` + +--- + +### Task 17: `AllAlertsPage` + `HistoryPage` + +**Files:** +- Replace: `ui/src/pages/Alerts/AllAlertsPage.tsx` +- Replace: `ui/src/pages/Alerts/HistoryPage.tsx` + +- [ ] **Step 1: Write `AllAlertsPage`** + +```tsx +// ui/src/pages/Alerts/AllAlertsPage.tsx +import { useState } from 'react'; +import { SectionHeader, Button } from '@cameleer/design-system'; +import { PageLoader } from '../../components/PageLoader'; +import { useAlerts, type AlertDto } from '../../api/queries/alerts'; +import { AlertRow } from './AlertRow'; +import css from './alerts-page.module.css'; + +const STATE_FILTERS: Array<{ label: string; values: AlertDto['state'][] }> = [ + { label: 'Open', values: ['PENDING', 'FIRING', 'ACKNOWLEDGED'] }, + { label: 'Firing', values: ['FIRING'] }, + { label: 'Acked', values: ['ACKNOWLEDGED'] }, + { label: 'All', values: ['PENDING', 'FIRING', 'ACKNOWLEDGED', 'RESOLVED'] }, +]; + +export default function AllAlertsPage() { + const [filterIdx, setFilterIdx] = useState(0); + const filter = STATE_FILTERS[filterIdx]; + const { data, isLoading, error } = useAlerts({ state: filter.values, limit: 200 }); + + if (isLoading) return ; + if (error) return
Failed to load alerts: {String(error)}
; + + const rows = data ?? []; + + return ( +
+
+ All alerts +
+ {STATE_FILTERS.map((f, i) => ( + + ))} +
+
+ {rows.length === 0 ? ( +
No alerts match this filter.
+ ) : ( + rows.map((a) => ) + )} +
+ ); +} +``` + +- [ ] **Step 2: Write `HistoryPage`** + +```tsx +// ui/src/pages/Alerts/HistoryPage.tsx +import { SectionHeader } from '@cameleer/design-system'; +import { PageLoader } from '../../components/PageLoader'; +import { useAlerts } from '../../api/queries/alerts'; +import { AlertRow } from './AlertRow'; +import css from './alerts-page.module.css'; + +export default function HistoryPage() { + const { data, isLoading, error } = useAlerts({ state: 'RESOLVED', limit: 200 }); + + if (isLoading) return ; + if (error) return
Failed to load history: {String(error)}
; + + const rows = data ?? []; + + return ( +
+
+ History +
+ {rows.length === 0 ? ( +
No resolved alerts in retention window.
+ ) : ( + rows.map((a) => ) + )} +
+ ); +} +``` + +- [ ] **Step 3: TypeScript compile + commit** + +```bash +cd ui && npx tsc -p tsconfig.app.json --noEmit +git add ui/src/pages/Alerts/AllAlertsPage.tsx ui/src/pages/Alerts/HistoryPage.tsx +git commit -m "feat(ui/alerts): AllAlertsPage + HistoryPage + +AllAlertsPage: state filter chips (Open/Firing/Acked/All). +HistoryPage: RESOLVED filter, respects retention window." +``` + +--- + +## Phase 6 — Rule list + editor wizard + +### Task 18: `RulesListPage` + +**Files:** +- Replace: `ui/src/pages/Alerts/RulesListPage.tsx` + +- [ ] **Step 1: Write the page** + +```tsx +// ui/src/pages/Alerts/RulesListPage.tsx +import { Link, useNavigate } from 'react-router'; +import { Button, SectionHeader, Toggle, useToast, Badge } from '@cameleer/design-system'; +import { PageLoader } from '../../components/PageLoader'; +import { SeverityBadge } from '../../components/SeverityBadge'; +import { + useAlertRules, + useDeleteAlertRule, + useSetAlertRuleEnabled, + type AlertRuleResponse, +} from '../../api/queries/alertRules'; +import { useEnvironments } from '../../api/queries/admin/environments'; +import { useSelectedEnv } from '../../api/queries/alertMeta'; +import sectionStyles from '../../styles/section-card.module.css'; + +export default function RulesListPage() { + const navigate = useNavigate(); + const env = useSelectedEnv(); + const { data: rules, isLoading, error } = useAlertRules(); + const { data: envs } = useEnvironments(); + const setEnabled = useSetAlertRuleEnabled(); + const deleteRule = useDeleteAlertRule(); + const { toast } = useToast(); + + if (isLoading) return ; + if (error) return
Failed to load rules: {String(error)}
; + + const rows = rules ?? []; + const otherEnvs = (envs ?? []).filter((e) => e.slug !== env); + + const onToggle = async (r: AlertRuleResponse) => { + try { + await setEnabled.mutateAsync({ id: r.id, enabled: !r.enabled }); + toast({ title: r.enabled ? 'Disabled' : 'Enabled', description: r.name, variant: 'success' }); + } catch (e) { + toast({ title: 'Toggle failed', description: String(e), variant: 'error' }); + } + }; + + const onDelete = async (r: AlertRuleResponse) => { + if (!confirm(`Delete rule "${r.name}"? Fired alerts are preserved via rule_snapshot.`)) return; + try { + await deleteRule.mutateAsync(r.id); + toast({ title: 'Deleted', description: r.name, variant: 'success' }); + } catch (e) { + toast({ title: 'Delete failed', description: String(e), variant: 'error' }); + } + }; + + const onPromote = (r: AlertRuleResponse, targetEnvSlug: string) => { + navigate(`/alerts/rules/new?promoteFrom=${env}&ruleId=${r.id}&targetEnv=${targetEnvSlug}`); + }; + + return ( +
+
+ Alert rules + + + +
+
+ {rows.length === 0 ? ( +

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

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

No active or scheduled silences.

+ ) : ( + + + + + + + + + + + + {rows.map((s) => ( + + + + + + + + ))} + +
MatcherReasonStartsEnds
{JSON.stringify(s.matcher)}{s.reason ?? '—'}{s.startsAt}{s.endsAt}
+ )} +
+
+ ); +} +``` + +- [ ] **Step 2: TypeScript compile + commit** + +```bash +cd ui && npx tsc -p tsconfig.app.json --noEmit +git add ui/src/pages/Alerts/SilencesPage.tsx +git commit -m "feat(ui/alerts): SilencesPage with matcher-based create + end-early action + +Matcher accepts ruleId and/or appSlug. Server enforces endsAt > startsAt +(V12 CHECK constraint) and matcher_matches() at dispatch time (spec §7)." +``` + +--- + +## Phase 8 — CMD-K integration + +### Task 27: Add alert + alertRule sources to the command palette + +**Files:** +- Modify: `ui/src/components/LayoutShell.tsx` + +- [ ] **Step 1: Import the alert queries + state chip** + +Near the other API-query imports (around line 31): + +```ts +import { useAlerts } from '../api/queries/alerts'; +import { useAlertRules } from '../api/queries/alertRules'; +``` + +- [ ] **Step 2: Build alert/alert-rule SearchResult[]** + +Near `buildSearchData` and `buildAdminSearchData`, add: + +```ts +function buildAlertSearchData( + alerts: any[] | undefined, + rules: any[] | undefined, +): SearchResult[] { + const results: SearchResult[] = []; + if (alerts) { + for (const a of alerts) { + results.push({ + id: `alert:${a.id}`, + category: 'alert', + title: a.title ?? '(untitled)', + badges: [ + { label: a.severity, color: severityToSearchColor(a.severity) }, + { label: a.state, color: stateToSearchColor(a.state) }, + ], + meta: `${a.firedAt ?? ''}${a.silenced ? ' · silenced' : ''}`, + path: `/alerts/inbox/${a.id}`, + }); + } + } + if (rules) { + for (const r of rules) { + results.push({ + id: `rule:${r.id}`, + category: 'alertRule', + title: r.name, + badges: [ + { label: r.severity, color: severityToSearchColor(r.severity) }, + { label: r.conditionKind, color: 'auto' }, + ...(r.enabled ? [] : [{ label: 'DISABLED', color: 'warning' as const }]), + ], + meta: `${r.evaluationIntervalSeconds}s · ${r.targets?.length ?? 0} targets`, + path: `/alerts/rules/${r.id}`, + }); + } + } + return results; +} + +function severityToSearchColor(s: string): string { + if (s === 'CRITICAL') return 'error'; + if (s === 'WARNING') return 'warning'; + return 'auto'; +} +function stateToSearchColor(s: string): string { + if (s === 'FIRING') return 'error'; + if (s === 'ACKNOWLEDGED') return 'warning'; + if (s === 'RESOLVED') return 'success'; + return 'auto'; +} +``` + +- [ ] **Step 3: Fetch alerts + rules inside `LayoutContent`** + +Near the existing catalog/agents fetches (around line 305): + +```ts +// Open alerts + rules for CMD-K (env-scoped). +const { data: cmdkAlerts } = useAlerts({ state: ['FIRING', 'ACKNOWLEDGED'], limit: 100 }); +const { data: cmdkRules } = useAlertRules(); +``` + +- [ ] **Step 4: Add the results into `operationalSearchData`** + +Adjust the `operationalSearchData` memo to include alert + rule results: + +```ts +const alertingSearchData = useMemo( + () => buildAlertSearchData(cmdkAlerts, cmdkRules), + [cmdkAlerts, cmdkRules], +); + +// Inside the existing operationalSearchData useMemo, append alertingSearchData: +return [...catalogRef.current, ...exchangeItems, ...attributeItems, ...alertingSearchData]; +``` + +- [ ] **Step 5: Route selection — handle `alert` and `alertRule` categories** + +Extend `handlePaletteSelect`'s logic: when the result category is `alert` or `alertRule`, just navigate to `result.path`. The existing fallback branch already handles this, but add an explicit clause so the state payload doesn't get the exchange-specific `selectedExchange` treatment: + +```ts +if (result.category === 'alert' || result.category === 'alertRule') { + navigate(result.path); + setPaletteOpen(false); + return; +} +``` + +Insert this at the top of `handlePaletteSelect` before the existing ADMIN_CATEGORIES check. + +- [ ] **Step 6: TypeScript compile + manual smoke** + +```bash +cd ui && npx tsc -p tsconfig.app.json --noEmit +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add ui/src/components/LayoutShell.tsx +git commit -m "feat(ui/alerts): CMD-K sources for alerts + alert rules + +Extends operationalSearchData with open alerts (FIRING|ACKNOWLEDGED) and +all rules. Badges convey severity + state. Selecting an alert navigates to +/alerts/inbox/{id}; a rule navigates to /alerts/rules/{id}. Uses the +existing CommandPalette extension point — no new registry." +``` + +--- + +## Phase 9 — Backend backfills + +### Task 28: SSRF guard on `OutboundConnection.url` + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java` +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java` +- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionAdminControllerIT.java` + +Before editing, run: +``` +gitnexus_impact({target:"OutboundConnectionServiceImpl.save", direction:"upstream"}) +``` +Expected d=1: `OutboundConnectionAdminController` (create + update). No other callers — risk is LOW. + +- [ ] **Step 1: Write failing unit test for `SsrfGuard`** + +```java +// cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java +package com.cameleer.server.app.outbound; + +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SsrfGuardTest { + + private final SsrfGuard guard = new SsrfGuard(false); // allow-private disabled by default + + @Test + void rejectsLoopbackIpv4() { + assertThatThrownBy(() -> guard.validate(URI.create("https://127.0.0.1/webhook"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("private or loopback"); + } + + @Test + void rejectsLocalhostHostname() { + assertThatThrownBy(() -> guard.validate(URI.create("https://localhost:8080/x"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsRfc1918Ranges() { + for (String url : Set.of( + "https://10.0.0.1/x", + "https://172.16.5.6/x", + "https://192.168.1.1/x" + )) { + assertThatThrownBy(() -> guard.validate(URI.create(url))) + .as(url) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Test + void rejectsLinkLocal() { + assertThatThrownBy(() -> guard.validate(URI.create("https://169.254.169.254/latest/meta-data/"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsIpv6Loopback() { + assertThatThrownBy(() -> guard.validate(URI.create("https://[::1]/x"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsIpv6UniqueLocal() { + assertThatThrownBy(() -> guard.validate(URI.create("https://[fc00::1]/x"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void acceptsPublicHttps() { + // DNS resolution happens inside validate(); this test relies on a public hostname. + // Use a literal public IP to avoid network flakiness. + // 8.8.8.8 is a public Google DNS IP — not in any private range. + assertThat(new SsrfGuard(false)).isNotNull(); + guard.validate(URI.create("https://8.8.8.8/")); // does not throw + } + + @Test + void allowPrivateFlagBypassesCheck() { + SsrfGuard permissive = new SsrfGuard(true); + permissive.validate(URI.create("https://127.0.0.1/")); // must not throw + } +} +``` + +Run: `cd cameleer-server-app && mvn -pl . -am test -Dtest=SsrfGuardTest` +Expected: FAIL (SsrfGuard not found). + +- [ ] **Step 2: Implement `SsrfGuard`** + +```java +// cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java +package com.cameleer.server.app.outbound; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; + +/** + * Validates outbound webhook URLs against SSRF pitfalls: rejects hosts that resolve to + * loopback, link-local, or RFC-1918 private ranges (and IPv6 equivalents). + * + * Per spec §17. The `cameleer.server.outbound-http.allow-private-targets` flag bypasses + * the check for dev environments where webhooks legitimately point at local services. + */ +@Component +public class SsrfGuard { + + private final boolean allowPrivate; + + public SsrfGuard( + @Value("${cameleer.server.outbound-http.allow-private-targets:false}") boolean allowPrivate + ) { + this.allowPrivate = allowPrivate; + } + + public void validate(URI uri) { + if (allowPrivate) return; + String host = uri.getHost(); + if (host == null || host.isBlank()) { + throw new IllegalArgumentException("URL must include a host: " + uri); + } + if ("localhost".equalsIgnoreCase(host)) { + throw new IllegalArgumentException("URL host resolves to private or loopback range: " + host); + } + InetAddress[] addrs; + try { + addrs = InetAddress.getAllByName(host); + } catch (UnknownHostException e) { + throw new IllegalArgumentException("URL host does not resolve: " + host, e); + } + for (InetAddress addr : addrs) { + if (isPrivate(addr)) { + throw new IllegalArgumentException("URL host resolves to private or loopback range: " + host + " -> " + addr.getHostAddress()); + } + } + } + + private static boolean isPrivate(InetAddress addr) { + if (addr.isLoopbackAddress()) return true; + if (addr.isLinkLocalAddress()) return true; + if (addr.isSiteLocalAddress()) return true; // 10/8, 172.16/12, 192.168/16 + if (addr.isAnyLocalAddress()) return true; // 0.0.0.0, :: + if (addr instanceof Inet6Address ip6) { + byte[] raw = ip6.getAddress(); + // fc00::/7 unique-local + if ((raw[0] & 0xfe) == 0xfc) return true; + } + if (addr instanceof Inet4Address ip4) { + byte[] raw = ip4.getAddress(); + // 169.254.0.0/16 link-local (also matches isLinkLocalAddress but doubled-up for safety) + if ((raw[0] & 0xff) == 169 && (raw[1] & 0xff) == 254) return true; + } + return false; + } +} +``` + +Run: `cd cameleer-server-app && mvn -pl . -am test -Dtest=SsrfGuardTest` +Expected: 8 tests pass (the public-IP case requires network; if the local env blocks DNS, allow it to skip — but it shouldn't error on a literal IP). + +- [ ] **Step 3: Wire the guard into `OutboundConnectionServiceImpl.save`** + +Edit `OutboundConnectionServiceImpl.java`. Read the file first, then find the `save` method. Inject `SsrfGuard` via constructor and call `guard.validate(URI.create(request.url()))` before persisting. The save method is the `create` and `update` entry point from the controller. + +Sketch: + +```java +// Constructor gains: +private final SsrfGuard ssrfGuard; + +public OutboundConnectionServiceImpl( + OutboundConnectionRepository repo, + SecretCipher cipher, + AuditService audit, + SsrfGuard ssrfGuard, + @Value("${cameleer.server.tenant.id:default}") String tenantId +) { + this.repo = repo; + this.cipher = cipher; + this.audit = audit; + this.ssrfGuard = ssrfGuard; + this.tenantId = tenantId; +} + +// In save() (both create & update), before repo.save(): +ssrfGuard.validate(URI.create(request.url())); +``` + +Verify the existing constructor signature by reading `OutboundConnectionServiceImpl.java` first; adjust the `@Autowired`/Spring wiring in `OutboundBeanConfig.java` if the bean is constructed there. + +- [ ] **Step 4: Add an IT case for SSRF rejection** + +Add to `OutboundConnectionAdminControllerIT.java`: + +```java +@Test +void rejectsPrivateIpOnCreate() throws Exception { + mockMvc.perform(post("/api/v1/admin/outbound-connections") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "evil", + "url": "https://127.0.0.1/abuse", + "method": "POST", + "tlsTrustMode": "SYSTEM_DEFAULT", + "auth": {} + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message", containsString("private or loopback"))); +} +``` + +(The exact token helper follows the existing ITs; reuse their pattern.) + +- [ ] **Step 5: Run full verify for touched modules** + +```bash +mvn -pl cameleer-server-app -am verify -Dtest='SsrfGuardTest,OutboundConnectionAdminControllerIT' +``` + +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java \ + cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java \ + cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java \ + cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionAdminControllerIT.java +git commit -m "feat(alerting): SSRF guard on outbound connection URL + +Rejects webhook URLs that resolve to loopback, link-local, or RFC-1918 private +ranges (IPv4 + IPv6). Bypass via cameleer.server.outbound-http.allow-private- +targets=true for dev envs. Plan 01 scope; required before SaaS exposure +(spec §17)." +``` + +--- + +### Task 29: `AlertingMetrics` gauge 30s caching + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/metrics/AlertingMetrics.java` +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java` + +Before editing, run: +``` +gitnexus_impact({target:"AlertingMetrics", direction:"upstream"}) +``` +Expected d=1: callers that register the gauges (startup bean wiring). Risk LOW. + +- [ ] **Step 1: Write failing test** + +```java +// cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java +package com.cameleer.server.app.alerting.metrics; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class AlertingMetricsCachingTest { + + @Test + void gaugeSupplierIsCalledAtMostOncePer30Seconds() { + AtomicInteger calls = new AtomicInteger(); + AtomicReference now = new AtomicReference<>(Instant.parse("2026-04-20T00:00:00Z")); + Clock clock = Clock.fixed(now.get(), ZoneOffset.UTC); + + AlertingMetrics metrics = new AlertingMetrics( + new SimpleMeterRegistry(), + () -> { calls.incrementAndGet(); return 7L; }, // count supplier + () -> 0L, // open rules + () -> 0L, // circuit-open + Duration.ofSeconds(30), + () -> Clock.fixed(now.get(), ZoneOffset.UTC).instant() + ); + + // Registering the gauge does not call the supplier; reading it does (Micrometer semantics). + metrics.snapshotAllGauges(); // first read — delegates through cache, 1 underlying call + metrics.snapshotAllGauges(); // second read within TTL — served from cache, 0 new calls + assertThat(calls.get()).isEqualTo(1); + + now.set(Instant.parse("2026-04-20T00:00:31Z")); // advance 31 s + metrics.snapshotAllGauges(); + assertThat(calls.get()).isEqualTo(2); + } +} +``` + +Run: `cd cameleer-server-app && mvn -pl . -am test -Dtest=AlertingMetricsCachingTest` +Expected: FAIL (caching not implemented, or signature mismatch). + +- [ ] **Step 2: Refactor `AlertingMetrics` to wrap suppliers in a TTL cache** + +Read the existing file. If it currently uses `registry.gauge(...)` with direct suppliers, wrap each one in a lightweight cache: + +```java +// Inside AlertingMetrics +private static final class TtlCache { + private final Supplier delegate; + private final Duration ttl; + private final Supplier clock; + private volatile Instant lastRead = Instant.MIN; + private volatile long cached = 0L; + + TtlCache(Supplier delegate, Duration ttl, Supplier clock) { + this.delegate = delegate; + this.ttl = ttl; + this.clock = clock; + } + + long get() { + Instant now = clock.get(); + if (Duration.between(lastRead, now).compareTo(ttl) >= 0) { + cached = delegate.get(); + lastRead = now; + } + return cached; + } +} +``` + +Register the gauges with `() -> cache.get()` in place of raw suppliers. Keep the existing bean constructor signature on the production path (default `ttl = Duration.ofSeconds(30)`, clock = `Instant::now`) and add a test-friendly constructor variant with explicit `ttl` + `clock`. + +Add a test helper `snapshotAllGauges()` that reads each registered cache's value (or simply calls `registry.find(...).gauge().value()` on each metric) — whatever the existing method surface supports. If no such helper exists, expose package-private accessors on the caches. + +- [ ] **Step 3: Run test** + +```bash +mvn -pl cameleer-server-app -am test -Dtest=AlertingMetricsCachingTest +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/metrics/AlertingMetrics.java \ + cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java +git commit -m "perf(alerting): 30s TTL cache on AlertingMetrics gauge suppliers + +Prometheus scrapes can fire every few seconds. The open-alerts / open-rules +gauges query Postgres on each read — caching the values for 30s amortises +that to one query per half-minute. Addresses final-review NIT from Plan 02." +``` + +--- + +## Phase 10 — E2E smoke + docs + final verification + +### Task 30: Playwright E2E smoke + +**Files:** +- Create: `ui/src/test/e2e/alerting.spec.ts` +- Create: `ui/src/test/e2e/fixtures.ts` + +- [ ] **Step 1: Write a login fixture** + +```ts +// ui/src/test/e2e/fixtures.ts +import { test as base, expect } from '@playwright/test'; + +export const ADMIN_USER = process.env.E2E_ADMIN_USER ?? 'admin'; +export const ADMIN_PASS = process.env.E2E_ADMIN_PASS ?? 'admin'; + +export const test = base.extend<{ loggedIn: void }>({ + loggedIn: [async ({ page }, use) => { + await page.goto('/login'); + await page.getByLabel(/username/i).fill(ADMIN_USER); + await page.getByLabel(/password/i).fill(ADMIN_PASS); + await page.getByRole('button', { name: /log in/i }).click(); + await expect(page).toHaveURL(/\/(exchanges|alerts)/); + await use(); + }, { auto: true }], +}); + +export { expect }; +``` + +- [ ] **Step 2: Write the smoke test** + +```ts +// ui/src/test/e2e/alerting.spec.ts +import { test, expect } from './fixtures'; + +test.describe('alerting UI smoke', () => { + test('sidebar Alerts section navigates to inbox', async ({ page }) => { + await page.getByRole('button', { name: /alerts/i }).first().click(); + await expect(page).toHaveURL(/\/alerts\/inbox/); + await expect(page.getByRole('heading', { name: /inbox/i })).toBeVisible(); + }); + + test('CRUD a rule end-to-end', async ({ page }) => { + await page.goto('/alerts/rules'); + await page.getByRole('link', { name: /new rule/i }).click(); + await expect(page).toHaveURL(/\/alerts\/rules\/new/); + + // Step 1 — scope + await page.getByLabel(/^name$/i).fill('e2e smoke rule'); + await page.getByRole('button', { name: /next/i }).click(); + + // Step 2 — condition (leave at ROUTE_METRIC defaults) + await page.getByRole('button', { name: /next/i }).click(); + + // Step 3 — trigger (defaults) + await page.getByRole('button', { name: /next/i }).click(); + + // Step 4 — notify (templates have defaults) + await page.getByRole('button', { name: /next/i }).click(); + + // Step 5 — review + await page.getByRole('button', { name: /create rule/i }).click(); + + await expect(page).toHaveURL(/\/alerts\/rules/); + await expect(page.getByText('e2e smoke rule')).toBeVisible(); + + // Delete + page.once('dialog', (d) => d.accept()); + await page.getByRole('row', { name: /e2e smoke rule/i }).getByRole('button', { name: /delete/i }).click(); + await expect(page.getByText('e2e smoke rule')).toHaveCount(0); + }); + + test('CMD-K navigates to a rule', async ({ page }) => { + await page.keyboard.press('Control+K'); + await page.getByRole('searchbox').fill('smoke'); + // No rule expected in fresh DB — verify palette renders without crashing + await expect(page.getByRole('dialog')).toBeVisible(); + await page.keyboard.press('Escape'); + }); + + test('silence create + end-early', async ({ page }) => { + await page.goto('/alerts/silences'); + await page.getByLabel(/app slug/i).fill('smoke-app'); + await page.getByLabel(/duration/i).fill('1'); + await page.getByLabel(/reason/i).fill('e2e smoke'); + await page.getByRole('button', { name: /create silence/i }).click(); + await expect(page.getByText('smoke-app')).toBeVisible(); + + page.once('dialog', (d) => d.accept()); + await page.getByRole('row', { name: /smoke-app/i }).getByRole('button', { name: /end/i }).click(); + await expect(page.getByText('smoke-app')).toHaveCount(0); + }); +}); +``` + +- [ ] **Step 3: Run the smoke (requires backend on :8081)** + +```bash +cd ui && npx playwright test +``` + +Expected: 4 tests pass. If the dev env uses a different admin credential, override via `E2E_ADMIN_USER` + `E2E_ADMIN_PASS`. + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/test/e2e/alerting.spec.ts ui/src/test/e2e/fixtures.ts +git commit -m "test(ui/alerts): Playwright smoke — sidebar nav, rule CRUD, CMD-K, silence CRUD + +Smoke runs against the real backend (not mocks) per project test policy. +Does not exercise fire-to-ack (requires event ingestion machinery); that +path is covered by backend AlertingFullLifecycleIT." +``` + +--- + +### Task 31: Update `.claude/rules/ui.md` + admin guide + +**Files:** +- Modify: `.claude/rules/ui.md` +- Modify: `docs/alerting.md` + +- [ ] **Step 1: Update `.claude/rules/ui.md`** + +Read the file and append two new sections: + +```markdown +## Alerts + +- **Sidebar section** (`buildAlertsTreeNodes` in `ui/src/components/sidebar-utils.ts`) — Inbox, All, Rules, Silences, History. +- **Routes** in `ui/src/router.tsx`: `/alerts`, `/alerts/inbox`, `/alerts/all`, `/alerts/history`, `/alerts/rules`, `/alerts/rules/new`, `/alerts/rules/:id`, `/alerts/silences`. +- **Pages** under `ui/src/pages/Alerts/`: + - `InboxPage.tsx` — user-targeted FIRING/ACK'd alerts with bulk-read. + - `AllAlertsPage.tsx` — env-wide list with state chip filter. + - `HistoryPage.tsx` — RESOLVED alerts. + - `RulesListPage.tsx` — CRUD + enable/disable toggle + env-promotion dropdown (pure UI prefill, no new endpoint). + - `RuleEditor/RuleEditorWizard.tsx` — 5-step wizard (Scope / Condition / Trigger / Notify / Review). `form-state.ts` is the single source of truth (initialForm / toRequest / validateStep). + - `SilencesPage.tsx` — matcher-based create + end-early. +- **Components**: + - `NotificationBell.tsx` — polls `/alerts/unread-count` every 30s, paused when tab hidden via `usePageVisible`. + - `AlertStateChip.tsx`, `SeverityBadge.tsx` — shared state/severity indicators. + - `MustacheEditor/` — CodeMirror 6 editor with variable autocomplete + inline linter. Shared between rule title/message, webhook body/header overrides, and Admin Outbound Connection editor (reduced-context mode for URL). +- **API queries** under `ui/src/api/queries/`: `alerts.ts`, `alertRules.ts`, `alertSilences.ts`, `alertNotifications.ts`, `alertMeta.ts`. All env-scoped via `useSelectedEnv`. +- **CMD-K**: `buildAlertSearchData` in `LayoutShell.tsx` registers `alert` and `alertRule` categories. Badges convey severity + state. +- **Sidebar accordion**: entering `/alerts/*` collapses Applications + Admin + Starred (mirrors Admin accordion). +``` + +- [ ] **Step 2: Update `docs/alerting.md` admin guide** + +Append a UI walkthrough section covering: +- Where to find the Alerts section (sidebar + top-bar bell) +- How to author a rule (wizard screenshots can be added later) +- How to create a silence +- How to interpret the env-promotion warnings +- Where Mustache variable autocomplete comes from + +Keep it to ~60 lines; point readers to spec §13 for the full design rationale. + +- [ ] **Step 3: Commit** + +```bash +git add .claude/rules/ui.md docs/alerting.md +git commit -m "docs(alerting): UI map + admin-guide walkthrough for Plan 03 + +.claude/rules/ui.md now maps every Plan 03 UI surface. Admin guide picks up +inbox/rules/silences sections so ops teams can start in the UI without +reading the spec." +``` + +--- + +### Task 32: Final verification — build, lint, tests + +- [ ] **Step 1: Frontend full build (type check + bundle)** + +```bash +cd ui && npm run build +``` + +Expected: clean build, no errors. Bundle size within reason (<2 MB uncompressed, CM6 + alerts pages add ~150 KB gzipped). + +- [ ] **Step 2: Frontend lint** + +```bash +cd ui && npm run lint +``` + +Expected: zero errors. Fix warnings introduced by Plan 03 files; ignore pre-existing ones. + +- [ ] **Step 3: Frontend unit tests** + +```bash +cd ui && npm test +``` + +Expected: all Plan 03 Vitest suites pass (≥ 25 tests across hooks, chips, editor, form-state, prefill). + +- [ ] **Step 4: Backend verify** + +```bash +mvn -pl cameleer-server-app -am verify +``` + +Expected: all existing + new tests pass (SsrfGuardTest, AlertingMetricsCachingTest, extended OutboundConnectionAdminControllerIT). + +- [ ] **Step 5: `gitnexus_detect_changes` pre-PR sanity** + +``` +gitnexus_detect_changes({scope:"compare", base_ref:"main"}) +``` + +Expected: affected symbols = only Plan 03 surface (Alerts pages, MustacheEditor, NotificationBell, SsrfGuard, AlertingMetrics caching, router/LayoutShell edits). No stray edits to unrelated modules. + +- [ ] **Step 6: Regenerate OpenAPI schema one final time** + +If any backend DTO changed during the backfills, run `cd ui && npm run generate-api:live` and commit the diff. If there's no diff, skip this step. + +- [ ] **Step 7: Commit any verification artifacts (none expected)** + +No commit if everything is clean. + +- [ ] **Step 8: Push branch + open PR** + +```bash +git push -u origin feat/alerting-03-ui +gh pr create --title "feat(alerting): Plan 03 — UI + backfills (SSRF guard, metrics caching)" --body "$(cat <<'EOF' +## Summary +- Alerting UI: inbox, all/history, rules list, 5-step rule editor wizard, silences, notification bell, CMD-K integration. +- MustacheEditor: CodeMirror 6 with variable autocomplete + inline linter (shared across rule templates + webhook body/header overrides + connection defaults). +- Rule promotion across envs: pure UI prefill (no new endpoint) with client-side warnings (app missing in target env, agent-specific scope, connection not allowed in target env). +- Backend backfills: SSRF guard on outbound connection URL save (rejects loopback/link-local/RFC-1918); 30s TTL cache on AlertingMetrics gauges. +- Docs: `.claude/rules/ui.md` updated with full Alerts map; `docs/alerting.md` gains UI walkthrough. + +Spec: `docs/superpowers/specs/2026-04-19-alerting-design.md` §12/§13/§9. +Plan: `docs/superpowers/plans/2026-04-20-alerting-03-ui.md`. + +## Test plan +- [ ] Vitest unit suites all pass (`cd ui && npm test`). +- [ ] Playwright smoke passes against a running backend (`cd ui && npx playwright test`). +- [ ] `mvn -pl cameleer-server-app -am verify` green. +- [ ] Manual: create a rule, see it in the list, CMD-K finds it, disable it, delete it. +- [ ] Manual: create a silence, see it, end it early. +- [ ] Manual: bell shows unread count when a FIRING alert targets the current user. +- [ ] Manual: promoting a rule with an agent-scope shows the agent-id warning. + +Plan 01 + Plan 02 are already on main; Plan 03 targets main directly. Supersedes the chore/openapi-regen-post-plan02 branch (delete after merge). + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review + +Ran per the writing-plans skill self-review checklist. + +### 1. Spec coverage + +| Spec requirement | Covered by | +|---|---| +| §12 CMD-K integration (alerts + alertRules result sources) | Task 27 | +| §13 UI routes (`/alerts/**`) | Task 13 | +| §13 Top-nav `` | Tasks 9, 15 | +| §13 `` | Task 8 | +| §13 Rule editor 5-step wizard | Tasks 19–25 | +| §13 `` with variable autocomplete | Tasks 10–12 | +| §13 Silences / History / Rules list / OutboundConnectionAdminPage | Tasks 16, 17, 18, 26 (Outbound page already exists from Plan 01) | +| §13 Real-time: bell polls every 30s; paused when tab hidden | Task 9 (`usePageVisible`) + query `refetchIntervalInBackground:false` | +| §13 Accessibility: keyboard nav, ARIA | CM6 autocomplete is ARIA-conformant; bell has aria-label | +| §13 Styling: `@cameleer/design-system` CSS variables | All files use `var(--error)` etc. (no hardcoded hex) | +| §9 Rule promotion across envs — pure UI prefill + warnings | Tasks 18 (entry), 24, 25 | +| §17 SSRF guard on outbound URL | Task 28 | +| Final-review NIT: 30s gauge caching | Task 29 | +| Regenerate OpenAPI schema | Tasks 3, 32 | +| Update `.claude/rules/` | Task 31 | +| Testing preference: REST-API-driven (not raw SQL); Playwright over real backend | Task 30 + backend IT extension in Task 28 | + +No uncovered requirements from the spec sections relevant to Plan 03. + +### 2. Placeholder scan + +- No "TBD" / "implement later" / "similar to Task N" / "Add appropriate error handling" patterns remain. +- Every step with a code change includes the actual code. +- Step stubs in Task 13 are explicitly marked as "replaced in Phase 5/6/7" — they're real code, just thin. +- Some condition-forms (Task 21 step 4) reuse the same pattern; each form is shown in full rather than "similar to RouteMetricForm". + +### 3. Type consistency + +- `MustacheEditor` props are the same shape across all call sites: `{ value, onChange, kind?, reducedContext?, label, placeholder?, minHeight?, singleLine? }`. +- `FormState` is declared in `form-state.ts` and used identically by all wizard steps. +- Schema-derived types (`AlertDto`, `AlertRuleResponse`, etc.) come from `components['schemas'][...]` so they stay in sync with the backend. +- Query hook names follow a consistent convention: `use()` (list), `use(id)` (single), `useCreate`, `useUpdate`, `useDelete`. + +No inconsistencies. + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-04-20-alerting-03-ui.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** — dispatch a fresh subagent per task, review between tasks, fast iteration. + +**2. Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints. + +**Which approach?** From 0aa1776b578240253b5d284fbda53f6e5d588fc0 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:16:22 +0200 Subject: [PATCH 02/39] 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. --- ui/package-lock.json | 1209 +++++++++++++++++++++++++++++++++++- ui/package.json | 11 +- ui/src/test/canary.test.ts | 7 + ui/src/test/setup.ts | 7 + ui/vitest.config.ts | 14 + 5 files changed, 1246 insertions(+), 2 deletions(-) create mode 100644 ui/src/test/canary.test.ts create mode 100644 ui/src/test/setup.ts create mode 100644 ui/vitest.config.ts diff --git a/ui/package-lock.json b/ui/package-lock.json index a279dc8e..d75946cb 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -24,22 +24,86 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@playwright/test": "^1.58.2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/js-yaml": "^4.0.9", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.0", + "@vitest/ui": "^4.1.4", "cross-env": "^10.1.0", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "jsdom": "^29.0.2", "openapi-typescript": "^7.13.0", "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", - "vite": "^8.0.0" + "vite": "^8.0.0", + "vitest": "^4.1.4" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -232,6 +296,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -280,6 +354,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@cameleer/design-system": { "version": "0.1.56", "resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.56/design-system-0.1.56.tgz", @@ -297,6 +384,146 @@ "react-router-dom": "^7.0.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", @@ -495,6 +722,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -640,6 +885,13 @@ "node": ">=18" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@redocly/ajv": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", @@ -1059,6 +1311,96 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1070,6 +1412,25 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -1133,6 +1494,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1511,6 +1879,141 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.4.tgz", + "integrity": "sha512-EgFR7nlj5iTDYZYCvavjFokNYwr3c3ry0sFiCg+N7B233Nwp+NNx7eoF/XvMWDCKY71xXAG3kFkt97ZHBJVL8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "fflate": "^0.8.2", + "flatted": "^3.4.2", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.4" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1571,6 +2074,17 @@ "node": ">=6" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1593,6 +2107,26 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1613,6 +2147,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1689,6 +2233,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1809,6 +2363,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1937,6 +2512,20 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1955,6 +2544,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -1968,6 +2564,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1978,6 +2584,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/electron-to-chromium": { "version": "1.5.321", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", @@ -1985,6 +2599,26 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-toolkit": { "version": "1.45.1", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", @@ -2192,6 +2826,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2208,6 +2852,16 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2247,6 +2901,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2376,6 +3037,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -2437,6 +3111,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/index-to-position": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", @@ -2482,6 +3166,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2518,6 +3209,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2892,6 +3634,44 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2905,6 +3685,16 @@ "node": "*" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2945,6 +3735,17 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/openapi-fetch": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", @@ -3075,6 +3876,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3095,6 +3909,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3211,6 +4032,44 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3340,6 +4199,20 @@ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -3422,6 +4295,19 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3467,6 +4353,28 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3477,6 +4385,33 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3512,12 +4447,36 @@ "@scarf/scarf": "=1.4.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3535,6 +4494,72 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3620,6 +4645,16 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -3784,6 +4819,144 @@ } } }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3800,6 +4973,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3810,6 +5000,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/ui/package.json b/ui/package.json index ad9ea9ed..56277afd 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,6 +12,9 @@ "preview": "vite preview", "generate-api": "openapi-typescript src/api/openapi.json -o src/api/schema.d.ts", "generate-api:live": "curl -s http://192.168.50.86:30090/api/v1/api-docs -o src/api/openapi.json && openapi-typescript src/api/openapi.json -o src/api/schema.d.ts", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui", "postinstall": "node -e \"const fs=require('fs');fs.mkdirSync('public',{recursive:true});fs.copyFileSync('node_modules/@cameleer/design-system/assets/cameleer-logo.svg','public/favicon.svg')\"" }, "dependencies": { @@ -30,19 +33,25 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@playwright/test": "^1.58.2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/js-yaml": "^4.0.9", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.0", + "@vitest/ui": "^4.1.4", "cross-env": "^10.1.0", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "jsdom": "^29.0.2", "openapi-typescript": "^7.13.0", "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", - "vite": "^8.0.0" + "vite": "^8.0.0", + "vitest": "^4.1.4" } } diff --git a/ui/src/test/canary.test.ts b/ui/src/test/canary.test.ts new file mode 100644 index 00000000..4d177fb4 --- /dev/null +++ b/ui/src/test/canary.test.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from 'vitest'; + +describe('vitest canary', () => { + it('arithmetic still works', () => { + expect(1 + 1).toBe(2); + }); +}); diff --git a/ui/src/test/setup.ts b/ui/src/test/setup.ts new file mode 100644 index 00000000..0d74b735 --- /dev/null +++ b/ui/src/test/setup.ts @@ -0,0 +1,7 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +afterEach(() => { + cleanup(); +}); diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts new file mode 100644 index 00000000..6888b634 --- /dev/null +++ b/ui/vitest.config.ts @@ -0,0 +1,14 @@ +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, + }, +}); From 1260cbe674c1208a1d2b3c9a9d1e6daa9537cfbc Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:55:57 +0200 Subject: [PATCH 03/39] chore(ui): add CodeMirror 6 + Playwright config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install @codemirror/{view,state,autocomplete,commands,language,lint} and @lezer/common — needed by Phase 3's MustacheEditor (Task 13). CM6 picked over a raw textarea for its small incremental-rendering bundle, full ARIA/keyboard support, and pluggable autocomplete + linter APIs that map cleanly to Mustache token parsing. Add ui/playwright.config.ts wiring Task 30's E2E smoke: - testDir ./src/test/e2e, single worker, trace+screenshot on failure - webServer launches `npm run dev:local` (backend on :8081 required) - PLAYWRIGHT_BASE_URL env var skips the dev server for CI against a pre-deployed UI Add test:e2e / test:e2e:ui npm scripts and exclude Playwright's test-results/ and playwright-report/ from git. @playwright/test itself was already in devDependencies from an earlier task. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/.gitignore | 2 + ui/package-lock.json | 125 ++++++++++++++++++++++++++++++++++++++++ ui/package.json | 9 +++ ui/playwright.config.ts | 26 +++++++++ 4 files changed, 162 insertions(+) create mode 100644 ui/playwright.config.ts diff --git a/ui/.gitignore b/ui/.gitignore index ebf1100d..bfae4736 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -1,3 +1,5 @@ node_modules dist public/favicon.svg +test-results/ +playwright-report/ diff --git a/ui/package-lock.json b/ui/package-lock.json index d75946cb..1e36f8fe 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -10,6 +10,13 @@ "hasInstallScript": true, "dependencies": { "@cameleer/design-system": "^0.1.56", + "@codemirror/autocomplete": "^6.20.1", + "@codemirror/commands": "^6.10.3", + "@codemirror/language": "^6.12.3", + "@codemirror/lint": "^6.9.5", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.41.1", + "@lezer/common": "^1.5.2", "@tanstack/react-query": "^5.90.21", "js-yaml": "^4.1.1", "lucide-react": "^1.7.0", @@ -384,6 +391,76 @@ "react-router-dom": "^7.0.0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.41.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.1.tgz", + "integrity": "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", @@ -842,6 +919,36 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", @@ -2330,6 +2437,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -4425,6 +4538,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4909,6 +5028,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/ui/package.json b/ui/package.json index 56277afd..5b8c06e2 100644 --- a/ui/package.json +++ b/ui/package.json @@ -15,10 +15,19 @@ "test": "vitest run", "test:watch": "vitest", "test:ui": "vitest --ui", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "postinstall": "node -e \"const fs=require('fs');fs.mkdirSync('public',{recursive:true});fs.copyFileSync('node_modules/@cameleer/design-system/assets/cameleer-logo.svg','public/favicon.svg')\"" }, "dependencies": { "@cameleer/design-system": "^0.1.56", + "@codemirror/autocomplete": "^6.20.1", + "@codemirror/commands": "^6.10.3", + "@codemirror/language": "^6.12.3", + "@codemirror/lint": "^6.9.5", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.41.1", + "@lezer/common": "^1.5.2", "@tanstack/react-query": "^5.90.21", "js-yaml": "^4.1.1", "lucide-react": "^1.7.0", diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts new file mode 100644 index 00000000..e780436d --- /dev/null +++ b/ui/playwright.config.ts @@ -0,0 +1,26 @@ +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, + }, +}); From b2066cdb68889d9cb270ef6ae3484b355f4aa86e Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:56:43 +0200 Subject: [PATCH 04/39] chore(ui): regenerate openapi.json + schema.d.ts from deployed Plan 02 backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fetched from http://192.168.50.86:30090/api/v1/api-docs via `npm run generate-api:live`. Adds TypeScript types for the new alerting REST surface merged in #140: - 15 alerting paths under /environments/{envSlug}/alerts/** (rules CRUD, enable/disable, render-preview, test-evaluate, inbox, unread-count, ack/read/bulk-read, silences CRUD, per-alert notifications) - 1 flat notification retry path /alerts/notifications/{id}/retry - 4 outbound-connection admin paths (from Plan 01 #139) Verified tsc -p tsconfig.app.json --noEmit exits 0 — no existing SPA call sites break against the fresh types. Plan 03 UI work can consume these directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/src/api/openapi.json | 2 +- ui/src/api/schema.d.ts | 1355 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 1341 insertions(+), 16 deletions(-) diff --git a/ui/src/api/openapi.json b/ui/src/api/openapi.json index e48f9741..0285d424 100644 --- a/ui/src/api/openapi.json +++ b/ui/src/api/openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"Cameleer Server API","version":"1.0"},"servers":[{"url":"/api/v1","description":"Relative"}],"security":[{"bearer":[]}],"tags":[{"name":"Route Metrics","description":"Route performance metrics (env-scoped)"},{"name":"Database Admin","description":"Database monitoring and management (ADMIN only)"},{"name":"Threshold Admin","description":"Monitoring threshold configuration (ADMIN only)"},{"name":"Agent Commands","description":"Command push endpoints for agent communication"},{"name":"Agent List","description":"List registered agents in an environment"},{"name":"Sensitive Keys Admin","description":"Global sensitive key masking configuration (ADMIN only)"},{"name":"License Admin","description":"License management"},{"name":"Role Admin","description":"Role management (ADMIN only)"},{"name":"RBAC Stats","description":"RBAC statistics (ADMIN only)"},{"name":"OIDC Config Admin","description":"OIDC provider configuration (ADMIN only)"},{"name":"Application Config","description":"Per-application observability configuration (user-facing)"},{"name":"App Management","description":"Application lifecycle and JAR uploads (env-scoped)"},{"name":"Catalog","description":"Unified application catalog"},{"name":"ClickHouse Admin","description":"ClickHouse monitoring and diagnostics (ADMIN only)"},{"name":"Ingestion","description":"Data ingestion endpoints"},{"name":"Group Admin","description":"Group management (ADMIN only)"},{"name":"Usage Analytics","description":"UI usage pattern analytics"},{"name":"Deployment Management","description":"Deploy, stop, promote, and view logs"},{"name":"Detail","description":"Execution detail and processor snapshot endpoints"},{"name":"Agent Config","description":"Agent-authoritative config read (AGENT only)"},{"name":"User Admin","description":"User management (ADMIN only)"},{"name":"Agent Management","description":"Agent registration and lifecycle endpoints"},{"name":"Authentication","description":"Login and token refresh endpoints"},{"name":"Agent Events","description":"Agent lifecycle event log (env-scoped)"},{"name":"Route Catalog","description":"Route catalog and discovery (env-scoped)"},{"name":"Application Logs","description":"Query application logs (env-scoped)"},{"name":"Agent SSE","description":"Server-Sent Events endpoint for agent communication"},{"name":"Search","description":"Transaction search and stats (env-scoped)"},{"name":"Audit Log","description":"Audit log viewer (ADMIN only)"},{"name":"Claim Mapping Admin","description":"Manage OIDC claim-to-role/group mapping rules"},{"name":"Diagrams","description":"Diagram rendering endpoints"},{"name":"Environment Admin","description":"Environment management (ADMIN only)"},{"name":"App Settings","description":"Per-application dashboard settings (ADMIN/OPERATOR)"}],"paths":{"/environments/{envSlug}/apps/{appSlug}/settings":{"get":{"tags":["App Settings"],"summary":"Get settings for an application in this environment (returns defaults if not configured)","operationId":"getByAppId","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AppSettings"}}}}}},"put":{"tags":["App Settings"],"summary":"Create or update settings for an application in this environment","operationId":"update","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppSettingsRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AppSettings"}}}}}},"delete":{"tags":["App Settings"],"summary":"Delete application settings for this environment (reverts to defaults)","operationId":"delete","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/environments/{envSlug}/apps/{appSlug}/container-config":{"put":{"tags":["App Management"],"summary":"Update container config for this app","operationId":"updateContainerConfig","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"object"}}}},"required":true},"responses":{"200":{"description":"Container config updated","content":{"*/*":{"schema":{"type":"object"}}}},"400":{"description":"Invalid configuration","content":{"*/*":{"schema":{"type":"object"}}}},"404":{"description":"App not found in this environment","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/environments/{envSlug}/apps/{appSlug}/config":{"get":{"tags":["Application Config"],"summary":"Get application config for this environment","description":"Returns stored config merged with global sensitive keys. Falls back to defaults if no row is persisted yet.","operationId":"getConfig","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Config returned","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AppConfigResponse"}}}}}},"put":{"tags":["Application Config"],"summary":"Update application config for this environment","description":"Saves config and pushes CONFIG_UPDATE to LIVE agents of this application in the given environment","operationId":"updateConfig","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationConfig"}}},"required":true},"responses":{"200":{"description":"Config saved and pushed","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ConfigUpdateResponse"}}}}}}},"/admin/users/{userId}":{"get":{"tags":["User Admin"],"summary":"Get user by ID with RBAC detail","operationId":"getUser","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"User found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDetail"}}}},"404":{"description":"User not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDetail"}}}}}},"put":{"tags":["User Admin"],"summary":"Update user display name or email","operationId":"updateUser","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true},"responses":{"200":{"description":"User updated"},"404":{"description":"User not found"}}},"delete":{"tags":["User Admin"],"summary":"Delete user","operationId":"deleteUser","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"User deleted"},"409":{"description":"Cannot delete the last admin user"}}}},"/admin/thresholds":{"get":{"tags":["Threshold Admin"],"summary":"Get current threshold configuration","operationId":"getThresholds","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ThresholdConfig"}}}}}},"put":{"tags":["Threshold Admin"],"summary":"Update threshold configuration","operationId":"updateThresholds","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ThresholdConfigRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ThresholdConfig"}}}}}}},"/admin/sensitive-keys":{"get":{"tags":["Sensitive Keys Admin"],"summary":"Get global sensitive keys configuration","operationId":"getSensitiveKeys","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SensitiveKeysConfig"}}}}}},"put":{"tags":["Sensitive Keys Admin"],"summary":"Update global sensitive keys configuration","description":"Saves the global sensitive keys. Optionally fans out merged keys to all live agents.","operationId":"updateSensitiveKeys","parameters":[{"name":"pushToAgents","in":"query","required":false,"schema":{"type":"boolean","default":false}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SensitiveKeysRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SensitiveKeysResponse"}}}}}}},"/admin/roles/{id}":{"get":{"tags":["Role Admin"],"summary":"Get role by ID with effective principals","operationId":"getRole","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Role found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RoleDetail"}}}},"404":{"description":"Role not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RoleDetail"}}}}}},"put":{"tags":["Role Admin"],"summary":"Update a custom role","operationId":"updateRole","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRoleRequest"}}},"required":true},"responses":{"200":{"description":"Role updated"},"403":{"description":"Cannot modify system role"},"404":{"description":"Role not found"}}},"delete":{"tags":["Role Admin"],"summary":"Delete a custom role","operationId":"deleteRole","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Role deleted"},"403":{"description":"Cannot delete system role"},"404":{"description":"Role not found"}}}},"/admin/oidc":{"get":{"tags":["OIDC Config Admin"],"summary":"Get OIDC configuration","operationId":"getConfig_1","responses":{"200":{"description":"Current OIDC configuration (client_secret masked)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/OidcAdminConfigResponse"}}}}}},"put":{"tags":["OIDC Config Admin"],"summary":"Save OIDC configuration","operationId":"saveConfig","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OidcAdminConfigRequest"}}},"required":true},"responses":{"200":{"description":"Configuration saved","content":{"*/*":{"schema":{"$ref":"#/components/schemas/OidcAdminConfigResponse"}}}},"400":{"description":"Invalid configuration","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["OIDC Config Admin"],"summary":"Delete OIDC configuration","operationId":"deleteConfig","responses":{"204":{"description":"Configuration deleted"}}}},"/admin/groups/{id}":{"get":{"tags":["Group Admin"],"summary":"Get group by ID with effective roles","operationId":"getGroup","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Group found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/GroupDetail"}}}},"404":{"description":"Group not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/GroupDetail"}}}}}},"put":{"tags":["Group Admin"],"summary":"Update group name or parent","operationId":"updateGroup","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateGroupRequest"}}},"required":true},"responses":{"200":{"description":"Group updated"},"404":{"description":"Group not found"},"409":{"description":"Cycle detected in group hierarchy"}}},"delete":{"tags":["Group Admin"],"summary":"Delete group","operationId":"deleteGroup","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Group deleted"},"404":{"description":"Group not found"}}}},"/admin/environments/{envSlug}":{"get":{"tags":["Environment Admin"],"summary":"Get environment by slug","operationId":"getEnvironment","parameters":[{"name":"envSlug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Environment found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Environment"}}}},"404":{"description":"Environment not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Environment"}}}}}},"put":{"tags":["Environment Admin"],"summary":"Update an environment's mutable fields (displayName, production, enabled)","description":"Slug is immutable after creation and cannot be changed. Any slug field in the request body is ignored.","operationId":"updateEnvironment","parameters":[{"name":"envSlug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateEnvironmentRequest"}}},"required":true},"responses":{"200":{"description":"Environment updated","content":{"*/*":{"schema":{"type":"object"}}}},"404":{"description":"Environment not found","content":{"*/*":{"schema":{"type":"object"}}}}}},"delete":{"tags":["Environment Admin"],"summary":"Delete an environment","operationId":"deleteEnvironment","parameters":[{"name":"envSlug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Environment deleted","content":{"*/*":{"schema":{"type":"object"}}}},"400":{"description":"Cannot delete default environment","content":{"*/*":{"schema":{"type":"object"}}}},"404":{"description":"Environment not found","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/admin/environments/{envSlug}/jar-retention":{"put":{"tags":["Environment Admin"],"summary":"Update JAR retention policy for an environment","operationId":"updateJarRetention","parameters":[{"name":"envSlug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JarRetentionRequest"}}},"required":true},"responses":{"200":{"description":"Retention policy updated","content":{"*/*":{"schema":{"type":"object"}}}},"404":{"description":"Environment not found","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/admin/environments/{envSlug}/default-container-config":{"put":{"tags":["Environment Admin"],"summary":"Update default container config for an environment","operationId":"updateDefaultContainerConfig","parameters":[{"name":"envSlug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"object"}}}},"required":true},"responses":{"200":{"description":"Default container config updated","content":{"*/*":{"schema":{"type":"object"}}}},"400":{"description":"Invalid configuration","content":{"*/*":{"schema":{"type":"object"}}}},"404":{"description":"Environment not found","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/admin/claim-mappings/{id}":{"get":{"tags":["Claim Mapping Admin"],"summary":"Get a claim mapping rule by ID","operationId":"get","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ClaimMappingRule"}}}}}},"put":{"tags":["Claim Mapping Admin"],"summary":"Update a claim mapping rule","operationId":"update_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRuleRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ClaimMappingRule"}}}}}},"delete":{"tags":["Claim Mapping Admin"],"summary":"Delete a claim mapping rule","operationId":"delete_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK"}}}},"/environments/{envSlug}/executions/search":{"post":{"tags":["Search"],"summary":"Advanced search with all filters","description":"Env from the path overrides any environment field in the body.","operationId":"searchPost","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SearchResultExecutionSummary"}}}}}}},"/environments/{envSlug}/apps":{"get":{"tags":["App Management"],"summary":"List apps in this environment","operationId":"listApps","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}}],"responses":{"200":{"description":"App list returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/App"}}}}}}},"post":{"tags":["App Management"],"summary":"Create a new app in this environment","description":"Slug must match ^[a-z0-9][a-z0-9-]{0,63}$ and be unique within the environment. Slug is immutable after creation.","operationId":"createApp","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAppRequest"}}},"required":true},"responses":{"201":{"description":"App created","content":{"*/*":{"schema":{"type":"object"}}}},"400":{"description":"Invalid slug, or slug already exists in this environment","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/environments/{envSlug}/apps/{appSlug}/versions":{"get":{"tags":["App Management"],"summary":"List versions for this app","operationId":"listVersions","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Version list returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AppVersion"}}}}}}},"post":{"tags":["App Management"],"summary":"Upload a JAR for a new version of this app","operationId":"uploadJar","parameters":[{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"env":{"$ref":"#/components/schemas/Environment"},"file":{"type":"string","format":"binary"}},"required":["file"]}}}},"responses":{"201":{"description":"JAR uploaded and version created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AppVersion"}}}},"404":{"description":"App not found in this environment","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AppVersion"}}}}}}},"/environments/{envSlug}/apps/{appSlug}/deployments":{"get":{"tags":["Deployment Management"],"summary":"List deployments for this app in this environment","operationId":"listDeployments","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deployment list returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Deployment"}}}}}}},"post":{"tags":["Deployment Management"],"summary":"Create and start a new deployment for this app in this environment","operationId":"deploy","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeployRequest"}}},"required":true},"responses":{"202":{"description":"Deployment accepted and starting","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Deployment"}}}}}}},"/environments/{envSlug}/apps/{appSlug}/deployments/{deploymentId}/stop":{"post":{"tags":["Deployment Management"],"summary":"Stop a running deployment","operationId":"stop","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}},{"name":"deploymentId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Deployment stopped","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Deployment"}}}},"404":{"description":"Deployment not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Deployment"}}}}}}},"/environments/{envSlug}/apps/{appSlug}/deployments/{deploymentId}/promote":{"post":{"tags":["Deployment Management"],"summary":"Promote this deployment to a different environment","description":"Target environment is specified by slug in the request body. The same app slug must exist in the target environment (or be created separately first).","operationId":"promote","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}},{"name":"deploymentId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromoteRequest"}}},"required":true},"responses":{"202":{"description":"Promotion accepted and starting","content":{"*/*":{"schema":{"type":"object"}}}},"404":{"description":"Deployment or target environment not found","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/environments/{envSlug}/apps/{appSlug}/config/test-expression":{"post":{"tags":["Application Config"],"summary":"Test a tap expression against sample data via a live agent in this environment","operationId":"testExpression","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestExpressionRequest"}}},"required":true},"responses":{"200":{"description":"Expression evaluated successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TestExpressionResponse"}}}},"404":{"description":"No live agent available for this application in this environment","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TestExpressionResponse"}}}},"504":{"description":"Agent did not respond in time","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TestExpressionResponse"}}}}}}},"/data/metrics":{"post":{"tags":["Ingestion"],"summary":"Ingest agent metrics","description":"Accepts an array of MetricsSnapshot objects","operationId":"ingestMetrics","requestBody":{"content":{"application/json":{"schema":{"type":"string"}}},"required":true},"responses":{"202":{"description":"Data accepted for processing"},"400":{"description":"Invalid payload"},"503":{"description":"Buffer full, retry later"}}}},"/data/logs":{"post":{"tags":["Ingestion"],"summary":"Ingest application log entries","description":"Accepts a batch of log entries from an agent. Entries are buffered and flushed periodically.","operationId":"ingestLogs","requestBody":{"content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/LogEntry"}}}},"required":true},"responses":{"202":{"description":"Logs accepted for indexing"}}}},"/data/executions":{"post":{"tags":["Ingestion"],"summary":"Ingest execution chunk","operationId":"ingestChunks","requestBody":{"content":{"application/json":{"schema":{"type":"string"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/data/events":{"post":{"tags":["Ingestion"],"summary":"Ingest agent events","operationId":"ingestEvents","requestBody":{"content":{"application/json":{"schema":{"type":"string"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/data/diagrams":{"post":{"tags":["Ingestion"],"summary":"Ingest route diagram data","description":"Accepts a single RouteGraph or an array of RouteGraphs","operationId":"ingestDiagrams","requestBody":{"content":{"application/json":{"schema":{"type":"string"}}},"required":true},"responses":{"202":{"description":"Data accepted for processing"}}}},"/auth/refresh":{"post":{"tags":["Authentication"],"summary":"Refresh access token","operationId":"refresh","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshRequest"}}},"required":true},"responses":{"200":{"description":"Token refreshed","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AuthTokenResponse"}}}},"401":{"description":"Invalid refresh token","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/auth/oidc/callback":{"post":{"tags":["Authentication"],"summary":"Exchange OIDC authorization code for JWTs","operationId":"callback","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CallbackRequest"}}},"required":true},"responses":{"200":{"description":"Authentication successful","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AuthTokenResponse"}}}},"401":{"description":"OIDC authentication failed","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Account not provisioned","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"OIDC not configured or disabled","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AuthTokenResponse"}}}}}}},"/auth/login":{"post":{"tags":["Authentication"],"summary":"Login with local credentials","operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"Login successful","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AuthTokenResponse"}}}},"401":{"description":"Invalid credentials","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Account locked due to too many failed attempts","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AuthTokenResponse"}}}}}}},"/agents/{id}/replay":{"post":{"tags":["Agent Commands"],"summary":"Replay an exchange on a specific agent (synchronous)","description":"Sends a replay command and waits for the agent to complete the replay. Returns the replay result including status, replayExchangeId, and duration.","operationId":"replayExchange","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplayRequest"}}},"required":true},"responses":{"200":{"description":"Replay completed (check status for success/failure)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ReplayResponse"}}}},"404":{"description":"Agent not found or not connected","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ReplayResponse"}}}},"504":{"description":"Agent did not respond in time","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ReplayResponse"}}}}}}},"/agents/{id}/refresh":{"post":{"tags":["Agent Management"],"summary":"Refresh access token","description":"Issues a new access JWT from a valid refresh token","operationId":"refresh_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentRefreshRequest"}}},"required":true},"responses":{"200":{"description":"New access token issued","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AgentRefreshResponse"}}}},"401":{"description":"Invalid or expired refresh token","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AgentRefreshResponse"}}}},"404":{"description":"Agent not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AgentRefreshResponse"}}}}}}},"/agents/{id}/heartbeat":{"post":{"tags":["Agent Management"],"summary":"Agent heartbeat ping","description":"Updates the agent's last heartbeat timestamp. Auto-registers the agent if not in registry (e.g. after server restart).","operationId":"heartbeat","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HeartbeatRequest"}}}},"responses":{"200":{"description":"Heartbeat accepted"}}}},"/agents/{id}/deregister":{"post":{"tags":["Agent Management"],"summary":"Deregister agent","description":"Removes the agent from the registry. Called by agents during graceful shutdown.","operationId":"deregister","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Agent deregistered"},"404":{"description":"Agent not registered"}}}},"/agents/{id}/commands":{"post":{"tags":["Agent Commands"],"summary":"Send command to a specific agent","description":"Sends a command to the specified agent via SSE","operationId":"sendCommand","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommandRequest"}}},"required":true},"responses":{"202":{"description":"Command accepted","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CommandSingleResponse"}}}},"400":{"description":"Invalid command payload","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CommandSingleResponse"}}}},"404":{"description":"Agent not registered","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CommandSingleResponse"}}}}}}},"/agents/{id}/commands/{commandId}/ack":{"post":{"tags":["Agent Commands"],"summary":"Acknowledge command receipt","description":"Agent acknowledges that it has received and processed a command, with result status and message","operationId":"acknowledgeCommand","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"commandId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommandAckRequest"}}}},"responses":{"200":{"description":"Command acknowledged"},"404":{"description":"Command not found"}}}},"/agents/register":{"post":{"tags":["Agent Management"],"summary":"Register an agent","description":"Registers a new agent or re-registers an existing one. Requires bootstrap token in Authorization header.","operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentRegistrationRequest"}}},"required":true},"responses":{"200":{"description":"Agent registered successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AgentRegistrationResponse"}}}},"400":{"description":"Invalid registration payload","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Missing or invalid bootstrap token","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AgentRegistrationResponse"}}}}}}},"/agents/groups/{group}/commands":{"post":{"tags":["Agent Commands"],"summary":"Send command to all agents in a group","description":"Sends a command to all LIVE agents in the specified group and waits for responses","operationId":"sendGroupCommand","parameters":[{"name":"group","in":"path","required":true,"schema":{"type":"string"}},{"name":"environment","in":"query","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommandRequest"}}},"required":true},"responses":{"200":{"description":"Commands dispatched and responses collected","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CommandGroupResponse"}}}},"400":{"description":"Invalid command payload","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CommandGroupResponse"}}}}}}},"/agents/commands":{"post":{"tags":["Agent Commands"],"summary":"Broadcast command to all live agents","description":"Sends a command to all agents currently in LIVE state","operationId":"broadcastCommand","parameters":[{"name":"environment","in":"query","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommandRequest"}}},"required":true},"responses":{"202":{"description":"Commands accepted","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CommandBroadcastResponse"}}}},"400":{"description":"Invalid command payload","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CommandBroadcastResponse"}}}}}}},"/admin/users":{"get":{"tags":["User Admin"],"summary":"List all users with RBAC detail","operationId":"listUsers","responses":{"200":{"description":"User list returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserDetail"}}}}}}},"post":{"tags":["User Admin"],"summary":"Create a local user","operationId":"createUser","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserRequest"}}},"required":true},"responses":{"200":{"description":"User created","content":{"*/*":{"schema":{"type":"object"}}}},"400":{"description":"Disabled in OIDC mode","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/admin/users/{userId}/roles/{roleId}":{"post":{"tags":["User Admin"],"summary":"Assign a role to a user","operationId":"assignRoleToUser","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}},{"name":"roleId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Role assigned"},"404":{"description":"User or role not found"}}},"delete":{"tags":["User Admin"],"summary":"Remove a role from a user","operationId":"removeRoleFromUser","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}},{"name":"roleId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Role removed"}}}},"/admin/users/{userId}/password":{"post":{"tags":["User Admin"],"summary":"Reset user password","operationId":"resetPassword","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetPasswordRequest"}}},"required":true},"responses":{"204":{"description":"Password reset"},"400":{"description":"Disabled in OIDC mode or policy violation"}}}},"/admin/users/{userId}/groups/{groupId}":{"post":{"tags":["User Admin"],"summary":"Add a user to a group","operationId":"addUserToGroup","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}},{"name":"groupId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"User added to group"}}},"delete":{"tags":["User Admin"],"summary":"Remove a user from a group","operationId":"removeUserFromGroup","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}},{"name":"groupId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"User removed from group"}}}},"/admin/roles":{"get":{"tags":["Role Admin"],"summary":"List all roles (system and custom)","operationId":"listRoles","responses":{"200":{"description":"Role list returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/RoleDetail"}}}}}}},"post":{"tags":["Role Admin"],"summary":"Create a custom role","operationId":"createRole","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRoleRequest"}}},"required":true},"responses":{"200":{"description":"Role created","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"string","format":"uuid"}}}}}}}},"/admin/oidc/test":{"post":{"tags":["OIDC Config Admin"],"summary":"Test OIDC provider connectivity","operationId":"testConnection","responses":{"200":{"description":"Provider reachable","content":{"*/*":{"schema":{"$ref":"#/components/schemas/OidcTestResult"}}}},"400":{"description":"Provider unreachable or misconfigured","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/admin/license":{"get":{"tags":["License Admin"],"summary":"Get current license info","operationId":"getCurrent","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/LicenseInfo"}}}}}},"post":{"tags":["License Admin"],"summary":"Update license token at runtime","operationId":"update_2","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateLicenseRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/admin/groups":{"get":{"tags":["Group Admin"],"summary":"List all groups with hierarchy and effective roles","operationId":"listGroups","responses":{"200":{"description":"Group list returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/GroupDetail"}}}}}}},"post":{"tags":["Group Admin"],"summary":"Create a new group","operationId":"createGroup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGroupRequest"}}},"required":true},"responses":{"200":{"description":"Group created","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"string","format":"uuid"}}}}}}}},"/admin/groups/{id}/roles/{roleId}":{"post":{"tags":["Group Admin"],"summary":"Assign a role to a group","operationId":"assignRoleToGroup","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"roleId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Role assigned to group"},"404":{"description":"Group not found"}}},"delete":{"tags":["Group Admin"],"summary":"Remove a role from a group","operationId":"removeRoleFromGroup","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"roleId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Role removed from group"},"404":{"description":"Group not found"}}}},"/admin/environments":{"get":{"tags":["Environment Admin"],"summary":"List all environments","operationId":"listEnvironments","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Environment"}}}}}}},"post":{"tags":["Environment Admin"],"summary":"Create a new environment","operationId":"createEnvironment","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateEnvironmentRequest"}}},"required":true},"responses":{"201":{"description":"Environment created","content":{"*/*":{"schema":{"type":"object"}}}},"400":{"description":"Invalid slug or slug already exists","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/admin/database/queries/{pid}/kill":{"post":{"tags":["Database Admin"],"summary":"Terminate a query by PID","operationId":"killQuery","parameters":[{"name":"pid","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK"}}}},"/admin/claim-mappings":{"get":{"tags":["Claim Mapping Admin"],"summary":"List all claim mapping rules","operationId":"list","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ClaimMappingRule"}}}}}}},"post":{"tags":["Claim Mapping Admin"],"summary":"Create a claim mapping rule","operationId":"create","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRuleRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ClaimMappingRule"}}}}}}},"/admin/claim-mappings/test":{"post":{"tags":["Claim Mapping Admin"],"summary":"Test claim mapping rules against a set of claims (accepts unsaved rules)","operationId":"test","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TestResponse"}}}}}}},"/executions/{executionId}":{"get":{"tags":["Detail"],"summary":"Get execution detail with nested processor tree","operationId":"getDetail","parameters":[{"name":"executionId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Execution detail found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ExecutionDetail"}}}},"404":{"description":"Execution not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ExecutionDetail"}}}}}}},"/executions/{executionId}/processors/{index}/snapshot":{"get":{"tags":["Detail"],"summary":"Get exchange snapshot for a specific processor by index","operationId":"getProcessorSnapshot","parameters":[{"name":"executionId","in":"path","required":true,"schema":{"type":"string"}},{"name":"index","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"Snapshot data","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"string"}}}}},"404":{"description":"Snapshot not found","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"string"}}}}}}}},"/executions/{executionId}/processors/by-seq/{seq}/snapshot":{"get":{"tags":["Detail"],"summary":"Get exchange snapshot for a processor by seq number","operationId":"processorSnapshotBySeq","parameters":[{"name":"executionId","in":"path","required":true,"schema":{"type":"string"}},{"name":"seq","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"Snapshot data","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"string"}}}}},"404":{"description":"Snapshot not found","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"string"}}}}}}}},"/executions/{executionId}/processors/by-id/{processorId}/snapshot":{"get":{"tags":["Detail"],"summary":"Get exchange snapshot for a specific processor by processorId","operationId":"processorSnapshotById","parameters":[{"name":"executionId","in":"path","required":true,"schema":{"type":"string"}},{"name":"processorId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Snapshot data","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"string"}}}}},"404":{"description":"Snapshot not found","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"string"}}}}}}}},"/environments/{envSlug}/stats":{"get":{"tags":["Search"],"summary":"Aggregate execution stats (P99 latency, active count, SLA compliance)","operationId":"stats","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"from","in":"query","required":true,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"routeId","in":"query","required":false,"schema":{"type":"string"}},{"name":"application","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ExecutionStats"}}}}}}},"/environments/{envSlug}/stats/timeseries":{"get":{"tags":["Search"],"summary":"Bucketed time-series stats over a time window","operationId":"timeseries","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"from","in":"query","required":true,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"buckets","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":24}},{"name":"routeId","in":"query","required":false,"schema":{"type":"string"}},{"name":"application","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/StatsTimeseries"}}}}}}},"/environments/{envSlug}/stats/timeseries/by-route":{"get":{"tags":["Search"],"summary":"Timeseries grouped by route for an application","operationId":"timeseriesByRoute","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"from","in":"query","required":true,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"buckets","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":24}},{"name":"application","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/StatsTimeseries"}}}}}}}},"/environments/{envSlug}/stats/timeseries/by-app":{"get":{"tags":["Search"],"summary":"Timeseries grouped by application","operationId":"timeseriesByApp","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"from","in":"query","required":true,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"buckets","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":24}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/StatsTimeseries"}}}}}}}},"/environments/{envSlug}/stats/punchcard":{"get":{"tags":["Search"],"summary":"Transaction punchcard: weekday x hour grid (rolling 7 days)","operationId":"punchcard","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"application","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PunchcardCell"}}}}}}}},"/environments/{envSlug}/routes":{"get":{"tags":["Route Catalog"],"summary":"Get route catalog for this environment","description":"Returns all applications with their routes, agents, and health status — filtered to this environment","operationId":"getCatalog","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"from","in":"query","required":false,"schema":{"type":"string"}},{"name":"to","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Catalog returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AppCatalogEntry"}}}}}}}},"/environments/{envSlug}/routes/metrics":{"get":{"tags":["Route Metrics"],"summary":"Get route metrics for this environment","description":"Returns aggregated performance metrics per route for the given time window. Optional appId filter narrows to a single application.","operationId":"getMetrics","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"from","in":"query","required":false,"schema":{"type":"string"}},{"name":"to","in":"query","required":false,"schema":{"type":"string"}},{"name":"appId","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Metrics returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/RouteMetrics"}}}}}}}},"/environments/{envSlug}/routes/metrics/processors":{"get":{"tags":["Route Metrics"],"summary":"Get processor metrics for this environment","description":"Returns aggregated performance metrics per processor for the given route and time window","operationId":"getProcessorMetrics","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"routeId","in":"query","required":true,"schema":{"type":"string"}},{"name":"appId","in":"query","required":false,"schema":{"type":"string"}},{"name":"from","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"}}],"responses":{"200":{"description":"Metrics returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProcessorMetrics"}}}}}}}},"/environments/{envSlug}/logs":{"get":{"tags":["Application Logs"],"summary":"Search application log entries in this environment","description":"Cursor-paginated log search scoped to the env in the path. Supports free-text search, multi-level filtering, and optional application/agent scoping.","operationId":"searchLogs","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"q","in":"query","required":false,"schema":{"type":"string"}},{"name":"query","in":"query","required":false,"schema":{"type":"string"}},{"name":"level","in":"query","required":false,"schema":{"type":"string"}},{"name":"application","in":"query","required":false,"schema":{"type":"string"}},{"name":"agentId","in":"query","required":false,"schema":{"type":"string"}},{"name":"exchangeId","in":"query","required":false,"schema":{"type":"string"}},{"name":"logger","in":"query","required":false,"schema":{"type":"string"}},{"name":"source","in":"query","required":false,"schema":{"type":"string"}},{"name":"from","in":"query","required":false,"schema":{"type":"string"}},{"name":"to","in":"query","required":false,"schema":{"type":"string"}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":100}},{"name":"sort","in":"query","required":false,"schema":{"type":"string","default":"desc"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/LogSearchPageResponse"}}}}}}},"/environments/{envSlug}/executions":{"get":{"tags":["Search"],"summary":"Search executions with basic filters (env from path)","operationId":"searchGet","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"status","in":"query","required":false,"schema":{"type":"string"}},{"name":"timeFrom","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"timeTo","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"correlationId","in":"query","required":false,"schema":{"type":"string"}},{"name":"text","in":"query","required":false,"schema":{"type":"string"}},{"name":"routeId","in":"query","required":false,"schema":{"type":"string"}},{"name":"agentId","in":"query","required":false,"schema":{"type":"string"}},{"name":"processorType","in":"query","required":false,"schema":{"type":"string"}},{"name":"application","in":"query","required":false,"schema":{"type":"string"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":50}},{"name":"sortField","in":"query","required":false,"schema":{"type":"string"}},{"name":"sortDir","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SearchResultExecutionSummary"}}}}}}},"/environments/{envSlug}/errors/top":{"get":{"tags":["Search"],"summary":"Top N errors with velocity trend","operationId":"topErrors","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"from","in":"query","required":true,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"application","in":"query","required":false,"schema":{"type":"string"}},{"name":"routeId","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":5}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TopError"}}}}}}}},"/environments/{envSlug}/config":{"get":{"tags":["Application Config"],"summary":"List application configs in this environment","operationId":"listConfigs","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}}],"responses":{"200":{"description":"Configs returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApplicationConfig"}}}}}}}},"/environments/{envSlug}/attributes/keys":{"get":{"tags":["Search"],"summary":"Distinct attribute key names for this environment","operationId":"attributeKeys","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"type":"string"}}}}}}}},"/environments/{envSlug}/apps/{appSlug}":{"get":{"tags":["App Management"],"summary":"Get app by env + slug","operationId":"getApp","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"App found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/App"}}}},"404":{"description":"App not found in this environment","content":{"*/*":{"schema":{"$ref":"#/components/schemas/App"}}}}}},"delete":{"tags":["App Management"],"summary":"Delete this app","operationId":"deleteApp","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"App deleted"}}}},"/environments/{envSlug}/apps/{appSlug}/routes/{routeId}/diagram":{"get":{"tags":["Diagrams"],"summary":"Find the latest diagram for this app's route in this environment","description":"Resolves agents in this env for this app, then looks up the latest diagram for the route they reported. Env scope prevents a dev route from returning a prod diagram.","operationId":"findByAppAndRoute","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}},{"name":"routeId","in":"path","required":true,"schema":{"type":"string"}},{"name":"direction","in":"query","required":false,"schema":{"type":"string","default":"LR"}}],"responses":{"200":{"description":"Diagram layout returned","content":{"*/*":{"schema":{"$ref":"#/components/schemas/DiagramLayout"}}}},"404":{"description":"No diagram found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/DiagramLayout"}}}}}}},"/environments/{envSlug}/apps/{appSlug}/processor-routes":{"get":{"tags":["Application Config"],"summary":"Get processor to route mapping for this environment","description":"Returns a map of processorId → routeId for all processors seen in this application + environment","operationId":"getProcessorRouteMapping","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Mapping returned","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"string"}}}}}}}},"/environments/{envSlug}/apps/{appSlug}/deployments/{deploymentId}":{"get":{"tags":["Deployment Management"],"summary":"Get deployment by ID","operationId":"getDeployment","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}},{"name":"deploymentId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Deployment found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Deployment"}}}},"404":{"description":"Deployment not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Deployment"}}}}}}},"/environments/{envSlug}/apps/{appSlug}/deployments/{deploymentId}/logs":{"get":{"tags":["Deployment Management"],"summary":"Get container logs for this deployment","operationId":"getLogs","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}},{"name":"deploymentId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Logs returned","content":{"*/*":{"schema":{"type":"array","items":{"type":"string"}}}}},"404":{"description":"Deployment not found or no container","content":{"*/*":{"schema":{"type":"array","items":{"type":"string"}}}}}}}},"/environments/{envSlug}/app-settings":{"get":{"tags":["App Settings"],"summary":"List application settings in this environment","operationId":"getAll","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AppSettings"}}}}}}}},"/environments/{envSlug}/agents":{"get":{"tags":["Agent List"],"summary":"List all agents in this environment","description":"Returns registered agents with runtime metrics, optionally filtered by status and/or application","operationId":"listAgents","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"status","in":"query","required":false,"schema":{"type":"string"}},{"name":"application","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Agent list returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AgentInstanceResponse"}}}}},"400":{"description":"Invalid status filter","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/environments/{envSlug}/agents/{agentId}/metrics":{"get":{"tags":["agent-metrics-controller"],"operationId":"getMetrics_1","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"agentId","in":"path","required":true,"schema":{"type":"string"}},{"name":"names","in":"query","required":true,"schema":{"type":"string"}},{"name":"from","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"buckets","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":60}},{"name":"mode","in":"query","required":false,"schema":{"type":"string","default":"gauge"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AgentMetricsResponse"}}}}}}},"/environments/{envSlug}/agents/events":{"get":{"tags":["Agent Events"],"summary":"Query agent events in this environment","description":"Cursor-paginated. Returns newest first. Pass nextCursor back as ?cursor= for the next page.","operationId":"getEvents","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appId","in":"query","required":false,"schema":{"type":"string"}},{"name":"agentId","in":"query","required":false,"schema":{"type":"string"}},{"name":"from","in":"query","required":false,"schema":{"type":"string"}},{"name":"to","in":"query","required":false,"schema":{"type":"string"}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":50}}],"responses":{"200":{"description":"Event page returned","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AgentEventPageResponse"}}}}}}},"/diagrams/{contentHash}/render":{"get":{"tags":["Diagrams"],"summary":"Render a route diagram by content hash","description":"Returns SVG (default) or JSON layout based on Accept header. Content hashes are globally unique, so this endpoint is intentionally flat (no env).","operationId":"renderDiagram","parameters":[{"name":"contentHash","in":"path","required":true,"schema":{"type":"string"}},{"name":"direction","in":"query","required":false,"schema":{"type":"string","default":"LR"}}],"responses":{"200":{"description":"Diagram rendered successfully","content":{"image/svg+xml":{"schema":{"type":"string"}},"application/json":{"schema":{"$ref":"#/components/schemas/DiagramLayout"}}}},"404":{"description":"Diagram not found","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/catalog":{"get":{"tags":["Catalog"],"summary":"Get unified catalog","description":"Returns all applications (managed + unmanaged) with live agent data, routes, and deployment status","operationId":"getCatalog_1","parameters":[{"name":"environment","in":"query","required":false,"schema":{"type":"string"}},{"name":"from","in":"query","required":false,"schema":{"type":"string"}},{"name":"to","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Catalog returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CatalogApp"}}}}}}}},"/auth/oidc/config":{"get":{"tags":["Authentication"],"summary":"Get OIDC config for SPA login flow","operationId":"getConfig_2","responses":{"200":{"description":"OIDC configuration","content":{"*/*":{"schema":{"$ref":"#/components/schemas/OidcPublicConfigResponse"}}}},"404":{"description":"OIDC not configured or disabled","content":{"*/*":{"schema":{"$ref":"#/components/schemas/OidcPublicConfigResponse"}}}},"500":{"description":"Failed to retrieve OIDC provider metadata","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/auth/me":{"get":{"tags":["Authentication"],"summary":"Get current user details","operationId":"me","responses":{"200":{"description":"Current user details","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDetail"}}}},"401":{"description":"Not authenticated","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDetail"}}}}}}},"/agents/{id}/events":{"get":{"tags":["Agent SSE"],"summary":"Open SSE event stream","description":"Opens a Server-Sent Events stream for the specified agent. Commands (config-update, deep-trace, replay) are pushed as events. Ping keepalive comments sent every 15 seconds.","operationId":"events","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"Last-Event-ID","in":"header","description":"Last received event ID (no replay, acknowledged only)","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"SSE stream opened","content":{"text/event-stream":{"schema":{"$ref":"#/components/schemas/SseEmitter"}}}},"404":{"description":"Agent not registered and cannot be auto-registered","content":{"text/event-stream":{"schema":{"$ref":"#/components/schemas/SseEmitter"}}}}}}},"/agents/config":{"get":{"tags":["Agent Config"],"summary":"Get application config for the calling agent","description":"Resolves (application, environment) from the agent's JWT + registry. Prefers the registry entry (heartbeat-authoritative); falls back to the JWT env claim. Returns 404 if neither identifies a valid agent.","operationId":"getConfigForAgent","responses":{"200":{"description":"Config returned","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AppConfigResponse"}}}},"404":{"description":"Calling agent could not be resolved","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AppConfigResponse"}}}}}}},"/admin/usage":{"get":{"tags":["Usage Analytics"],"summary":"Query usage statistics","description":"Returns aggregated API usage stats grouped by endpoint, user, or hour","operationId":"getUsage","parameters":[{"name":"from","in":"query","required":false,"schema":{"type":"string"}},{"name":"to","in":"query","required":false,"schema":{"type":"string"}},{"name":"username","in":"query","required":false,"schema":{"type":"string"}},{"name":"groupBy","in":"query","required":false,"schema":{"type":"string","default":"endpoint"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UsageStats"}}}}}}}},"/admin/rbac/stats":{"get":{"tags":["RBAC Stats"],"summary":"Get RBAC statistics for the dashboard","operationId":"getStats","responses":{"200":{"description":"RBAC stats returned","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RbacStats"}}}}}}},"/admin/database/tables":{"get":{"tags":["Database Admin"],"summary":"Get table sizes and row counts","operationId":"getTables","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TableSizeResponse"}}}}}}}},"/admin/database/status":{"get":{"tags":["Database Admin"],"summary":"Get database connection status and version","operationId":"getStatus","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/DatabaseStatusResponse"}}}}}}},"/admin/database/queries":{"get":{"tags":["Database Admin"],"summary":"Get active queries","operationId":"getQueries","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ActiveQueryResponse"}}}}}}}},"/admin/database/pool":{"get":{"tags":["Database Admin"],"summary":"Get HikariCP connection pool stats","operationId":"getPool","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ConnectionPoolResponse"}}}}}}},"/admin/clickhouse/tables":{"get":{"tags":["ClickHouse Admin"],"summary":"List ClickHouse tables with sizes","operationId":"getTables_1","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ClickHouseTableInfo"}}}}}}}},"/admin/clickhouse/status":{"get":{"tags":["ClickHouse Admin"],"summary":"ClickHouse cluster status","operationId":"getStatus_1","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ClickHouseStatusResponse"}}}}}}},"/admin/clickhouse/queries":{"get":{"tags":["ClickHouse Admin"],"summary":"Active ClickHouse queries","operationId":"getQueries_1","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ClickHouseQueryInfo"}}}}}}}},"/admin/clickhouse/pipeline":{"get":{"tags":["ClickHouse Admin"],"summary":"Search indexer pipeline statistics","operationId":"getPipeline","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/IndexerPipelineResponse"}}}}}}},"/admin/clickhouse/performance":{"get":{"tags":["ClickHouse Admin"],"summary":"ClickHouse storage and performance metrics","operationId":"getPerformance","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ClickHousePerformanceResponse"}}}}}}},"/admin/audit":{"get":{"tags":["Audit Log"],"summary":"Search audit log entries with pagination","operationId":"getAuditLog","parameters":[{"name":"username","in":"query","required":false,"schema":{"type":"string"}},{"name":"category","in":"query","required":false,"schema":{"type":"string"}},{"name":"search","in":"query","required":false,"schema":{"type":"string"}},{"name":"from","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"sort","in":"query","required":false,"schema":{"type":"string","default":"timestamp"}},{"name":"order","in":"query","required":false,"schema":{"type":"string","default":"desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"size","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":25}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AuditLogPageResponse"}}}}}}},"/catalog/{applicationId}":{"delete":{"tags":["Catalog"],"summary":"Dismiss application and purge all data","operationId":"dismissApplication","parameters":[{"name":"applicationId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Application dismissed"},"409":{"description":"Cannot dismiss — live agents connected"}}}}},"components":{"schemas":{"Environment":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"displayName":{"type":"string"},"production":{"type":"boolean"},"enabled":{"type":"boolean"},"defaultContainerConfig":{"type":"object","additionalProperties":{"type":"object"}},"jarRetentionCount":{"type":"integer","format":"int32"},"createdAt":{"type":"string","format":"date-time"}}},"AppSettingsRequest":{"type":"object","description":"Per-application dashboard settings","properties":{"slaThresholdMs":{"type":"integer","format":"int32","description":"SLA duration threshold in milliseconds","minimum":1},"healthErrorWarn":{"type":"number","format":"double","description":"Error rate % threshold for warning (yellow) health dot","maximum":100,"minimum":0},"healthErrorCrit":{"type":"number","format":"double","description":"Error rate % threshold for critical (red) health dot","maximum":100,"minimum":0},"healthSlaWarn":{"type":"number","format":"double","description":"SLA compliance % threshold for warning (yellow) health dot","maximum":100,"minimum":0},"healthSlaCrit":{"type":"number","format":"double","description":"SLA compliance % threshold for critical (red) health dot","maximum":100,"minimum":0}},"required":["healthErrorCrit","healthErrorWarn","healthSlaCrit","healthSlaWarn","slaThresholdMs"]},"AppSettings":{"type":"object","properties":{"applicationId":{"type":"string"},"environment":{"type":"string"},"slaThresholdMs":{"type":"integer","format":"int32"},"healthErrorWarn":{"type":"number","format":"double"},"healthErrorCrit":{"type":"number","format":"double"},"healthSlaWarn":{"type":"number","format":"double"},"healthSlaCrit":{"type":"number","format":"double"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"ApplicationConfig":{"type":"object","properties":{"application":{"type":"string"},"environment":{"type":"string"},"version":{"type":"integer","format":"int32"},"updatedAt":{"type":"string","format":"date-time"},"engineLevel":{"type":"string"},"payloadCaptureMode":{"type":"string"},"metricsEnabled":{"type":"boolean"},"samplingRate":{"type":"number","format":"double"},"tracedProcessors":{"type":"object","additionalProperties":{"type":"string"}},"applicationLogLevel":{"type":"string"},"taps":{"type":"array","items":{"$ref":"#/components/schemas/TapDefinition"}},"tapVersion":{"type":"integer","format":"int32"},"routeRecording":{"type":"object","additionalProperties":{"type":"boolean"}},"compressSuccess":{"type":"boolean"},"agentLogLevel":{"type":"string"},"routeSamplingRates":{"type":"object","additionalProperties":{"type":"number","format":"double"}},"sensitiveKeys":{"type":"array","items":{"type":"string"}}}},"TapDefinition":{"type":"object","properties":{"tapId":{"type":"string"},"processorId":{"type":"string"},"target":{"type":"string"},"expression":{"type":"string"},"language":{"type":"string"},"attributeName":{"type":"string"},"attributeType":{"type":"string"},"enabled":{"type":"boolean"},"version":{"type":"integer","format":"int32"}}},"AgentResponse":{"type":"object","properties":{"agentId":{"type":"string"},"status":{"type":"string"},"message":{"type":"string"}}},"CommandGroupResponse":{"type":"object","properties":{"success":{"type":"boolean"},"total":{"type":"integer","format":"int32"},"responded":{"type":"integer","format":"int32"},"responses":{"type":"array","items":{"$ref":"#/components/schemas/AgentResponse"}},"timedOut":{"type":"array","items":{"type":"string"}}}},"ConfigUpdateResponse":{"type":"object","properties":{"config":{"$ref":"#/components/schemas/ApplicationConfig"},"pushResult":{"$ref":"#/components/schemas/CommandGroupResponse"}}},"UpdateUserRequest":{"type":"object","properties":{"displayName":{"type":"string"},"email":{"type":"string"}}},"DatabaseThresholdsRequest":{"type":"object","description":"Database monitoring thresholds","properties":{"connectionPoolWarning":{"type":"integer","format":"int32","description":"Connection pool usage warning threshold (percentage)","maximum":100,"minimum":0},"connectionPoolCritical":{"type":"integer","format":"int32","description":"Connection pool usage critical threshold (percentage)","maximum":100,"minimum":0},"queryDurationWarning":{"type":"number","format":"double","description":"Query duration warning threshold (seconds)"},"queryDurationCritical":{"type":"number","format":"double","description":"Query duration critical threshold (seconds)"}}},"ThresholdConfigRequest":{"type":"object","description":"Threshold configuration for admin monitoring","properties":{"database":{"$ref":"#/components/schemas/DatabaseThresholdsRequest"}},"required":["database"]},"DatabaseThresholds":{"type":"object","properties":{"connectionPoolWarning":{"type":"integer","format":"int32"},"connectionPoolCritical":{"type":"integer","format":"int32"},"queryDurationWarning":{"type":"number","format":"double"},"queryDurationCritical":{"type":"number","format":"double"}}},"ThresholdConfig":{"type":"object","properties":{"database":{"$ref":"#/components/schemas/DatabaseThresholds"}}},"SensitiveKeysRequest":{"type":"object","description":"Global sensitive keys configuration","properties":{"keys":{"type":"array","description":"List of key names or glob patterns to mask","items":{"type":"string"}}},"required":["keys"]},"SensitiveKeysResponse":{"type":"object","properties":{"keys":{"type":"array","items":{"type":"string"}},"pushResult":{"$ref":"#/components/schemas/CommandGroupResponse"}}},"UpdateRoleRequest":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"},"scope":{"type":"string"}}},"OidcAdminConfigRequest":{"type":"object","description":"OIDC configuration update request","properties":{"enabled":{"type":"boolean"},"issuerUri":{"type":"string"},"clientId":{"type":"string"},"clientSecret":{"type":"string"},"rolesClaim":{"type":"string"},"defaultRoles":{"type":"array","items":{"type":"string"}},"autoSignup":{"type":"boolean"},"displayNameClaim":{"type":"string"},"userIdClaim":{"type":"string"},"audience":{"type":"string"},"additionalScopes":{"type":"array","items":{"type":"string"}}}},"ErrorResponse":{"type":"object","description":"Error response","properties":{"message":{"type":"string"}},"required":["message"]},"OidcAdminConfigResponse":{"type":"object","description":"OIDC configuration for admin management","properties":{"configured":{"type":"boolean"},"enabled":{"type":"boolean"},"issuerUri":{"type":"string"},"clientId":{"type":"string"},"clientSecretSet":{"type":"boolean"},"rolesClaim":{"type":"string"},"defaultRoles":{"type":"array","items":{"type":"string"}},"autoSignup":{"type":"boolean"},"displayNameClaim":{"type":"string"},"userIdClaim":{"type":"string"},"audience":{"type":"string"},"additionalScopes":{"type":"array","items":{"type":"string"}}}},"UpdateGroupRequest":{"type":"object","properties":{"name":{"type":"string"},"parentGroupId":{"type":"string","format":"uuid"}}},"UpdateEnvironmentRequest":{"type":"object","properties":{"displayName":{"type":"string"},"production":{"type":"boolean"},"enabled":{"type":"boolean"}}},"JarRetentionRequest":{"type":"object","properties":{"jarRetentionCount":{"type":"integer","format":"int32"}}},"CreateRuleRequest":{"type":"object","properties":{"claim":{"type":"string"},"matchType":{"type":"string"},"matchValue":{"type":"string"},"action":{"type":"string"},"target":{"type":"string"},"priority":{"type":"integer","format":"int32"}}},"ClaimMappingRule":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"claim":{"type":"string"},"matchType":{"type":"string"},"matchValue":{"type":"string"},"action":{"type":"string"},"target":{"type":"string"},"priority":{"type":"integer","format":"int32"},"createdAt":{"type":"string","format":"date-time"}}},"SearchRequest":{"type":"object","properties":{"status":{"type":"string"},"timeFrom":{"type":"string","format":"date-time"},"timeTo":{"type":"string","format":"date-time"},"durationMin":{"type":"integer","format":"int64"},"durationMax":{"type":"integer","format":"int64"},"correlationId":{"type":"string"},"text":{"type":"string"},"textInBody":{"type":"string"},"textInHeaders":{"type":"string"},"textInErrors":{"type":"string"},"routeId":{"type":"string"},"instanceId":{"type":"string"},"processorType":{"type":"string"},"applicationId":{"type":"string"},"instanceIds":{"type":"array","items":{"type":"string"}},"offset":{"type":"integer","format":"int32"},"limit":{"type":"integer","format":"int32"},"sortField":{"type":"string"},"sortDir":{"type":"string"},"environment":{"type":"string"}}},"ExecutionSummary":{"type":"object","properties":{"executionId":{"type":"string"},"routeId":{"type":"string"},"instanceId":{"type":"string"},"applicationId":{"type":"string"},"status":{"type":"string"},"startTime":{"type":"string","format":"date-time"},"endTime":{"type":"string","format":"date-time"},"durationMs":{"type":"integer","format":"int64"},"correlationId":{"type":"string"},"errorMessage":{"type":"string"},"diagramContentHash":{"type":"string"},"highlight":{"type":"string"},"attributes":{"type":"object","additionalProperties":{"type":"string"}},"hasTraceData":{"type":"boolean"},"isReplay":{"type":"boolean"}},"required":["applicationId","attributes","correlationId","diagramContentHash","durationMs","endTime","errorMessage","executionId","hasTraceData","highlight","instanceId","isReplay","routeId","startTime","status"]},"SearchResultExecutionSummary":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ExecutionSummary"}},"total":{"type":"integer","format":"int64"},"offset":{"type":"integer","format":"int32"},"limit":{"type":"integer","format":"int32"}},"required":["data","limit","offset","total"]},"CreateAppRequest":{"type":"object","properties":{"slug":{"type":"string"},"displayName":{"type":"string"}}},"AppVersion":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"appId":{"type":"string","format":"uuid"},"version":{"type":"integer","format":"int32"},"jarPath":{"type":"string"},"jarChecksum":{"type":"string"},"jarFilename":{"type":"string"},"jarSizeBytes":{"type":"integer","format":"int64"},"detectedRuntimeType":{"type":"string"},"detectedMainClass":{"type":"string"},"uploadedAt":{"type":"string","format":"date-time"}}},"DeployRequest":{"type":"object","properties":{"appVersionId":{"type":"string","format":"uuid"}}},"Deployment":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"appId":{"type":"string","format":"uuid"},"appVersionId":{"type":"string","format":"uuid"},"environmentId":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["STOPPED","STARTING","RUNNING","DEGRADED","STOPPING","FAILED"]},"targetState":{"type":"string"},"deploymentStrategy":{"type":"string"},"replicaStates":{"type":"array","items":{"type":"object","additionalProperties":{"type":"object"}}},"deployStage":{"type":"string"},"containerId":{"type":"string"},"containerName":{"type":"string"},"errorMessage":{"type":"string"},"resolvedConfig":{"type":"object","additionalProperties":{"type":"object"}},"deployedAt":{"type":"string","format":"date-time"},"stoppedAt":{"type":"string","format":"date-time"},"createdAt":{"type":"string","format":"date-time"}}},"PromoteRequest":{"type":"object","properties":{"targetEnvironment":{"type":"string"}}},"TestExpressionRequest":{"type":"object","properties":{"expression":{"type":"string"},"language":{"type":"string"},"body":{"type":"string"},"target":{"type":"string"}}},"TestExpressionResponse":{"type":"object","properties":{"result":{"type":"string"},"error":{"type":"string"}}},"LogEntry":{"type":"object","properties":{"timestamp":{"type":"string","format":"date-time"},"level":{"type":"string"},"loggerName":{"type":"string"},"message":{"type":"string"},"threadName":{"type":"string"},"stackTrace":{"type":"string"},"mdc":{"type":"object","additionalProperties":{"type":"string"}},"source":{"type":"string"}}},"RefreshRequest":{"type":"object","properties":{"refreshToken":{"type":"string"}}},"AuthTokenResponse":{"type":"object","description":"JWT token pair","properties":{"accessToken":{"type":"string"},"refreshToken":{"type":"string"},"displayName":{"type":"string"},"idToken":{"type":"string","description":"OIDC id_token for end-session logout (only present after OIDC login)"}},"required":["accessToken","displayName","refreshToken"]},"CallbackRequest":{"type":"object","properties":{"code":{"type":"string"},"redirectUri":{"type":"string"}}},"LoginRequest":{"type":"object","properties":{"username":{"type":"string"},"password":{"type":"string"}}},"ReplayRequest":{"type":"object","description":"Request to replay an exchange on an agent","properties":{"routeId":{"type":"string","description":"Camel route ID to replay on"},"body":{"type":"string","description":"Message body for the replayed exchange"},"headers":{"type":"object","additionalProperties":{"type":"string"},"description":"Message headers for the replayed exchange"},"originalExchangeId":{"type":"string","description":"Exchange ID of the original execution being replayed (for audit trail)"}},"required":["routeId"]},"ReplayResponse":{"type":"object","description":"Result of a replay command","properties":{"status":{"type":"string","description":"Replay outcome: SUCCESS or FAILURE"},"message":{"type":"string","description":"Human-readable result message"},"data":{"type":"string","description":"Structured result data from the agent (JSON)"}}},"AgentRefreshRequest":{"type":"object","description":"Agent token refresh request","properties":{"refreshToken":{"type":"string"}},"required":["refreshToken"]},"AgentRefreshResponse":{"type":"object","description":"Refreshed access and refresh tokens","properties":{"accessToken":{"type":"string"},"refreshToken":{"type":"string"}},"required":["accessToken","refreshToken"]},"HeartbeatRequest":{"type":"object","properties":{"routeStates":{"type":"object","additionalProperties":{"type":"string"}},"capabilities":{"type":"object","additionalProperties":{"type":"object"}},"environmentId":{"type":"string"}}},"CommandRequest":{"type":"object","description":"Command to send to agent(s)","properties":{"type":{"type":"string","description":"Command type: config-update, deep-trace, or replay"},"payload":{"type":"object","description":"Command payload JSON"}},"required":["type"]},"CommandSingleResponse":{"type":"object","description":"Result of sending a command to a single agent","properties":{"commandId":{"type":"string"},"status":{"type":"string"}},"required":["commandId","status"]},"CommandAckRequest":{"type":"object","properties":{"status":{"type":"string"},"message":{"type":"string"},"data":{"type":"string"}}},"AgentRegistrationRequest":{"type":"object","description":"Agent registration payload","properties":{"instanceId":{"type":"string"},"applicationId":{"type":"string","default":"default"},"environmentId":{"type":"string","default":"default"},"version":{"type":"string"},"routeIds":{"type":"array","items":{"type":"string"}},"capabilities":{"type":"object","additionalProperties":{"type":"object"}}},"required":["instanceId"]},"AgentRegistrationResponse":{"type":"object","description":"Agent registration result with JWT tokens and SSE endpoint","properties":{"instanceId":{"type":"string"},"sseEndpoint":{"type":"string"},"heartbeatIntervalMs":{"type":"integer","format":"int64"},"serverPublicKey":{"type":"string"},"accessToken":{"type":"string"},"refreshToken":{"type":"string"}},"required":["accessToken","instanceId","refreshToken","serverPublicKey","sseEndpoint"]},"CommandBroadcastResponse":{"type":"object","description":"Result of broadcasting a command to multiple agents","properties":{"commandIds":{"type":"array","items":{"type":"string"}},"targetCount":{"type":"integer","format":"int32"}},"required":["commandIds"]},"CreateUserRequest":{"type":"object","properties":{"username":{"type":"string"},"displayName":{"type":"string"},"email":{"type":"string"},"password":{"type":"string"}}},"SetPasswordRequest":{"type":"object","properties":{"password":{"type":"string","minLength":1}}},"CreateRoleRequest":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"},"scope":{"type":"string"}}},"OidcTestResult":{"type":"object","description":"OIDC provider connectivity test result","properties":{"status":{"type":"string"},"authorizationEndpoint":{"type":"string"}},"required":["authorizationEndpoint","status"]},"UpdateLicenseRequest":{"type":"object","properties":{"token":{"type":"string"}}},"CreateGroupRequest":{"type":"object","properties":{"name":{"type":"string"},"parentGroupId":{"type":"string","format":"uuid"}}},"CreateEnvironmentRequest":{"type":"object","properties":{"slug":{"type":"string"},"displayName":{"type":"string"},"production":{"type":"boolean"}}},"TestRequest":{"type":"object","properties":{"rules":{"type":"array","items":{"$ref":"#/components/schemas/TestRuleRequest"}},"claims":{"type":"object","additionalProperties":{"type":"object"}}}},"TestRuleRequest":{"type":"object","properties":{"id":{"type":"string"},"claim":{"type":"string"},"matchType":{"type":"string"},"matchValue":{"type":"string"},"action":{"type":"string"},"target":{"type":"string"},"priority":{"type":"integer","format":"int32"}}},"MatchedRuleResponse":{"type":"object","properties":{"ruleId":{"type":"string"},"priority":{"type":"integer","format":"int32"},"claim":{"type":"string"},"matchType":{"type":"string"},"matchValue":{"type":"string"},"action":{"type":"string"},"target":{"type":"string"}}},"TestResponse":{"type":"object","properties":{"matchedRules":{"type":"array","items":{"$ref":"#/components/schemas/MatchedRuleResponse"}},"effectiveRoles":{"type":"array","items":{"type":"string"}},"effectiveGroups":{"type":"array","items":{"type":"string"}},"fallback":{"type":"boolean"}}},"ExecutionDetail":{"type":"object","properties":{"executionId":{"type":"string"},"routeId":{"type":"string"},"instanceId":{"type":"string"},"applicationId":{"type":"string"},"environment":{"type":"string"},"status":{"type":"string"},"startTime":{"type":"string","format":"date-time"},"endTime":{"type":"string","format":"date-time"},"durationMs":{"type":"integer","format":"int64"},"correlationId":{"type":"string"},"exchangeId":{"type":"string"},"errorMessage":{"type":"string"},"errorStackTrace":{"type":"string"},"diagramContentHash":{"type":"string"},"processors":{"type":"array","items":{"$ref":"#/components/schemas/ProcessorNode"}},"inputBody":{"type":"string"},"outputBody":{"type":"string"},"inputHeaders":{"type":"string"},"outputHeaders":{"type":"string"},"inputProperties":{"type":"string"},"outputProperties":{"type":"string"},"attributes":{"type":"object","additionalProperties":{"type":"string"}},"errorType":{"type":"string"},"errorCategory":{"type":"string"},"rootCauseType":{"type":"string"},"rootCauseMessage":{"type":"string"},"traceId":{"type":"string"},"spanId":{"type":"string"}},"required":["applicationId","attributes","correlationId","diagramContentHash","durationMs","endTime","environment","errorCategory","errorMessage","errorStackTrace","errorType","exchangeId","executionId","inputBody","inputHeaders","inputProperties","instanceId","outputBody","outputHeaders","outputProperties","processors","rootCauseMessage","rootCauseType","routeId","spanId","startTime","status","traceId"]},"ProcessorNode":{"type":"object","properties":{"processorId":{"type":"string"},"processorType":{"type":"string"},"status":{"type":"string"},"startTime":{"type":"string","format":"date-time"},"endTime":{"type":"string","format":"date-time"},"durationMs":{"type":"integer","format":"int64"},"errorMessage":{"type":"string"},"errorStackTrace":{"type":"string"},"attributes":{"type":"object","additionalProperties":{"type":"string"}},"iteration":{"type":"integer","format":"int32"},"iterationSize":{"type":"integer","format":"int32"},"loopIndex":{"type":"integer","format":"int32"},"loopSize":{"type":"integer","format":"int32"},"splitIndex":{"type":"integer","format":"int32"},"splitSize":{"type":"integer","format":"int32"},"multicastIndex":{"type":"integer","format":"int32"},"resolvedEndpointUri":{"type":"string"},"errorType":{"type":"string"},"errorCategory":{"type":"string"},"rootCauseType":{"type":"string"},"rootCauseMessage":{"type":"string"},"errorHandlerType":{"type":"string"},"circuitBreakerState":{"type":"string"},"fallbackTriggered":{"type":"boolean"},"filterMatched":{"type":"boolean"},"duplicateMessage":{"type":"boolean"},"hasTraceData":{"type":"boolean"},"children":{"type":"array","items":{"$ref":"#/components/schemas/ProcessorNode"}}},"required":["attributes","children","circuitBreakerState","duplicateMessage","durationMs","endTime","errorCategory","errorHandlerType","errorMessage","errorStackTrace","errorType","fallbackTriggered","filterMatched","hasTraceData","iteration","iterationSize","loopIndex","loopSize","multicastIndex","processorId","processorType","resolvedEndpointUri","rootCauseMessage","rootCauseType","splitIndex","splitSize","startTime","status"]},"ExecutionStats":{"type":"object","properties":{"totalCount":{"type":"integer","format":"int64"},"failedCount":{"type":"integer","format":"int64"},"avgDurationMs":{"type":"integer","format":"int64"},"p99LatencyMs":{"type":"integer","format":"int64"},"activeCount":{"type":"integer","format":"int64"},"totalToday":{"type":"integer","format":"int64"},"prevTotalCount":{"type":"integer","format":"int64"},"prevFailedCount":{"type":"integer","format":"int64"},"prevAvgDurationMs":{"type":"integer","format":"int64"},"prevP99LatencyMs":{"type":"integer","format":"int64"},"slaCompliance":{"type":"number","format":"double"}},"required":["activeCount","avgDurationMs","failedCount","p99LatencyMs","prevAvgDurationMs","prevFailedCount","prevP99LatencyMs","prevTotalCount","slaCompliance","totalCount","totalToday"]},"StatsTimeseries":{"type":"object","properties":{"buckets":{"type":"array","items":{"$ref":"#/components/schemas/TimeseriesBucket"}}},"required":["buckets"]},"TimeseriesBucket":{"type":"object","properties":{"time":{"type":"string","format":"date-time"},"totalCount":{"type":"integer","format":"int64"},"failedCount":{"type":"integer","format":"int64"},"avgDurationMs":{"type":"integer","format":"int64"},"p99DurationMs":{"type":"integer","format":"int64"},"activeCount":{"type":"integer","format":"int64"}},"required":["activeCount","avgDurationMs","failedCount","p99DurationMs","time","totalCount"]},"PunchcardCell":{"type":"object","properties":{"weekday":{"type":"integer","format":"int32"},"hour":{"type":"integer","format":"int32"},"totalCount":{"type":"integer","format":"int64"},"failedCount":{"type":"integer","format":"int64"}}},"AgentSummary":{"type":"object","description":"Summary of an agent instance for sidebar display","properties":{"id":{"type":"string"},"name":{"type":"string"},"status":{"type":"string"},"tps":{"type":"number","format":"double"}},"required":["id","name","status","tps"]},"AppCatalogEntry":{"type":"object","description":"Application catalog entry with routes and agents","properties":{"appId":{"type":"string"},"routes":{"type":"array","items":{"$ref":"#/components/schemas/RouteSummary"}},"agents":{"type":"array","items":{"$ref":"#/components/schemas/AgentSummary"}},"agentCount":{"type":"integer","format":"int32"},"health":{"type":"string"},"exchangeCount":{"type":"integer","format":"int64"}},"required":["agentCount","agents","appId","exchangeCount","health","routes"]},"RouteSummary":{"type":"object","description":"Summary of a route within an application","properties":{"routeId":{"type":"string"},"exchangeCount":{"type":"integer","format":"int64"},"lastSeen":{"type":"string","format":"date-time"},"fromEndpointUri":{"type":"string","description":"The from() endpoint URI, e.g. 'direct:processOrder'"},"routeState":{"type":"string","description":"Operational state of the route: stopped, suspended, or null (started/default)"}},"required":["exchangeCount","fromEndpointUri","lastSeen","routeId","routeState"]},"RouteMetrics":{"type":"object","description":"Aggregated route performance metrics","properties":{"routeId":{"type":"string"},"appId":{"type":"string"},"exchangeCount":{"type":"integer","format":"int64"},"successRate":{"type":"number","format":"double"},"avgDurationMs":{"type":"number","format":"double"},"p99DurationMs":{"type":"number","format":"double"},"errorRate":{"type":"number","format":"double"},"throughputPerSec":{"type":"number","format":"double"},"sparkline":{"type":"array","items":{"type":"number","format":"double"}},"slaCompliance":{"type":"number","format":"double"}},"required":["appId","avgDurationMs","errorRate","exchangeCount","p99DurationMs","routeId","slaCompliance","sparkline","successRate","throughputPerSec"]},"ProcessorMetrics":{"type":"object","properties":{"processorId":{"type":"string"},"processorType":{"type":"string"},"routeId":{"type":"string"},"appId":{"type":"string"},"totalCount":{"type":"integer","format":"int64"},"failedCount":{"type":"integer","format":"int64"},"avgDurationMs":{"type":"number","format":"double"},"p99DurationMs":{"type":"number","format":"double"},"errorRate":{"type":"number","format":"double"}},"required":["appId","avgDurationMs","errorRate","failedCount","p99DurationMs","processorId","processorType","routeId","totalCount"]},"LogEntryResponse":{"type":"object","description":"Application log entry","properties":{"timestamp":{"type":"string","description":"Log timestamp (ISO-8601)"},"level":{"type":"string","description":"Log level (INFO, WARN, ERROR, DEBUG, TRACE)"},"loggerName":{"type":"string","description":"Logger name"},"message":{"type":"string","description":"Log message"},"threadName":{"type":"string","description":"Thread name"},"stackTrace":{"type":"string","description":"Stack trace (if present)"},"exchangeId":{"type":"string","description":"Camel exchange ID (if present)"},"instanceId":{"type":"string","description":"Agent instance ID"},"application":{"type":"string","description":"Application ID"},"mdc":{"type":"object","additionalProperties":{"type":"string"},"description":"MDC context map"},"source":{"type":"string","description":"Log source: app or agent"}}},"LogSearchPageResponse":{"type":"object","description":"Log search response with cursor pagination and level counts","properties":{"data":{"type":"array","description":"Log entries for the current page","items":{"$ref":"#/components/schemas/LogEntryResponse"}},"nextCursor":{"type":"string","description":"Cursor for next page (null if no more results)"},"hasMore":{"type":"boolean","description":"Whether more results exist beyond this page"},"levelCounts":{"type":"object","additionalProperties":{"type":"integer","format":"int64"},"description":"Count of logs per level (unaffected by level filter)"}}},"TopError":{"type":"object","properties":{"errorType":{"type":"string"},"routeId":{"type":"string"},"processorId":{"type":"string"},"count":{"type":"integer","format":"int64"},"velocity":{"type":"number","format":"double"},"trend":{"type":"string"},"lastSeen":{"type":"string","format":"date-time"}}},"App":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"environmentId":{"type":"string","format":"uuid"},"slug":{"type":"string"},"displayName":{"type":"string"},"containerConfig":{"type":"object","additionalProperties":{"type":"object"}},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"DiagramLayout":{"type":"object","properties":{"width":{"type":"number","format":"double"},"height":{"type":"number","format":"double"},"nodes":{"type":"array","items":{"$ref":"#/components/schemas/PositionedNode"}},"edges":{"type":"array","items":{"$ref":"#/components/schemas/PositionedEdge"}}}},"PositionedEdge":{"type":"object","properties":{"sourceId":{"type":"string"},"targetId":{"type":"string"},"label":{"type":"string"},"points":{"type":"array","items":{"type":"array","items":{"type":"number","format":"double"}}}}},"PositionedNode":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"x":{"type":"number","format":"double"},"y":{"type":"number","format":"double"},"width":{"type":"number","format":"double"},"height":{"type":"number","format":"double"},"endpointUri":{"type":"string"}}},"AppConfigResponse":{"type":"object","properties":{"config":{"$ref":"#/components/schemas/ApplicationConfig"},"globalSensitiveKeys":{"type":"array","items":{"type":"string"}},"mergedSensitiveKeys":{"type":"array","items":{"type":"string"}}}},"AgentInstanceResponse":{"type":"object","description":"Agent instance summary with runtime metrics","properties":{"instanceId":{"type":"string"},"displayName":{"type":"string"},"applicationId":{"type":"string"},"environmentId":{"type":"string"},"status":{"type":"string"},"routeIds":{"type":"array","items":{"type":"string"}},"registeredAt":{"type":"string","format":"date-time"},"lastHeartbeat":{"type":"string","format":"date-time"},"version":{"type":"string"},"capabilities":{"type":"object","additionalProperties":{"type":"object"}},"tps":{"type":"number","format":"double"},"errorRate":{"type":"number","format":"double"},"activeRoutes":{"type":"integer","format":"int32"},"totalRoutes":{"type":"integer","format":"int32"},"uptimeSeconds":{"type":"integer","format":"int64"},"cpuUsage":{"type":"number","format":"double","description":"Recent average CPU usage (0.0–1.0), -1 if unavailable"}},"required":["activeRoutes","applicationId","capabilities","cpuUsage","displayName","environmentId","errorRate","instanceId","lastHeartbeat","registeredAt","routeIds","status","totalRoutes","tps","uptimeSeconds","version"]},"AgentMetricsResponse":{"type":"object","properties":{"metrics":{"type":"object","additionalProperties":{"type":"array","items":{"$ref":"#/components/schemas/MetricBucket"}}}},"required":["metrics"]},"MetricBucket":{"type":"object","properties":{"time":{"type":"string","format":"date-time"},"value":{"type":"number","format":"double"}},"required":["time","value"]},"AgentEventPageResponse":{"type":"object","description":"Cursor-paginated agent event list","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AgentEventResponse"}},"nextCursor":{"type":"string"},"hasMore":{"type":"boolean"}}},"AgentEventResponse":{"type":"object","description":"Agent lifecycle event","properties":{"id":{"type":"integer","format":"int64"},"instanceId":{"type":"string"},"applicationId":{"type":"string"},"eventType":{"type":"string"},"detail":{"type":"string"},"timestamp":{"type":"string","format":"date-time"}},"required":["applicationId","detail","eventType","id","instanceId","timestamp"]},"CatalogApp":{"type":"object","description":"Unified catalog entry combining app records with live agent data","properties":{"slug":{"type":"string","description":"Application slug (universal identifier)"},"displayName":{"type":"string","description":"Display name"},"managed":{"type":"boolean","description":"True if a managed App record exists in the database"},"environmentSlug":{"type":"string","description":"Environment slug"},"health":{"type":"string","description":"Composite health: deployment status + agent health"},"healthTooltip":{"type":"string","description":"Human-readable tooltip explaining the health state"},"agentCount":{"type":"integer","format":"int32","description":"Number of connected agents"},"routes":{"type":"array","description":"Live routes from agents","items":{"$ref":"#/components/schemas/RouteSummary"}},"agents":{"type":"array","description":"Connected agent summaries","items":{"$ref":"#/components/schemas/AgentSummary"}},"exchangeCount":{"type":"integer","format":"int64","description":"Total exchange count from ClickHouse"},"deployment":{"$ref":"#/components/schemas/DeploymentSummary","description":"Active deployment info, null if no deployment"}}},"DeploymentSummary":{"type":"object","properties":{"status":{"type":"string"},"replicas":{"type":"string"},"version":{"type":"integer","format":"int32"}}},"OidcPublicConfigResponse":{"type":"object","description":"OIDC configuration for SPA login flow","properties":{"issuer":{"type":"string"},"clientId":{"type":"string"},"authorizationEndpoint":{"type":"string"},"endSessionEndpoint":{"type":"string","description":"Present if the provider supports RP-initiated logout"},"resource":{"type":"string","description":"RFC 8707 resource indicator for the authorization request"},"additionalScopes":{"type":"array","description":"Additional scopes to request beyond openid email profile","items":{"type":"string"}}},"required":["authorizationEndpoint","clientId","issuer"]},"GroupSummary":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"RoleSummary":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"system":{"type":"boolean"},"source":{"type":"string"}}},"UserDetail":{"type":"object","properties":{"userId":{"type":"string"},"provider":{"type":"string"},"email":{"type":"string"},"displayName":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"directRoles":{"type":"array","items":{"$ref":"#/components/schemas/RoleSummary"}},"directGroups":{"type":"array","items":{"$ref":"#/components/schemas/GroupSummary"}},"effectiveRoles":{"type":"array","items":{"$ref":"#/components/schemas/RoleSummary"}},"effectiveGroups":{"type":"array","items":{"$ref":"#/components/schemas/GroupSummary"}}}},"SseEmitter":{"type":"object","properties":{"timeout":{"type":"integer","format":"int64"}}},"UsageStats":{"type":"object","properties":{"key":{"type":"string"},"count":{"type":"integer","format":"int64"},"avgDurationMs":{"type":"integer","format":"int64"}}},"SensitiveKeysConfig":{"type":"object","properties":{"keys":{"type":"array","items":{"type":"string"}}}},"RoleDetail":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":"string"},"scope":{"type":"string"},"system":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"},"assignedGroups":{"type":"array","items":{"$ref":"#/components/schemas/GroupSummary"}},"directUsers":{"type":"array","items":{"$ref":"#/components/schemas/UserSummary"}},"effectivePrincipals":{"type":"array","items":{"$ref":"#/components/schemas/UserSummary"}}}},"UserSummary":{"type":"object","properties":{"userId":{"type":"string"},"displayName":{"type":"string"},"provider":{"type":"string"}}},"RbacStats":{"type":"object","properties":{"userCount":{"type":"integer","format":"int32"},"activeUserCount":{"type":"integer","format":"int32"},"groupCount":{"type":"integer","format":"int32"},"maxGroupDepth":{"type":"integer","format":"int32"},"roleCount":{"type":"integer","format":"int32"}}},"LicenseInfo":{"type":"object","properties":{"tier":{"type":"string"},"features":{"type":"array","items":{"type":"string","enum":["topology","lineage","correlation","debugger","replay"]},"uniqueItems":true},"limits":{"type":"object","additionalProperties":{"type":"integer","format":"int32"}},"issuedAt":{"type":"string","format":"date-time"},"expiresAt":{"type":"string","format":"date-time"},"expired":{"type":"boolean"}}},"GroupDetail":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"parentGroupId":{"type":"string","format":"uuid"},"createdAt":{"type":"string","format":"date-time"},"directRoles":{"type":"array","items":{"$ref":"#/components/schemas/RoleSummary"}},"effectiveRoles":{"type":"array","items":{"$ref":"#/components/schemas/RoleSummary"}},"members":{"type":"array","items":{"$ref":"#/components/schemas/UserSummary"}},"childGroups":{"type":"array","items":{"$ref":"#/components/schemas/GroupSummary"}}}},"TableSizeResponse":{"type":"object","description":"Table size and row count information","properties":{"tableName":{"type":"string","description":"Table name"},"rowCount":{"type":"integer","format":"int64","description":"Approximate row count"},"dataSize":{"type":"string","description":"Human-readable data size"},"indexSize":{"type":"string","description":"Human-readable index size"},"dataSizeBytes":{"type":"integer","format":"int64","description":"Data size in bytes"},"indexSizeBytes":{"type":"integer","format":"int64","description":"Index size in bytes"}}},"DatabaseStatusResponse":{"type":"object","description":"Database connection and version status","properties":{"connected":{"type":"boolean","description":"Whether the database is reachable"},"version":{"type":"string","description":"PostgreSQL version string"},"host":{"type":"string","description":"Database host"},"schema":{"type":"string","description":"Current schema"}}},"ActiveQueryResponse":{"type":"object","description":"Currently running database query","properties":{"pid":{"type":"integer","format":"int32","description":"Backend process ID"},"durationSeconds":{"type":"number","format":"double","description":"Query duration in seconds"},"state":{"type":"string","description":"Backend state (active, idle, etc.)"},"query":{"type":"string","description":"SQL query text"}}},"ConnectionPoolResponse":{"type":"object","description":"HikariCP connection pool statistics","properties":{"activeConnections":{"type":"integer","format":"int32","description":"Number of currently active connections"},"idleConnections":{"type":"integer","format":"int32","description":"Number of idle connections"},"pendingThreads":{"type":"integer","format":"int32","description":"Number of threads waiting for a connection"},"maxWaitMs":{"type":"integer","format":"int64","description":"Maximum wait time in milliseconds"},"maxPoolSize":{"type":"integer","format":"int32","description":"Maximum pool size"}}},"ClickHouseTableInfo":{"type":"object","description":"ClickHouse table information","properties":{"name":{"type":"string"},"engine":{"type":"string"},"rowCount":{"type":"integer","format":"int64"},"dataSize":{"type":"string"},"dataSizeBytes":{"type":"integer","format":"int64"},"partitionCount":{"type":"integer","format":"int32"}}},"ClickHouseStatusResponse":{"type":"object","description":"ClickHouse cluster status","properties":{"reachable":{"type":"boolean"},"version":{"type":"string"},"uptime":{"type":"string"},"host":{"type":"string"}}},"ClickHouseQueryInfo":{"type":"object","description":"Active ClickHouse query information","properties":{"queryId":{"type":"string"},"elapsedSeconds":{"type":"number","format":"double"},"memory":{"type":"string"},"readRows":{"type":"integer","format":"int64"},"query":{"type":"string"}}},"IndexerPipelineResponse":{"type":"object","description":"Search indexer pipeline statistics","properties":{"queueDepth":{"type":"integer","format":"int32"},"maxQueueSize":{"type":"integer","format":"int32"},"failedCount":{"type":"integer","format":"int64"},"indexedCount":{"type":"integer","format":"int64"},"debounceMs":{"type":"integer","format":"int64"},"indexingRate":{"type":"number","format":"double"},"lastIndexedAt":{"type":"string","format":"date-time"}}},"ClickHousePerformanceResponse":{"type":"object","description":"ClickHouse storage and performance metrics","properties":{"diskSize":{"type":"string"},"uncompressedSize":{"type":"string"},"compressionRatio":{"type":"number","format":"double"},"totalRows":{"type":"integer","format":"int64"},"partCount":{"type":"integer","format":"int32"},"memoryUsage":{"type":"string"},"currentQueries":{"type":"integer","format":"int32"}}},"AuditLogPageResponse":{"type":"object","description":"Paginated audit log entries","properties":{"items":{"type":"array","description":"Audit log entries","items":{"$ref":"#/components/schemas/AuditRecord"}},"totalCount":{"type":"integer","format":"int64","description":"Total number of matching entries"},"page":{"type":"integer","format":"int32","description":"Current page number (0-based)"},"pageSize":{"type":"integer","format":"int32","description":"Page size"},"totalPages":{"type":"integer","format":"int32","description":"Total number of pages"}}},"AuditRecord":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"timestamp":{"type":"string","format":"date-time"},"username":{"type":"string"},"action":{"type":"string"},"category":{"type":"string","enum":["INFRA","AUTH","USER_MGMT","CONFIG","RBAC","AGENT"]},"target":{"type":"string"},"detail":{"type":"object","additionalProperties":{"type":"object"}},"result":{"type":"string","enum":["SUCCESS","FAILURE"]},"ipAddress":{"type":"string"},"userAgent":{"type":"string"}}}},"securitySchemes":{"bearer":{"type":"http","scheme":"bearer","bearerFormat":"JWT"}}}} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"Cameleer Server API","version":"1.0"},"servers":[{"url":"/api/v1","description":"Relative"}],"security":[{"bearer":[]}],"tags":[{"name":"Route Metrics","description":"Route performance metrics (env-scoped)"},{"name":"Database Admin","description":"Database monitoring and management (ADMIN only)"},{"name":"Threshold Admin","description":"Monitoring threshold configuration (ADMIN only)"},{"name":"Agent Commands","description":"Command push endpoints for agent communication"},{"name":"Agent List","description":"List registered agents in an environment"},{"name":"Sensitive Keys Admin","description":"Global sensitive key masking configuration (ADMIN only)"},{"name":"License Admin","description":"License management"},{"name":"Role Admin","description":"Role management (ADMIN only)"},{"name":"RBAC Stats","description":"RBAC statistics (ADMIN only)"},{"name":"OIDC Config Admin","description":"OIDC provider configuration (ADMIN only)"},{"name":"Alerts Inbox","description":"In-app alert inbox, ack and read tracking (env-scoped)"},{"name":"Application Config","description":"Per-application observability configuration (user-facing)"},{"name":"App Management","description":"Application lifecycle and JAR uploads (env-scoped)"},{"name":"Catalog","description":"Unified application catalog"},{"name":"ClickHouse Admin","description":"ClickHouse monitoring and diagnostics (ADMIN only)"},{"name":"Alert Silences","description":"Alert silence management (env-scoped)"},{"name":"Ingestion","description":"Data ingestion endpoints"},{"name":"Group Admin","description":"Group management (ADMIN only)"},{"name":"Usage Analytics","description":"UI usage pattern analytics"},{"name":"Alert Notifications","description":"Outbound webhook notification management"},{"name":"Deployment Management","description":"Deploy, stop, promote, and view logs"},{"name":"Detail","description":"Execution detail and processor snapshot endpoints"},{"name":"Outbound Connections Admin","description":"Admin-managed outbound HTTPS destinations"},{"name":"Agent Config","description":"Agent-authoritative config read (AGENT only)"},{"name":"User Admin","description":"User management (ADMIN only)"},{"name":"Agent Management","description":"Agent registration and lifecycle endpoints"},{"name":"Authentication","description":"Login and token refresh endpoints"},{"name":"Agent Events","description":"Agent lifecycle event log (env-scoped)"},{"name":"Route Catalog","description":"Route catalog and discovery (env-scoped)"},{"name":"Application Logs","description":"Query application logs (env-scoped)"},{"name":"Agent SSE","description":"Server-Sent Events endpoint for agent communication"},{"name":"Search","description":"Transaction search and stats (env-scoped)"},{"name":"Audit Log","description":"Audit log viewer (ADMIN only)"},{"name":"Claim Mapping Admin","description":"Manage OIDC claim-to-role/group mapping rules"},{"name":"Diagrams","description":"Diagram rendering endpoints"},{"name":"Environment Admin","description":"Environment management (ADMIN only)"},{"name":"App Settings","description":"Per-application dashboard settings (ADMIN/OPERATOR)"},{"name":"Alert Rules","description":"Alert rule management (env-scoped)"}],"paths":{"/environments/{envSlug}/apps/{appSlug}/settings":{"get":{"tags":["App Settings"],"summary":"Get settings for an application in this environment (returns defaults if not configured)","operationId":"getByAppId","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AppSettings"}}}}}},"put":{"tags":["App Settings"],"summary":"Create or update settings for an application in this environment","operationId":"update","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppSettingsRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AppSettings"}}}}}},"delete":{"tags":["App Settings"],"summary":"Delete application settings for this environment (reverts to defaults)","operationId":"delete","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/environments/{envSlug}/apps/{appSlug}/container-config":{"put":{"tags":["App Management"],"summary":"Update container config for this app","operationId":"updateContainerConfig","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"object"}}}},"required":true},"responses":{"200":{"description":"Container config updated","content":{"*/*":{"schema":{"type":"object"}}}},"400":{"description":"Invalid configuration","content":{"*/*":{"schema":{"type":"object"}}}},"404":{"description":"App not found in this environment","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/environments/{envSlug}/apps/{appSlug}/config":{"get":{"tags":["Application Config"],"summary":"Get application config for this environment","description":"Returns stored config merged with global sensitive keys. Falls back to defaults if no row is persisted yet.","operationId":"getConfig","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Config returned","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AppConfigResponse"}}}}}},"put":{"tags":["Application Config"],"summary":"Update application config for this environment","description":"Saves config and pushes CONFIG_UPDATE to LIVE agents of this application in the given environment","operationId":"updateConfig","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationConfig"}}},"required":true},"responses":{"200":{"description":"Config saved and pushed","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ConfigUpdateResponse"}}}}}}},"/environments/{envSlug}/alerts/silences/{id}":{"put":{"tags":["Alert Silences"],"operationId":"update_1","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertSilenceRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AlertSilenceResponse"}}}}}},"delete":{"tags":["Alert Silences"],"operationId":"delete_1","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK"}}}},"/environments/{envSlug}/alerts/rules/{id}":{"get":{"tags":["Alert Rules"],"operationId":"get","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AlertRuleResponse"}}}}}},"put":{"tags":["Alert Rules"],"operationId":"update_2","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertRuleRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AlertRuleResponse"}}}}}},"delete":{"tags":["Alert Rules"],"operationId":"delete_2","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK"}}}},"/admin/users/{userId}":{"get":{"tags":["User Admin"],"summary":"Get user by ID with RBAC detail","operationId":"getUser","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"User found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDetail"}}}},"404":{"description":"User not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDetail"}}}}}},"put":{"tags":["User Admin"],"summary":"Update user display name or email","operationId":"updateUser","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true},"responses":{"200":{"description":"User updated"},"404":{"description":"User not found"}}},"delete":{"tags":["User Admin"],"summary":"Delete user","operationId":"deleteUser","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"User deleted"},"409":{"description":"Cannot delete the last admin user"}}}},"/admin/thresholds":{"get":{"tags":["Threshold Admin"],"summary":"Get current threshold configuration","operationId":"getThresholds","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ThresholdConfig"}}}}}},"put":{"tags":["Threshold Admin"],"summary":"Update threshold configuration","operationId":"updateThresholds","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ThresholdConfigRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ThresholdConfig"}}}}}}},"/admin/sensitive-keys":{"get":{"tags":["Sensitive Keys Admin"],"summary":"Get global sensitive keys configuration","operationId":"getSensitiveKeys","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SensitiveKeysConfig"}}}}}},"put":{"tags":["Sensitive Keys Admin"],"summary":"Update global sensitive keys configuration","description":"Saves the global sensitive keys. Optionally fans out merged keys to all live agents.","operationId":"updateSensitiveKeys","parameters":[{"name":"pushToAgents","in":"query","required":false,"schema":{"type":"boolean","default":false}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SensitiveKeysRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SensitiveKeysResponse"}}}}}}},"/admin/roles/{id}":{"get":{"tags":["Role Admin"],"summary":"Get role by ID with effective principals","operationId":"getRole","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Role found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RoleDetail"}}}},"404":{"description":"Role not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RoleDetail"}}}}}},"put":{"tags":["Role Admin"],"summary":"Update a custom role","operationId":"updateRole","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRoleRequest"}}},"required":true},"responses":{"200":{"description":"Role updated"},"403":{"description":"Cannot modify system role"},"404":{"description":"Role not found"}}},"delete":{"tags":["Role Admin"],"summary":"Delete a custom role","operationId":"deleteRole","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Role deleted"},"403":{"description":"Cannot delete system role"},"404":{"description":"Role not found"}}}},"/admin/outbound-connections/{id}":{"get":{"tags":["Outbound Connections Admin"],"operationId":"get_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/OutboundConnectionDto"}}}}}},"put":{"tags":["Outbound Connections Admin"],"operationId":"update_3","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutboundConnectionRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/OutboundConnectionDto"}}}}}},"delete":{"tags":["Outbound Connections Admin"],"operationId":"delete_3","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK"}}}},"/admin/oidc":{"get":{"tags":["OIDC Config Admin"],"summary":"Get OIDC configuration","operationId":"getConfig_1","responses":{"200":{"description":"Current OIDC configuration (client_secret masked)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/OidcAdminConfigResponse"}}}}}},"put":{"tags":["OIDC Config Admin"],"summary":"Save OIDC configuration","operationId":"saveConfig","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OidcAdminConfigRequest"}}},"required":true},"responses":{"200":{"description":"Configuration saved","content":{"*/*":{"schema":{"$ref":"#/components/schemas/OidcAdminConfigResponse"}}}},"400":{"description":"Invalid configuration","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["OIDC Config Admin"],"summary":"Delete OIDC configuration","operationId":"deleteConfig","responses":{"204":{"description":"Configuration deleted"}}}},"/admin/groups/{id}":{"get":{"tags":["Group Admin"],"summary":"Get group by ID with effective roles","operationId":"getGroup","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Group found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/GroupDetail"}}}},"404":{"description":"Group not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/GroupDetail"}}}}}},"put":{"tags":["Group Admin"],"summary":"Update group name or parent","operationId":"updateGroup","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateGroupRequest"}}},"required":true},"responses":{"200":{"description":"Group updated"},"404":{"description":"Group not found"},"409":{"description":"Cycle detected in group hierarchy"}}},"delete":{"tags":["Group Admin"],"summary":"Delete group","operationId":"deleteGroup","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Group deleted"},"404":{"description":"Group not found"}}}},"/admin/environments/{envSlug}":{"get":{"tags":["Environment Admin"],"summary":"Get environment by slug","operationId":"getEnvironment","parameters":[{"name":"envSlug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Environment found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Environment"}}}},"404":{"description":"Environment not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Environment"}}}}}},"put":{"tags":["Environment Admin"],"summary":"Update an environment's mutable fields (displayName, production, enabled)","description":"Slug is immutable after creation and cannot be changed. Any slug field in the request body is ignored.","operationId":"updateEnvironment","parameters":[{"name":"envSlug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateEnvironmentRequest"}}},"required":true},"responses":{"200":{"description":"Environment updated","content":{"*/*":{"schema":{"type":"object"}}}},"404":{"description":"Environment not found","content":{"*/*":{"schema":{"type":"object"}}}}}},"delete":{"tags":["Environment Admin"],"summary":"Delete an environment","operationId":"deleteEnvironment","parameters":[{"name":"envSlug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Environment deleted","content":{"*/*":{"schema":{"type":"object"}}}},"400":{"description":"Cannot delete default environment","content":{"*/*":{"schema":{"type":"object"}}}},"404":{"description":"Environment not found","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/admin/environments/{envSlug}/jar-retention":{"put":{"tags":["Environment Admin"],"summary":"Update JAR retention policy for an environment","operationId":"updateJarRetention","parameters":[{"name":"envSlug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JarRetentionRequest"}}},"required":true},"responses":{"200":{"description":"Retention policy updated","content":{"*/*":{"schema":{"type":"object"}}}},"404":{"description":"Environment not found","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/admin/environments/{envSlug}/default-container-config":{"put":{"tags":["Environment Admin"],"summary":"Update default container config for an environment","operationId":"updateDefaultContainerConfig","parameters":[{"name":"envSlug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"object"}}}},"required":true},"responses":{"200":{"description":"Default container config updated","content":{"*/*":{"schema":{"type":"object"}}}},"400":{"description":"Invalid configuration","content":{"*/*":{"schema":{"type":"object"}}}},"404":{"description":"Environment not found","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/admin/claim-mappings/{id}":{"get":{"tags":["Claim Mapping Admin"],"summary":"Get a claim mapping rule by ID","operationId":"get_2","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ClaimMappingRule"}}}}}},"put":{"tags":["Claim Mapping Admin"],"summary":"Update a claim mapping rule","operationId":"update_4","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRuleRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ClaimMappingRule"}}}}}},"delete":{"tags":["Claim Mapping Admin"],"summary":"Delete a claim mapping rule","operationId":"delete_4","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK"}}}},"/environments/{envSlug}/executions/search":{"post":{"tags":["Search"],"summary":"Advanced search with all filters","description":"Env from the path overrides any environment field in the body.","operationId":"searchPost","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SearchResultExecutionSummary"}}}}}}},"/environments/{envSlug}/apps":{"get":{"tags":["App Management"],"summary":"List apps in this environment","operationId":"listApps","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}}],"responses":{"200":{"description":"App list returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/App"}}}}}}},"post":{"tags":["App Management"],"summary":"Create a new app in this environment","description":"Slug must match ^[a-z0-9][a-z0-9-]{0,63}$ and be unique within the environment. Slug is immutable after creation.","operationId":"createApp","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAppRequest"}}},"required":true},"responses":{"201":{"description":"App created","content":{"*/*":{"schema":{"type":"object"}}}},"400":{"description":"Invalid slug, or slug already exists in this environment","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/environments/{envSlug}/apps/{appSlug}/versions":{"get":{"tags":["App Management"],"summary":"List versions for this app","operationId":"listVersions","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Version list returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AppVersion"}}}}}}},"post":{"tags":["App Management"],"summary":"Upload a JAR for a new version of this app","operationId":"uploadJar","parameters":[{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"env":{"$ref":"#/components/schemas/Environment"},"file":{"type":"string","format":"binary"}},"required":["file"]}}}},"responses":{"201":{"description":"JAR uploaded and version created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AppVersion"}}}},"404":{"description":"App not found in this environment","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AppVersion"}}}}}}},"/environments/{envSlug}/apps/{appSlug}/deployments":{"get":{"tags":["Deployment Management"],"summary":"List deployments for this app in this environment","operationId":"listDeployments","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deployment list returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Deployment"}}}}}}},"post":{"tags":["Deployment Management"],"summary":"Create and start a new deployment for this app in this environment","operationId":"deploy","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeployRequest"}}},"required":true},"responses":{"202":{"description":"Deployment accepted and starting","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Deployment"}}}}}}},"/environments/{envSlug}/apps/{appSlug}/deployments/{deploymentId}/stop":{"post":{"tags":["Deployment Management"],"summary":"Stop a running deployment","operationId":"stop","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}},{"name":"deploymentId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Deployment stopped","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Deployment"}}}},"404":{"description":"Deployment not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Deployment"}}}}}}},"/environments/{envSlug}/apps/{appSlug}/deployments/{deploymentId}/promote":{"post":{"tags":["Deployment Management"],"summary":"Promote this deployment to a different environment","description":"Target environment is specified by slug in the request body. The same app slug must exist in the target environment (or be created separately first).","operationId":"promote","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}},{"name":"deploymentId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromoteRequest"}}},"required":true},"responses":{"202":{"description":"Promotion accepted and starting","content":{"*/*":{"schema":{"type":"object"}}}},"404":{"description":"Deployment or target environment not found","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/environments/{envSlug}/apps/{appSlug}/config/test-expression":{"post":{"tags":["Application Config"],"summary":"Test a tap expression against sample data via a live agent in this environment","operationId":"testExpression","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestExpressionRequest"}}},"required":true},"responses":{"200":{"description":"Expression evaluated successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TestExpressionResponse"}}}},"404":{"description":"No live agent available for this application in this environment","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TestExpressionResponse"}}}},"504":{"description":"Agent did not respond in time","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TestExpressionResponse"}}}}}}},"/environments/{envSlug}/alerts/{id}/read":{"post":{"tags":["Alerts Inbox"],"operationId":"read","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK"}}}},"/environments/{envSlug}/alerts/{id}/ack":{"post":{"tags":["Alerts Inbox"],"operationId":"ack","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AlertDto"}}}}}}},"/environments/{envSlug}/alerts/silences":{"get":{"tags":["Alert Silences"],"operationId":"list","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AlertSilenceResponse"}}}}}}},"post":{"tags":["Alert Silences"],"operationId":"create","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertSilenceRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AlertSilenceResponse"}}}}}}},"/environments/{envSlug}/alerts/rules":{"get":{"tags":["Alert Rules"],"operationId":"list_1","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AlertRuleResponse"}}}}}}},"post":{"tags":["Alert Rules"],"operationId":"create_1","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertRuleRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AlertRuleResponse"}}}}}}},"/environments/{envSlug}/alerts/rules/{id}/test-evaluate":{"post":{"tags":["Alert Rules"],"operationId":"testEvaluate","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestEvaluateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TestEvaluateResponse"}}}}}}},"/environments/{envSlug}/alerts/rules/{id}/render-preview":{"post":{"tags":["Alert Rules"],"operationId":"renderPreview","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderPreviewRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RenderPreviewResponse"}}}}}}},"/environments/{envSlug}/alerts/rules/{id}/enable":{"post":{"tags":["Alert Rules"],"operationId":"enable","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AlertRuleResponse"}}}}}}},"/environments/{envSlug}/alerts/rules/{id}/disable":{"post":{"tags":["Alert Rules"],"operationId":"disable","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AlertRuleResponse"}}}}}}},"/environments/{envSlug}/alerts/bulk-read":{"post":{"tags":["Alerts Inbox"],"operationId":"bulkRead","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkReadRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/data/metrics":{"post":{"tags":["Ingestion"],"summary":"Ingest agent metrics","description":"Accepts an array of MetricsSnapshot objects","operationId":"ingestMetrics","requestBody":{"content":{"application/json":{"schema":{"type":"string"}}},"required":true},"responses":{"202":{"description":"Data accepted for processing"},"400":{"description":"Invalid payload"},"503":{"description":"Buffer full, retry later"}}}},"/data/logs":{"post":{"tags":["Ingestion"],"summary":"Ingest application log entries","description":"Accepts a batch of log entries from an agent. Entries are buffered and flushed periodically.","operationId":"ingestLogs","requestBody":{"content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/LogEntry"}}}},"required":true},"responses":{"202":{"description":"Logs accepted for indexing"}}}},"/data/executions":{"post":{"tags":["Ingestion"],"summary":"Ingest execution chunk","operationId":"ingestChunks","requestBody":{"content":{"application/json":{"schema":{"type":"string"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/data/events":{"post":{"tags":["Ingestion"],"summary":"Ingest agent events","operationId":"ingestEvents","requestBody":{"content":{"application/json":{"schema":{"type":"string"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/data/diagrams":{"post":{"tags":["Ingestion"],"summary":"Ingest route diagram data","description":"Accepts a single RouteGraph or an array of RouteGraphs","operationId":"ingestDiagrams","requestBody":{"content":{"application/json":{"schema":{"type":"string"}}},"required":true},"responses":{"202":{"description":"Data accepted for processing"}}}},"/auth/refresh":{"post":{"tags":["Authentication"],"summary":"Refresh access token","operationId":"refresh","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshRequest"}}},"required":true},"responses":{"200":{"description":"Token refreshed","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AuthTokenResponse"}}}},"401":{"description":"Invalid refresh token","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/auth/oidc/callback":{"post":{"tags":["Authentication"],"summary":"Exchange OIDC authorization code for JWTs","operationId":"callback","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CallbackRequest"}}},"required":true},"responses":{"200":{"description":"Authentication successful","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AuthTokenResponse"}}}},"401":{"description":"OIDC authentication failed","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Account not provisioned","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"OIDC not configured or disabled","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AuthTokenResponse"}}}}}}},"/auth/login":{"post":{"tags":["Authentication"],"summary":"Login with local credentials","operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"Login successful","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AuthTokenResponse"}}}},"401":{"description":"Invalid credentials","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Account locked due to too many failed attempts","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AuthTokenResponse"}}}}}}},"/alerts/notifications/{id}/retry":{"post":{"tags":["Alert Notifications"],"operationId":"retry","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AlertNotificationDto"}}}}}}},"/agents/{id}/replay":{"post":{"tags":["Agent Commands"],"summary":"Replay an exchange on a specific agent (synchronous)","description":"Sends a replay command and waits for the agent to complete the replay. Returns the replay result including status, replayExchangeId, and duration.","operationId":"replayExchange","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplayRequest"}}},"required":true},"responses":{"200":{"description":"Replay completed (check status for success/failure)","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ReplayResponse"}}}},"404":{"description":"Agent not found or not connected","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ReplayResponse"}}}},"504":{"description":"Agent did not respond in time","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ReplayResponse"}}}}}}},"/agents/{id}/refresh":{"post":{"tags":["Agent Management"],"summary":"Refresh access token","description":"Issues a new access JWT from a valid refresh token","operationId":"refresh_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentRefreshRequest"}}},"required":true},"responses":{"200":{"description":"New access token issued","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AgentRefreshResponse"}}}},"401":{"description":"Invalid or expired refresh token","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AgentRefreshResponse"}}}},"404":{"description":"Agent not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AgentRefreshResponse"}}}}}}},"/agents/{id}/heartbeat":{"post":{"tags":["Agent Management"],"summary":"Agent heartbeat ping","description":"Updates the agent's last heartbeat timestamp. Auto-registers the agent if not in registry (e.g. after server restart).","operationId":"heartbeat","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HeartbeatRequest"}}}},"responses":{"200":{"description":"Heartbeat accepted"}}}},"/agents/{id}/deregister":{"post":{"tags":["Agent Management"],"summary":"Deregister agent","description":"Removes the agent from the registry. Called by agents during graceful shutdown.","operationId":"deregister","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Agent deregistered"},"404":{"description":"Agent not registered"}}}},"/agents/{id}/commands":{"post":{"tags":["Agent Commands"],"summary":"Send command to a specific agent","description":"Sends a command to the specified agent via SSE","operationId":"sendCommand","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommandRequest"}}},"required":true},"responses":{"202":{"description":"Command accepted","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CommandSingleResponse"}}}},"400":{"description":"Invalid command payload","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CommandSingleResponse"}}}},"404":{"description":"Agent not registered","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CommandSingleResponse"}}}}}}},"/agents/{id}/commands/{commandId}/ack":{"post":{"tags":["Agent Commands"],"summary":"Acknowledge command receipt","description":"Agent acknowledges that it has received and processed a command, with result status and message","operationId":"acknowledgeCommand","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"commandId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommandAckRequest"}}}},"responses":{"200":{"description":"Command acknowledged"},"404":{"description":"Command not found"}}}},"/agents/register":{"post":{"tags":["Agent Management"],"summary":"Register an agent","description":"Registers a new agent or re-registers an existing one. Requires bootstrap token in Authorization header.","operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentRegistrationRequest"}}},"required":true},"responses":{"200":{"description":"Agent registered successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AgentRegistrationResponse"}}}},"400":{"description":"Invalid registration payload","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Missing or invalid bootstrap token","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AgentRegistrationResponse"}}}}}}},"/agents/groups/{group}/commands":{"post":{"tags":["Agent Commands"],"summary":"Send command to all agents in a group","description":"Sends a command to all LIVE agents in the specified group and waits for responses","operationId":"sendGroupCommand","parameters":[{"name":"group","in":"path","required":true,"schema":{"type":"string"}},{"name":"environment","in":"query","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommandRequest"}}},"required":true},"responses":{"200":{"description":"Commands dispatched and responses collected","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CommandGroupResponse"}}}},"400":{"description":"Invalid command payload","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CommandGroupResponse"}}}}}}},"/agents/commands":{"post":{"tags":["Agent Commands"],"summary":"Broadcast command to all live agents","description":"Sends a command to all agents currently in LIVE state","operationId":"broadcastCommand","parameters":[{"name":"environment","in":"query","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommandRequest"}}},"required":true},"responses":{"202":{"description":"Commands accepted","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CommandBroadcastResponse"}}}},"400":{"description":"Invalid command payload","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CommandBroadcastResponse"}}}}}}},"/admin/users":{"get":{"tags":["User Admin"],"summary":"List all users with RBAC detail","operationId":"listUsers","responses":{"200":{"description":"User list returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserDetail"}}}}}}},"post":{"tags":["User Admin"],"summary":"Create a local user","operationId":"createUser","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserRequest"}}},"required":true},"responses":{"200":{"description":"User created","content":{"*/*":{"schema":{"type":"object"}}}},"400":{"description":"Disabled in OIDC mode","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/admin/users/{userId}/roles/{roleId}":{"post":{"tags":["User Admin"],"summary":"Assign a role to a user","operationId":"assignRoleToUser","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}},{"name":"roleId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Role assigned"},"404":{"description":"User or role not found"}}},"delete":{"tags":["User Admin"],"summary":"Remove a role from a user","operationId":"removeRoleFromUser","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}},{"name":"roleId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Role removed"}}}},"/admin/users/{userId}/password":{"post":{"tags":["User Admin"],"summary":"Reset user password","operationId":"resetPassword","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetPasswordRequest"}}},"required":true},"responses":{"204":{"description":"Password reset"},"400":{"description":"Disabled in OIDC mode or policy violation"}}}},"/admin/users/{userId}/groups/{groupId}":{"post":{"tags":["User Admin"],"summary":"Add a user to a group","operationId":"addUserToGroup","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}},{"name":"groupId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"User added to group"}}},"delete":{"tags":["User Admin"],"summary":"Remove a user from a group","operationId":"removeUserFromGroup","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}},{"name":"groupId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"User removed from group"}}}},"/admin/roles":{"get":{"tags":["Role Admin"],"summary":"List all roles (system and custom)","operationId":"listRoles","responses":{"200":{"description":"Role list returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/RoleDetail"}}}}}}},"post":{"tags":["Role Admin"],"summary":"Create a custom role","operationId":"createRole","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRoleRequest"}}},"required":true},"responses":{"200":{"description":"Role created","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"string","format":"uuid"}}}}}}}},"/admin/outbound-connections":{"get":{"tags":["Outbound Connections Admin"],"operationId":"list_2","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/OutboundConnectionDto"}}}}}}},"post":{"tags":["Outbound Connections Admin"],"operationId":"create_2","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutboundConnectionRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/OutboundConnectionDto"}}}}}}},"/admin/outbound-connections/{id}/test":{"post":{"tags":["Outbound Connections Admin"],"operationId":"test","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/OutboundConnectionTestResult"}}}}}}},"/admin/oidc/test":{"post":{"tags":["OIDC Config Admin"],"summary":"Test OIDC provider connectivity","operationId":"testConnection","responses":{"200":{"description":"Provider reachable","content":{"*/*":{"schema":{"$ref":"#/components/schemas/OidcTestResult"}}}},"400":{"description":"Provider unreachable or misconfigured","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/admin/license":{"get":{"tags":["License Admin"],"summary":"Get current license info","operationId":"getCurrent","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/LicenseInfo"}}}}}},"post":{"tags":["License Admin"],"summary":"Update license token at runtime","operationId":"update_5","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateLicenseRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/admin/groups":{"get":{"tags":["Group Admin"],"summary":"List all groups with hierarchy and effective roles","operationId":"listGroups","responses":{"200":{"description":"Group list returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/GroupDetail"}}}}}}},"post":{"tags":["Group Admin"],"summary":"Create a new group","operationId":"createGroup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGroupRequest"}}},"required":true},"responses":{"200":{"description":"Group created","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"string","format":"uuid"}}}}}}}},"/admin/groups/{id}/roles/{roleId}":{"post":{"tags":["Group Admin"],"summary":"Assign a role to a group","operationId":"assignRoleToGroup","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"roleId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Role assigned to group"},"404":{"description":"Group not found"}}},"delete":{"tags":["Group Admin"],"summary":"Remove a role from a group","operationId":"removeRoleFromGroup","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"roleId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Role removed from group"},"404":{"description":"Group not found"}}}},"/admin/environments":{"get":{"tags":["Environment Admin"],"summary":"List all environments","operationId":"listEnvironments","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Environment"}}}}}}},"post":{"tags":["Environment Admin"],"summary":"Create a new environment","operationId":"createEnvironment","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateEnvironmentRequest"}}},"required":true},"responses":{"201":{"description":"Environment created","content":{"*/*":{"schema":{"type":"object"}}}},"400":{"description":"Invalid slug or slug already exists","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/admin/database/queries/{pid}/kill":{"post":{"tags":["Database Admin"],"summary":"Terminate a query by PID","operationId":"killQuery","parameters":[{"name":"pid","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK"}}}},"/admin/claim-mappings":{"get":{"tags":["Claim Mapping Admin"],"summary":"List all claim mapping rules","operationId":"list_3","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ClaimMappingRule"}}}}}}},"post":{"tags":["Claim Mapping Admin"],"summary":"Create a claim mapping rule","operationId":"create_3","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRuleRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ClaimMappingRule"}}}}}}},"/admin/claim-mappings/test":{"post":{"tags":["Claim Mapping Admin"],"summary":"Test claim mapping rules against a set of claims (accepts unsaved rules)","operationId":"test_1","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TestResponse"}}}}}}},"/executions/{executionId}":{"get":{"tags":["Detail"],"summary":"Get execution detail with nested processor tree","operationId":"getDetail","parameters":[{"name":"executionId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Execution detail found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ExecutionDetail"}}}},"404":{"description":"Execution not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ExecutionDetail"}}}}}}},"/executions/{executionId}/processors/{index}/snapshot":{"get":{"tags":["Detail"],"summary":"Get exchange snapshot for a specific processor by index","operationId":"getProcessorSnapshot","parameters":[{"name":"executionId","in":"path","required":true,"schema":{"type":"string"}},{"name":"index","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"Snapshot data","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"string"}}}}},"404":{"description":"Snapshot not found","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"string"}}}}}}}},"/executions/{executionId}/processors/by-seq/{seq}/snapshot":{"get":{"tags":["Detail"],"summary":"Get exchange snapshot for a processor by seq number","operationId":"processorSnapshotBySeq","parameters":[{"name":"executionId","in":"path","required":true,"schema":{"type":"string"}},{"name":"seq","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"Snapshot data","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"string"}}}}},"404":{"description":"Snapshot not found","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"string"}}}}}}}},"/executions/{executionId}/processors/by-id/{processorId}/snapshot":{"get":{"tags":["Detail"],"summary":"Get exchange snapshot for a specific processor by processorId","operationId":"processorSnapshotById","parameters":[{"name":"executionId","in":"path","required":true,"schema":{"type":"string"}},{"name":"processorId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Snapshot data","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"string"}}}}},"404":{"description":"Snapshot not found","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"string"}}}}}}}},"/environments/{envSlug}/stats":{"get":{"tags":["Search"],"summary":"Aggregate execution stats (P99 latency, active count, SLA compliance)","operationId":"stats","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"from","in":"query","required":true,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"routeId","in":"query","required":false,"schema":{"type":"string"}},{"name":"application","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ExecutionStats"}}}}}}},"/environments/{envSlug}/stats/timeseries":{"get":{"tags":["Search"],"summary":"Bucketed time-series stats over a time window","operationId":"timeseries","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"from","in":"query","required":true,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"buckets","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":24}},{"name":"routeId","in":"query","required":false,"schema":{"type":"string"}},{"name":"application","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/StatsTimeseries"}}}}}}},"/environments/{envSlug}/stats/timeseries/by-route":{"get":{"tags":["Search"],"summary":"Timeseries grouped by route for an application","operationId":"timeseriesByRoute","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"from","in":"query","required":true,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"buckets","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":24}},{"name":"application","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/StatsTimeseries"}}}}}}}},"/environments/{envSlug}/stats/timeseries/by-app":{"get":{"tags":["Search"],"summary":"Timeseries grouped by application","operationId":"timeseriesByApp","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"from","in":"query","required":true,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"buckets","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":24}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/StatsTimeseries"}}}}}}}},"/environments/{envSlug}/stats/punchcard":{"get":{"tags":["Search"],"summary":"Transaction punchcard: weekday x hour grid (rolling 7 days)","operationId":"punchcard","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"application","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PunchcardCell"}}}}}}}},"/environments/{envSlug}/routes":{"get":{"tags":["Route Catalog"],"summary":"Get route catalog for this environment","description":"Returns all applications with their routes, agents, and health status — filtered to this environment","operationId":"getCatalog","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"from","in":"query","required":false,"schema":{"type":"string"}},{"name":"to","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Catalog returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AppCatalogEntry"}}}}}}}},"/environments/{envSlug}/routes/metrics":{"get":{"tags":["Route Metrics"],"summary":"Get route metrics for this environment","description":"Returns aggregated performance metrics per route for the given time window. Optional appId filter narrows to a single application.","operationId":"getMetrics","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"from","in":"query","required":false,"schema":{"type":"string"}},{"name":"to","in":"query","required":false,"schema":{"type":"string"}},{"name":"appId","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Metrics returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/RouteMetrics"}}}}}}}},"/environments/{envSlug}/routes/metrics/processors":{"get":{"tags":["Route Metrics"],"summary":"Get processor metrics for this environment","description":"Returns aggregated performance metrics per processor for the given route and time window","operationId":"getProcessorMetrics","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"routeId","in":"query","required":true,"schema":{"type":"string"}},{"name":"appId","in":"query","required":false,"schema":{"type":"string"}},{"name":"from","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"}}],"responses":{"200":{"description":"Metrics returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProcessorMetrics"}}}}}}}},"/environments/{envSlug}/logs":{"get":{"tags":["Application Logs"],"summary":"Search application log entries in this environment","description":"Cursor-paginated log search scoped to the env in the path. Supports free-text search, multi-level filtering, and optional application/agent scoping.","operationId":"searchLogs","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"q","in":"query","required":false,"schema":{"type":"string"}},{"name":"query","in":"query","required":false,"schema":{"type":"string"}},{"name":"level","in":"query","required":false,"schema":{"type":"string"}},{"name":"application","in":"query","required":false,"schema":{"type":"string"}},{"name":"agentId","in":"query","required":false,"schema":{"type":"string"}},{"name":"exchangeId","in":"query","required":false,"schema":{"type":"string"}},{"name":"logger","in":"query","required":false,"schema":{"type":"string"}},{"name":"source","in":"query","required":false,"schema":{"type":"string"}},{"name":"from","in":"query","required":false,"schema":{"type":"string"}},{"name":"to","in":"query","required":false,"schema":{"type":"string"}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":100}},{"name":"sort","in":"query","required":false,"schema":{"type":"string","default":"desc"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/LogSearchPageResponse"}}}}}}},"/environments/{envSlug}/executions":{"get":{"tags":["Search"],"summary":"Search executions with basic filters (env from path)","operationId":"searchGet","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"status","in":"query","required":false,"schema":{"type":"string"}},{"name":"timeFrom","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"timeTo","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"correlationId","in":"query","required":false,"schema":{"type":"string"}},{"name":"text","in":"query","required":false,"schema":{"type":"string"}},{"name":"routeId","in":"query","required":false,"schema":{"type":"string"}},{"name":"agentId","in":"query","required":false,"schema":{"type":"string"}},{"name":"processorType","in":"query","required":false,"schema":{"type":"string"}},{"name":"application","in":"query","required":false,"schema":{"type":"string"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":50}},{"name":"sortField","in":"query","required":false,"schema":{"type":"string"}},{"name":"sortDir","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SearchResultExecutionSummary"}}}}}}},"/environments/{envSlug}/errors/top":{"get":{"tags":["Search"],"summary":"Top N errors with velocity trend","operationId":"topErrors","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"from","in":"query","required":true,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"application","in":"query","required":false,"schema":{"type":"string"}},{"name":"routeId","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":5}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TopError"}}}}}}}},"/environments/{envSlug}/config":{"get":{"tags":["Application Config"],"summary":"List application configs in this environment","operationId":"listConfigs","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}}],"responses":{"200":{"description":"Configs returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApplicationConfig"}}}}}}}},"/environments/{envSlug}/attributes/keys":{"get":{"tags":["Search"],"summary":"Distinct attribute key names for this environment","operationId":"attributeKeys","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"type":"string"}}}}}}}},"/environments/{envSlug}/apps/{appSlug}":{"get":{"tags":["App Management"],"summary":"Get app by env + slug","operationId":"getApp","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"App found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/App"}}}},"404":{"description":"App not found in this environment","content":{"*/*":{"schema":{"$ref":"#/components/schemas/App"}}}}}},"delete":{"tags":["App Management"],"summary":"Delete this app","operationId":"deleteApp","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"App deleted"}}}},"/environments/{envSlug}/apps/{appSlug}/routes/{routeId}/diagram":{"get":{"tags":["Diagrams"],"summary":"Find the latest diagram for this app's route in this environment","description":"Resolves agents in this env for this app, then looks up the latest diagram for the route they reported. Env scope prevents a dev route from returning a prod diagram.","operationId":"findByAppAndRoute","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}},{"name":"routeId","in":"path","required":true,"schema":{"type":"string"}},{"name":"direction","in":"query","required":false,"schema":{"type":"string","default":"LR"}}],"responses":{"200":{"description":"Diagram layout returned","content":{"*/*":{"schema":{"$ref":"#/components/schemas/DiagramLayout"}}}},"404":{"description":"No diagram found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/DiagramLayout"}}}}}}},"/environments/{envSlug}/apps/{appSlug}/processor-routes":{"get":{"tags":["Application Config"],"summary":"Get processor to route mapping for this environment","description":"Returns a map of processorId → routeId for all processors seen in this application + environment","operationId":"getProcessorRouteMapping","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Mapping returned","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"string"}}}}}}}},"/environments/{envSlug}/apps/{appSlug}/deployments/{deploymentId}":{"get":{"tags":["Deployment Management"],"summary":"Get deployment by ID","operationId":"getDeployment","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}},{"name":"deploymentId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Deployment found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Deployment"}}}},"404":{"description":"Deployment not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Deployment"}}}}}}},"/environments/{envSlug}/apps/{appSlug}/deployments/{deploymentId}/logs":{"get":{"tags":["Deployment Management"],"summary":"Get container logs for this deployment","operationId":"getLogs","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appSlug","in":"path","required":true,"schema":{"type":"string"}},{"name":"deploymentId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Logs returned","content":{"*/*":{"schema":{"type":"array","items":{"type":"string"}}}}},"404":{"description":"Deployment not found or no container","content":{"*/*":{"schema":{"type":"array","items":{"type":"string"}}}}}}}},"/environments/{envSlug}/app-settings":{"get":{"tags":["App Settings"],"summary":"List application settings in this environment","operationId":"getAll","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AppSettings"}}}}}}}},"/environments/{envSlug}/alerts":{"get":{"tags":["Alerts Inbox"],"operationId":"list_4","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":50}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AlertDto"}}}}}}}},"/environments/{envSlug}/alerts/{id}":{"get":{"tags":["Alerts Inbox"],"operationId":"get_3","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AlertDto"}}}}}}},"/environments/{envSlug}/alerts/{alertId}/notifications":{"get":{"tags":["Alert Notifications"],"operationId":"listForInstance","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"alertId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AlertNotificationDto"}}}}}}}},"/environments/{envSlug}/alerts/unread-count":{"get":{"tags":["Alerts Inbox"],"operationId":"unreadCount","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UnreadCountResponse"}}}}}}},"/environments/{envSlug}/agents":{"get":{"tags":["Agent List"],"summary":"List all agents in this environment","description":"Returns registered agents with runtime metrics, optionally filtered by status and/or application","operationId":"listAgents","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"status","in":"query","required":false,"schema":{"type":"string"}},{"name":"application","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Agent list returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AgentInstanceResponse"}}}}},"400":{"description":"Invalid status filter","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/environments/{envSlug}/agents/{agentId}/metrics":{"get":{"tags":["agent-metrics-controller"],"operationId":"getMetrics_1","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"agentId","in":"path","required":true,"schema":{"type":"string"}},{"name":"names","in":"query","required":true,"schema":{"type":"string"}},{"name":"from","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"buckets","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":60}},{"name":"mode","in":"query","required":false,"schema":{"type":"string","default":"gauge"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AgentMetricsResponse"}}}}}}},"/environments/{envSlug}/agents/events":{"get":{"tags":["Agent Events"],"summary":"Query agent events in this environment","description":"Cursor-paginated. Returns newest first. Pass nextCursor back as ?cursor= for the next page.","operationId":"getEvents","parameters":[{"name":"env","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Environment"}},{"name":"appId","in":"query","required":false,"schema":{"type":"string"}},{"name":"agentId","in":"query","required":false,"schema":{"type":"string"}},{"name":"from","in":"query","required":false,"schema":{"type":"string"}},{"name":"to","in":"query","required":false,"schema":{"type":"string"}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":50}}],"responses":{"200":{"description":"Event page returned","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AgentEventPageResponse"}}}}}}},"/diagrams/{contentHash}/render":{"get":{"tags":["Diagrams"],"summary":"Render a route diagram by content hash","description":"Returns SVG (default) or JSON layout based on Accept header. Content hashes are globally unique, so this endpoint is intentionally flat (no env).","operationId":"renderDiagram","parameters":[{"name":"contentHash","in":"path","required":true,"schema":{"type":"string"}},{"name":"direction","in":"query","required":false,"schema":{"type":"string","default":"LR"}}],"responses":{"200":{"description":"Diagram rendered successfully","content":{"image/svg+xml":{"schema":{"type":"string"}},"application/json":{"schema":{"$ref":"#/components/schemas/DiagramLayout"}}}},"404":{"description":"Diagram not found","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/catalog":{"get":{"tags":["Catalog"],"summary":"Get unified catalog","description":"Returns all applications (managed + unmanaged) with live agent data, routes, and deployment status","operationId":"getCatalog_1","parameters":[{"name":"environment","in":"query","required":false,"schema":{"type":"string"}},{"name":"from","in":"query","required":false,"schema":{"type":"string"}},{"name":"to","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Catalog returned","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CatalogApp"}}}}}}}},"/auth/oidc/config":{"get":{"tags":["Authentication"],"summary":"Get OIDC config for SPA login flow","operationId":"getConfig_2","responses":{"200":{"description":"OIDC configuration","content":{"*/*":{"schema":{"$ref":"#/components/schemas/OidcPublicConfigResponse"}}}},"404":{"description":"OIDC not configured or disabled","content":{"*/*":{"schema":{"$ref":"#/components/schemas/OidcPublicConfigResponse"}}}},"500":{"description":"Failed to retrieve OIDC provider metadata","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/auth/me":{"get":{"tags":["Authentication"],"summary":"Get current user details","operationId":"me","responses":{"200":{"description":"Current user details","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDetail"}}}},"401":{"description":"Not authenticated","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDetail"}}}}}}},"/agents/{id}/events":{"get":{"tags":["Agent SSE"],"summary":"Open SSE event stream","description":"Opens a Server-Sent Events stream for the specified agent. Commands (config-update, deep-trace, replay) are pushed as events. Ping keepalive comments sent every 15 seconds.","operationId":"events","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"Last-Event-ID","in":"header","description":"Last received event ID (no replay, acknowledged only)","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"SSE stream opened","content":{"text/event-stream":{"schema":{"$ref":"#/components/schemas/SseEmitter"}}}},"404":{"description":"Agent not registered and cannot be auto-registered","content":{"text/event-stream":{"schema":{"$ref":"#/components/schemas/SseEmitter"}}}}}}},"/agents/config":{"get":{"tags":["Agent Config"],"summary":"Get application config for the calling agent","description":"Resolves (application, environment) from the agent's JWT + registry. Prefers the registry entry (heartbeat-authoritative); falls back to the JWT env claim. Returns 404 if neither identifies a valid agent.","operationId":"getConfigForAgent","responses":{"200":{"description":"Config returned","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AppConfigResponse"}}}},"404":{"description":"Calling agent could not be resolved","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AppConfigResponse"}}}}}}},"/admin/usage":{"get":{"tags":["Usage Analytics"],"summary":"Query usage statistics","description":"Returns aggregated API usage stats grouped by endpoint, user, or hour","operationId":"getUsage","parameters":[{"name":"from","in":"query","required":false,"schema":{"type":"string"}},{"name":"to","in":"query","required":false,"schema":{"type":"string"}},{"name":"username","in":"query","required":false,"schema":{"type":"string"}},{"name":"groupBy","in":"query","required":false,"schema":{"type":"string","default":"endpoint"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UsageStats"}}}}}}}},"/admin/rbac/stats":{"get":{"tags":["RBAC Stats"],"summary":"Get RBAC statistics for the dashboard","operationId":"getStats","responses":{"200":{"description":"RBAC stats returned","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RbacStats"}}}}}}},"/admin/outbound-connections/{id}/usage":{"get":{"tags":["Outbound Connections Admin"],"operationId":"usage","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"type":"string","format":"uuid"}}}}}}}},"/admin/database/tables":{"get":{"tags":["Database Admin"],"summary":"Get table sizes and row counts","operationId":"getTables","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TableSizeResponse"}}}}}}}},"/admin/database/status":{"get":{"tags":["Database Admin"],"summary":"Get database connection status and version","operationId":"getStatus","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/DatabaseStatusResponse"}}}}}}},"/admin/database/queries":{"get":{"tags":["Database Admin"],"summary":"Get active queries","operationId":"getQueries","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ActiveQueryResponse"}}}}}}}},"/admin/database/pool":{"get":{"tags":["Database Admin"],"summary":"Get HikariCP connection pool stats","operationId":"getPool","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ConnectionPoolResponse"}}}}}}},"/admin/clickhouse/tables":{"get":{"tags":["ClickHouse Admin"],"summary":"List ClickHouse tables with sizes","operationId":"getTables_1","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ClickHouseTableInfo"}}}}}}}},"/admin/clickhouse/status":{"get":{"tags":["ClickHouse Admin"],"summary":"ClickHouse cluster status","operationId":"getStatus_1","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ClickHouseStatusResponse"}}}}}}},"/admin/clickhouse/queries":{"get":{"tags":["ClickHouse Admin"],"summary":"Active ClickHouse queries","operationId":"getQueries_1","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ClickHouseQueryInfo"}}}}}}}},"/admin/clickhouse/pipeline":{"get":{"tags":["ClickHouse Admin"],"summary":"Search indexer pipeline statistics","operationId":"getPipeline","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/IndexerPipelineResponse"}}}}}}},"/admin/clickhouse/performance":{"get":{"tags":["ClickHouse Admin"],"summary":"ClickHouse storage and performance metrics","operationId":"getPerformance","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ClickHousePerformanceResponse"}}}}}}},"/admin/audit":{"get":{"tags":["Audit Log"],"summary":"Search audit log entries with pagination","operationId":"getAuditLog","parameters":[{"name":"username","in":"query","required":false,"schema":{"type":"string"}},{"name":"category","in":"query","required":false,"schema":{"type":"string"}},{"name":"search","in":"query","required":false,"schema":{"type":"string"}},{"name":"from","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"sort","in":"query","required":false,"schema":{"type":"string","default":"timestamp"}},{"name":"order","in":"query","required":false,"schema":{"type":"string","default":"desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"size","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":25}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AuditLogPageResponse"}}}}}}},"/catalog/{applicationId}":{"delete":{"tags":["Catalog"],"summary":"Dismiss application and purge all data","operationId":"dismissApplication","parameters":[{"name":"applicationId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Application dismissed"},"409":{"description":"Cannot dismiss — live agents connected"}}}}},"components":{"schemas":{"Environment":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"displayName":{"type":"string"},"production":{"type":"boolean"},"enabled":{"type":"boolean"},"defaultContainerConfig":{"type":"object","additionalProperties":{"type":"object"}},"jarRetentionCount":{"type":"integer","format":"int32"},"createdAt":{"type":"string","format":"date-time"}}},"AppSettingsRequest":{"type":"object","description":"Per-application dashboard settings","properties":{"slaThresholdMs":{"type":"integer","format":"int32","description":"SLA duration threshold in milliseconds","minimum":1},"healthErrorWarn":{"type":"number","format":"double","description":"Error rate % threshold for warning (yellow) health dot","maximum":100,"minimum":0},"healthErrorCrit":{"type":"number","format":"double","description":"Error rate % threshold for critical (red) health dot","maximum":100,"minimum":0},"healthSlaWarn":{"type":"number","format":"double","description":"SLA compliance % threshold for warning (yellow) health dot","maximum":100,"minimum":0},"healthSlaCrit":{"type":"number","format":"double","description":"SLA compliance % threshold for critical (red) health dot","maximum":100,"minimum":0}},"required":["healthErrorCrit","healthErrorWarn","healthSlaCrit","healthSlaWarn","slaThresholdMs"]},"AppSettings":{"type":"object","properties":{"applicationId":{"type":"string"},"environment":{"type":"string"},"slaThresholdMs":{"type":"integer","format":"int32"},"healthErrorWarn":{"type":"number","format":"double"},"healthErrorCrit":{"type":"number","format":"double"},"healthSlaWarn":{"type":"number","format":"double"},"healthSlaCrit":{"type":"number","format":"double"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"ApplicationConfig":{"type":"object","properties":{"application":{"type":"string"},"environment":{"type":"string"},"version":{"type":"integer","format":"int32"},"updatedAt":{"type":"string","format":"date-time"},"engineLevel":{"type":"string"},"payloadCaptureMode":{"type":"string"},"metricsEnabled":{"type":"boolean"},"samplingRate":{"type":"number","format":"double"},"tracedProcessors":{"type":"object","additionalProperties":{"type":"string"}},"applicationLogLevel":{"type":"string"},"taps":{"type":"array","items":{"$ref":"#/components/schemas/TapDefinition"}},"tapVersion":{"type":"integer","format":"int32"},"routeRecording":{"type":"object","additionalProperties":{"type":"boolean"}},"compressSuccess":{"type":"boolean"},"agentLogLevel":{"type":"string"},"routeSamplingRates":{"type":"object","additionalProperties":{"type":"number","format":"double"}},"sensitiveKeys":{"type":"array","items":{"type":"string"}}}},"TapDefinition":{"type":"object","properties":{"tapId":{"type":"string"},"processorId":{"type":"string"},"target":{"type":"string"},"expression":{"type":"string"},"language":{"type":"string"},"attributeName":{"type":"string"},"attributeType":{"type":"string"},"enabled":{"type":"boolean"},"version":{"type":"integer","format":"int32"}}},"AgentResponse":{"type":"object","properties":{"agentId":{"type":"string"},"status":{"type":"string"},"message":{"type":"string"}}},"CommandGroupResponse":{"type":"object","properties":{"success":{"type":"boolean"},"total":{"type":"integer","format":"int32"},"responded":{"type":"integer","format":"int32"},"responses":{"type":"array","items":{"$ref":"#/components/schemas/AgentResponse"}},"timedOut":{"type":"array","items":{"type":"string"}}}},"ConfigUpdateResponse":{"type":"object","properties":{"config":{"$ref":"#/components/schemas/ApplicationConfig"},"pushResult":{"$ref":"#/components/schemas/CommandGroupResponse"}}},"AlertSilenceRequest":{"type":"object","properties":{"matcher":{"$ref":"#/components/schemas/SilenceMatcher"},"reason":{"type":"string"},"startsAt":{"type":"string","format":"date-time"},"endsAt":{"type":"string","format":"date-time"}},"required":["endsAt","matcher","startsAt"]},"SilenceMatcher":{"type":"object","properties":{"ruleId":{"type":"string","format":"uuid"},"appSlug":{"type":"string"},"routeId":{"type":"string"},"agentId":{"type":"string"},"severity":{"type":"string","enum":["CRITICAL","WARNING","INFO"]},"wildcard":{"type":"boolean"}}},"AlertSilenceResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"environmentId":{"type":"string","format":"uuid"},"matcher":{"$ref":"#/components/schemas/SilenceMatcher"},"reason":{"type":"string"},"startsAt":{"type":"string","format":"date-time"},"endsAt":{"type":"string","format":"date-time"},"createdBy":{"type":"string"},"createdAt":{"type":"string","format":"date-time"}}},"AgentStateCondition":{"allOf":[{"$ref":"#/components/schemas/AlertCondition"},{"type":"object","properties":{"scope":{"$ref":"#/components/schemas/AlertScope"},"state":{"type":"string"},"forSeconds":{"type":"integer","format":"int32"},"kind":{"type":"string","enum":["ROUTE_METRIC","EXCHANGE_MATCH","AGENT_STATE","DEPLOYMENT_STATE","LOG_PATTERN","JVM_METRIC"],"readOnly":true}}}]},"AlertCondition":{"type":"object","discriminator":{"propertyName":"kind"},"properties":{"kind":{"type":"string","enum":["ROUTE_METRIC","EXCHANGE_MATCH","AGENT_STATE","DEPLOYMENT_STATE","LOG_PATTERN","JVM_METRIC"]}}},"AlertRuleRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1},"description":{"type":"string"},"severity":{"type":"string","enum":["CRITICAL","WARNING","INFO"]},"conditionKind":{"type":"string","enum":["ROUTE_METRIC","EXCHANGE_MATCH","AGENT_STATE","DEPLOYMENT_STATE","LOG_PATTERN","JVM_METRIC"]},"condition":{"oneOf":[{"$ref":"#/components/schemas/AgentStateCondition"},{"$ref":"#/components/schemas/DeploymentStateCondition"},{"$ref":"#/components/schemas/ExchangeMatchCondition"},{"$ref":"#/components/schemas/JvmMetricCondition"},{"$ref":"#/components/schemas/LogPatternCondition"},{"$ref":"#/components/schemas/RouteMetricCondition"}]},"evaluationIntervalSeconds":{"type":"integer","format":"int32"},"forDurationSeconds":{"type":"integer","format":"int32"},"reNotifyMinutes":{"type":"integer","format":"int32"},"notificationTitleTmpl":{"type":"string"},"notificationMessageTmpl":{"type":"string"},"webhooks":{"type":"array","items":{"$ref":"#/components/schemas/WebhookBindingRequest"}},"targets":{"type":"array","items":{"$ref":"#/components/schemas/AlertRuleTarget"}}},"required":["condition","conditionKind","severity"]},"AlertRuleTarget":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"ruleId":{"type":"string","format":"uuid"},"kind":{"type":"string","enum":["USER","GROUP","ROLE"]},"targetId":{"type":"string"}}},"AlertScope":{"type":"object","properties":{"appSlug":{"type":"string"},"routeId":{"type":"string"},"agentId":{"type":"string"}}},"DeploymentStateCondition":{"allOf":[{"$ref":"#/components/schemas/AlertCondition"},{"type":"object","properties":{"scope":{"$ref":"#/components/schemas/AlertScope"},"states":{"type":"array","items":{"type":"string"}},"kind":{"type":"string","enum":["ROUTE_METRIC","EXCHANGE_MATCH","AGENT_STATE","DEPLOYMENT_STATE","LOG_PATTERN","JVM_METRIC"],"readOnly":true}}}]},"ExchangeFilter":{"type":"object","properties":{"status":{"type":"string"},"attributes":{"type":"object","additionalProperties":{"type":"string"}}}},"ExchangeMatchCondition":{"allOf":[{"$ref":"#/components/schemas/AlertCondition"},{"type":"object","properties":{"scope":{"$ref":"#/components/schemas/AlertScope"},"filter":{"$ref":"#/components/schemas/ExchangeFilter"},"fireMode":{"type":"string","enum":["PER_EXCHANGE","COUNT_IN_WINDOW"]},"threshold":{"type":"integer","format":"int32"},"windowSeconds":{"type":"integer","format":"int32"},"perExchangeLingerSeconds":{"type":"integer","format":"int32"},"kind":{"type":"string","enum":["ROUTE_METRIC","EXCHANGE_MATCH","AGENT_STATE","DEPLOYMENT_STATE","LOG_PATTERN","JVM_METRIC"],"readOnly":true}}}]},"JvmMetricCondition":{"allOf":[{"$ref":"#/components/schemas/AlertCondition"},{"type":"object","properties":{"scope":{"$ref":"#/components/schemas/AlertScope"},"metric":{"type":"string"},"aggregation":{"type":"string","enum":["MAX","MIN","AVG","LATEST"]},"comparator":{"type":"string","enum":["GT","GTE","LT","LTE","EQ"]},"threshold":{"type":"number","format":"double"},"windowSeconds":{"type":"integer","format":"int32"},"kind":{"type":"string","enum":["ROUTE_METRIC","EXCHANGE_MATCH","AGENT_STATE","DEPLOYMENT_STATE","LOG_PATTERN","JVM_METRIC"],"readOnly":true}}}]},"LogPatternCondition":{"allOf":[{"$ref":"#/components/schemas/AlertCondition"},{"type":"object","properties":{"scope":{"$ref":"#/components/schemas/AlertScope"},"level":{"type":"string"},"pattern":{"type":"string"},"threshold":{"type":"integer","format":"int32"},"windowSeconds":{"type":"integer","format":"int32"},"kind":{"type":"string","enum":["ROUTE_METRIC","EXCHANGE_MATCH","AGENT_STATE","DEPLOYMENT_STATE","LOG_PATTERN","JVM_METRIC"],"readOnly":true}}}]},"RouteMetricCondition":{"allOf":[{"$ref":"#/components/schemas/AlertCondition"},{"type":"object","properties":{"scope":{"$ref":"#/components/schemas/AlertScope"},"metric":{"type":"string","enum":["ERROR_RATE","AVG_DURATION_MS","P99_LATENCY_MS","THROUGHPUT","ERROR_COUNT"]},"comparator":{"type":"string","enum":["GT","GTE","LT","LTE","EQ"]},"threshold":{"type":"number","format":"double"},"windowSeconds":{"type":"integer","format":"int32"},"kind":{"type":"string","enum":["ROUTE_METRIC","EXCHANGE_MATCH","AGENT_STATE","DEPLOYMENT_STATE","LOG_PATTERN","JVM_METRIC"],"readOnly":true}}}]},"WebhookBindingRequest":{"type":"object","properties":{"outboundConnectionId":{"type":"string","format":"uuid"},"bodyOverride":{"type":"string"},"headerOverrides":{"type":"object","additionalProperties":{"type":"string"}}},"required":["outboundConnectionId"]},"AlertRuleResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"environmentId":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":"string"},"severity":{"type":"string","enum":["CRITICAL","WARNING","INFO"]},"enabled":{"type":"boolean"},"conditionKind":{"type":"string","enum":["ROUTE_METRIC","EXCHANGE_MATCH","AGENT_STATE","DEPLOYMENT_STATE","LOG_PATTERN","JVM_METRIC"]},"condition":{"oneOf":[{"$ref":"#/components/schemas/AgentStateCondition"},{"$ref":"#/components/schemas/DeploymentStateCondition"},{"$ref":"#/components/schemas/ExchangeMatchCondition"},{"$ref":"#/components/schemas/JvmMetricCondition"},{"$ref":"#/components/schemas/LogPatternCondition"},{"$ref":"#/components/schemas/RouteMetricCondition"}]},"evaluationIntervalSeconds":{"type":"integer","format":"int32"},"forDurationSeconds":{"type":"integer","format":"int32"},"reNotifyMinutes":{"type":"integer","format":"int32"},"notificationTitleTmpl":{"type":"string"},"notificationMessageTmpl":{"type":"string"},"webhooks":{"type":"array","items":{"$ref":"#/components/schemas/WebhookBindingResponse"}},"targets":{"type":"array","items":{"$ref":"#/components/schemas/AlertRuleTarget"}},"createdAt":{"type":"string","format":"date-time"},"createdBy":{"type":"string"},"updatedAt":{"type":"string","format":"date-time"},"updatedBy":{"type":"string"}}},"WebhookBindingResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"outboundConnectionId":{"type":"string","format":"uuid"},"bodyOverride":{"type":"string"},"headerOverrides":{"type":"object","additionalProperties":{"type":"string"}}}},"UpdateUserRequest":{"type":"object","properties":{"displayName":{"type":"string"},"email":{"type":"string"}}},"DatabaseThresholdsRequest":{"type":"object","description":"Database monitoring thresholds","properties":{"connectionPoolWarning":{"type":"integer","format":"int32","description":"Connection pool usage warning threshold (percentage)","maximum":100,"minimum":0},"connectionPoolCritical":{"type":"integer","format":"int32","description":"Connection pool usage critical threshold (percentage)","maximum":100,"minimum":0},"queryDurationWarning":{"type":"number","format":"double","description":"Query duration warning threshold (seconds)"},"queryDurationCritical":{"type":"number","format":"double","description":"Query duration critical threshold (seconds)"}}},"ThresholdConfigRequest":{"type":"object","description":"Threshold configuration for admin monitoring","properties":{"database":{"$ref":"#/components/schemas/DatabaseThresholdsRequest"}},"required":["database"]},"DatabaseThresholds":{"type":"object","properties":{"connectionPoolWarning":{"type":"integer","format":"int32"},"connectionPoolCritical":{"type":"integer","format":"int32"},"queryDurationWarning":{"type":"number","format":"double"},"queryDurationCritical":{"type":"number","format":"double"}}},"ThresholdConfig":{"type":"object","properties":{"database":{"$ref":"#/components/schemas/DatabaseThresholds"}}},"SensitiveKeysRequest":{"type":"object","description":"Global sensitive keys configuration","properties":{"keys":{"type":"array","description":"List of key names or glob patterns to mask","items":{"type":"string"}}},"required":["keys"]},"SensitiveKeysResponse":{"type":"object","properties":{"keys":{"type":"array","items":{"type":"string"}},"pushResult":{"$ref":"#/components/schemas/CommandGroupResponse"}}},"UpdateRoleRequest":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"},"scope":{"type":"string"}}},"Basic":{"allOf":[{"$ref":"#/components/schemas/OutboundAuth"},{"type":"object","properties":{"username":{"type":"string"},"passwordCiphertext":{"type":"string"}}}]},"Bearer":{"allOf":[{"$ref":"#/components/schemas/OutboundAuth"},{"type":"object","properties":{"tokenCiphertext":{"type":"string"}}}]},"None":{"allOf":[{"$ref":"#/components/schemas/OutboundAuth"}]},"OutboundAuth":{"type":"object"},"OutboundConnectionRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":100,"minLength":0},"description":{"type":"string","maxLength":2000,"minLength":0},"url":{"type":"string","minLength":1,"pattern":"^https://.+"},"method":{"type":"string","enum":["POST","PUT","PATCH"]},"defaultHeaders":{"type":"object","additionalProperties":{"type":"string"}},"defaultBodyTmpl":{"type":"string"},"tlsTrustMode":{"type":"string","enum":["SYSTEM_DEFAULT","TRUST_ALL","TRUST_PATHS"]},"tlsCaPemPaths":{"type":"array","items":{"type":"string"}},"hmacSecret":{"type":"string"},"auth":{"oneOf":[{"$ref":"#/components/schemas/Basic"},{"$ref":"#/components/schemas/Bearer"},{"$ref":"#/components/schemas/None"}]},"allowedEnvironmentIds":{"type":"array","items":{"type":"string","format":"uuid"}}},"required":["auth","method","tlsTrustMode"]},"OutboundConnectionDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":"string"},"url":{"type":"string"},"method":{"type":"string","enum":["POST","PUT","PATCH"]},"defaultHeaders":{"type":"object","additionalProperties":{"type":"string"}},"defaultBodyTmpl":{"type":"string"},"tlsTrustMode":{"type":"string","enum":["SYSTEM_DEFAULT","TRUST_ALL","TRUST_PATHS"]},"tlsCaPemPaths":{"type":"array","items":{"type":"string"}},"hmacSecretSet":{"type":"boolean"},"authKind":{"type":"string","enum":["NONE","BEARER","BASIC"]},"allowedEnvironmentIds":{"type":"array","items":{"type":"string","format":"uuid"}},"createdAt":{"type":"string","format":"date-time"},"createdBy":{"type":"string"},"updatedAt":{"type":"string","format":"date-time"},"updatedBy":{"type":"string"}}},"OidcAdminConfigRequest":{"type":"object","description":"OIDC configuration update request","properties":{"enabled":{"type":"boolean"},"issuerUri":{"type":"string"},"clientId":{"type":"string"},"clientSecret":{"type":"string"},"rolesClaim":{"type":"string"},"defaultRoles":{"type":"array","items":{"type":"string"}},"autoSignup":{"type":"boolean"},"displayNameClaim":{"type":"string"},"userIdClaim":{"type":"string"},"audience":{"type":"string"},"additionalScopes":{"type":"array","items":{"type":"string"}}}},"ErrorResponse":{"type":"object","description":"Error response","properties":{"message":{"type":"string"}},"required":["message"]},"OidcAdminConfigResponse":{"type":"object","description":"OIDC configuration for admin management","properties":{"configured":{"type":"boolean"},"enabled":{"type":"boolean"},"issuerUri":{"type":"string"},"clientId":{"type":"string"},"clientSecretSet":{"type":"boolean"},"rolesClaim":{"type":"string"},"defaultRoles":{"type":"array","items":{"type":"string"}},"autoSignup":{"type":"boolean"},"displayNameClaim":{"type":"string"},"userIdClaim":{"type":"string"},"audience":{"type":"string"},"additionalScopes":{"type":"array","items":{"type":"string"}}}},"UpdateGroupRequest":{"type":"object","properties":{"name":{"type":"string"},"parentGroupId":{"type":"string","format":"uuid"}}},"UpdateEnvironmentRequest":{"type":"object","properties":{"displayName":{"type":"string"},"production":{"type":"boolean"},"enabled":{"type":"boolean"}}},"JarRetentionRequest":{"type":"object","properties":{"jarRetentionCount":{"type":"integer","format":"int32"}}},"CreateRuleRequest":{"type":"object","properties":{"claim":{"type":"string"},"matchType":{"type":"string"},"matchValue":{"type":"string"},"action":{"type":"string"},"target":{"type":"string"},"priority":{"type":"integer","format":"int32"}}},"ClaimMappingRule":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"claim":{"type":"string"},"matchType":{"type":"string"},"matchValue":{"type":"string"},"action":{"type":"string"},"target":{"type":"string"},"priority":{"type":"integer","format":"int32"},"createdAt":{"type":"string","format":"date-time"}}},"SearchRequest":{"type":"object","properties":{"status":{"type":"string"},"timeFrom":{"type":"string","format":"date-time"},"timeTo":{"type":"string","format":"date-time"},"durationMin":{"type":"integer","format":"int64"},"durationMax":{"type":"integer","format":"int64"},"correlationId":{"type":"string"},"text":{"type":"string"},"textInBody":{"type":"string"},"textInHeaders":{"type":"string"},"textInErrors":{"type":"string"},"routeId":{"type":"string"},"instanceId":{"type":"string"},"processorType":{"type":"string"},"applicationId":{"type":"string"},"instanceIds":{"type":"array","items":{"type":"string"}},"offset":{"type":"integer","format":"int32"},"limit":{"type":"integer","format":"int32"},"sortField":{"type":"string"},"sortDir":{"type":"string"},"environment":{"type":"string"}}},"ExecutionSummary":{"type":"object","properties":{"executionId":{"type":"string"},"routeId":{"type":"string"},"instanceId":{"type":"string"},"applicationId":{"type":"string"},"status":{"type":"string"},"startTime":{"type":"string","format":"date-time"},"endTime":{"type":"string","format":"date-time"},"durationMs":{"type":"integer","format":"int64"},"correlationId":{"type":"string"},"errorMessage":{"type":"string"},"diagramContentHash":{"type":"string"},"highlight":{"type":"string"},"attributes":{"type":"object","additionalProperties":{"type":"string"}},"hasTraceData":{"type":"boolean"},"isReplay":{"type":"boolean"}},"required":["applicationId","attributes","correlationId","diagramContentHash","durationMs","endTime","errorMessage","executionId","hasTraceData","highlight","instanceId","isReplay","routeId","startTime","status"]},"SearchResultExecutionSummary":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ExecutionSummary"}},"total":{"type":"integer","format":"int64"},"offset":{"type":"integer","format":"int32"},"limit":{"type":"integer","format":"int32"}},"required":["data","limit","offset","total"]},"CreateAppRequest":{"type":"object","properties":{"slug":{"type":"string"},"displayName":{"type":"string"}}},"AppVersion":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"appId":{"type":"string","format":"uuid"},"version":{"type":"integer","format":"int32"},"jarPath":{"type":"string"},"jarChecksum":{"type":"string"},"jarFilename":{"type":"string"},"jarSizeBytes":{"type":"integer","format":"int64"},"detectedRuntimeType":{"type":"string"},"detectedMainClass":{"type":"string"},"uploadedAt":{"type":"string","format":"date-time"}}},"DeployRequest":{"type":"object","properties":{"appVersionId":{"type":"string","format":"uuid"}}},"Deployment":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"appId":{"type":"string","format":"uuid"},"appVersionId":{"type":"string","format":"uuid"},"environmentId":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["STOPPED","STARTING","RUNNING","DEGRADED","STOPPING","FAILED"]},"targetState":{"type":"string"},"deploymentStrategy":{"type":"string"},"replicaStates":{"type":"array","items":{"type":"object","additionalProperties":{"type":"object"}}},"deployStage":{"type":"string"},"containerId":{"type":"string"},"containerName":{"type":"string"},"errorMessage":{"type":"string"},"resolvedConfig":{"type":"object","additionalProperties":{"type":"object"}},"deployedAt":{"type":"string","format":"date-time"},"stoppedAt":{"type":"string","format":"date-time"},"createdAt":{"type":"string","format":"date-time"}}},"PromoteRequest":{"type":"object","properties":{"targetEnvironment":{"type":"string"}}},"TestExpressionRequest":{"type":"object","properties":{"expression":{"type":"string"},"language":{"type":"string"},"body":{"type":"string"},"target":{"type":"string"}}},"TestExpressionResponse":{"type":"object","properties":{"result":{"type":"string"},"error":{"type":"string"}}},"AlertDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"ruleId":{"type":"string","format":"uuid"},"environmentId":{"type":"string","format":"uuid"},"state":{"type":"string","enum":["PENDING","FIRING","ACKNOWLEDGED","RESOLVED"]},"severity":{"type":"string","enum":["CRITICAL","WARNING","INFO"]},"title":{"type":"string"},"message":{"type":"string"},"firedAt":{"type":"string","format":"date-time"},"ackedAt":{"type":"string","format":"date-time"},"ackedBy":{"type":"string"},"resolvedAt":{"type":"string","format":"date-time"},"silenced":{"type":"boolean"},"currentValue":{"type":"number","format":"double"},"threshold":{"type":"number","format":"double"},"context":{"type":"object","additionalProperties":{"type":"object"}}}},"TestEvaluateRequest":{"type":"object"},"TestEvaluateResponse":{"type":"object","properties":{"resultKind":{"type":"string"},"detail":{"type":"string"}}},"RenderPreviewRequest":{"type":"object","properties":{"context":{"type":"object","additionalProperties":{"type":"object"}}}},"RenderPreviewResponse":{"type":"object","properties":{"title":{"type":"string"},"message":{"type":"string"}}},"BulkReadRequest":{"type":"object","properties":{"instanceIds":{"type":"array","items":{"type":"string","format":"uuid"}}},"required":["instanceIds"]},"LogEntry":{"type":"object","properties":{"timestamp":{"type":"string","format":"date-time"},"level":{"type":"string"},"loggerName":{"type":"string"},"message":{"type":"string"},"threadName":{"type":"string"},"stackTrace":{"type":"string"},"mdc":{"type":"object","additionalProperties":{"type":"string"}},"source":{"type":"string"}}},"RefreshRequest":{"type":"object","properties":{"refreshToken":{"type":"string"}}},"AuthTokenResponse":{"type":"object","description":"JWT token pair","properties":{"accessToken":{"type":"string"},"refreshToken":{"type":"string"},"displayName":{"type":"string"},"idToken":{"type":"string","description":"OIDC id_token for end-session logout (only present after OIDC login)"}},"required":["accessToken","displayName","refreshToken"]},"CallbackRequest":{"type":"object","properties":{"code":{"type":"string"},"redirectUri":{"type":"string"}}},"LoginRequest":{"type":"object","properties":{"username":{"type":"string"},"password":{"type":"string"}}},"AlertNotificationDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"alertInstanceId":{"type":"string","format":"uuid"},"webhookId":{"type":"string","format":"uuid"},"outboundConnectionId":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["PENDING","DELIVERED","FAILED"]},"attempts":{"type":"integer","format":"int32"},"nextAttemptAt":{"type":"string","format":"date-time"},"lastResponseStatus":{"type":"integer","format":"int32"},"lastResponseSnippet":{"type":"string"},"deliveredAt":{"type":"string","format":"date-time"},"createdAt":{"type":"string","format":"date-time"}}},"ReplayRequest":{"type":"object","description":"Request to replay an exchange on an agent","properties":{"routeId":{"type":"string","description":"Camel route ID to replay on"},"body":{"type":"string","description":"Message body for the replayed exchange"},"headers":{"type":"object","additionalProperties":{"type":"string"},"description":"Message headers for the replayed exchange"},"originalExchangeId":{"type":"string","description":"Exchange ID of the original execution being replayed (for audit trail)"}},"required":["routeId"]},"ReplayResponse":{"type":"object","description":"Result of a replay command","properties":{"status":{"type":"string","description":"Replay outcome: SUCCESS or FAILURE"},"message":{"type":"string","description":"Human-readable result message"},"data":{"type":"string","description":"Structured result data from the agent (JSON)"}}},"AgentRefreshRequest":{"type":"object","description":"Agent token refresh request","properties":{"refreshToken":{"type":"string"}},"required":["refreshToken"]},"AgentRefreshResponse":{"type":"object","description":"Refreshed access and refresh tokens","properties":{"accessToken":{"type":"string"},"refreshToken":{"type":"string"}},"required":["accessToken","refreshToken"]},"HeartbeatRequest":{"type":"object","properties":{"routeStates":{"type":"object","additionalProperties":{"type":"string"}},"capabilities":{"type":"object","additionalProperties":{"type":"object"}},"environmentId":{"type":"string"}}},"CommandRequest":{"type":"object","description":"Command to send to agent(s)","properties":{"type":{"type":"string","description":"Command type: config-update, deep-trace, or replay"},"payload":{"type":"object","description":"Command payload JSON"}},"required":["type"]},"CommandSingleResponse":{"type":"object","description":"Result of sending a command to a single agent","properties":{"commandId":{"type":"string"},"status":{"type":"string"}},"required":["commandId","status"]},"CommandAckRequest":{"type":"object","properties":{"status":{"type":"string"},"message":{"type":"string"},"data":{"type":"string"}}},"AgentRegistrationRequest":{"type":"object","description":"Agent registration payload","properties":{"instanceId":{"type":"string"},"applicationId":{"type":"string","default":"default"},"environmentId":{"type":"string","default":"default"},"version":{"type":"string"},"routeIds":{"type":"array","items":{"type":"string"}},"capabilities":{"type":"object","additionalProperties":{"type":"object"}}},"required":["instanceId"]},"AgentRegistrationResponse":{"type":"object","description":"Agent registration result with JWT tokens and SSE endpoint","properties":{"instanceId":{"type":"string"},"sseEndpoint":{"type":"string"},"heartbeatIntervalMs":{"type":"integer","format":"int64"},"serverPublicKey":{"type":"string"},"accessToken":{"type":"string"},"refreshToken":{"type":"string"}},"required":["accessToken","instanceId","refreshToken","serverPublicKey","sseEndpoint"]},"CommandBroadcastResponse":{"type":"object","description":"Result of broadcasting a command to multiple agents","properties":{"commandIds":{"type":"array","items":{"type":"string"}},"targetCount":{"type":"integer","format":"int32"}},"required":["commandIds"]},"CreateUserRequest":{"type":"object","properties":{"username":{"type":"string"},"displayName":{"type":"string"},"email":{"type":"string"},"password":{"type":"string"}}},"SetPasswordRequest":{"type":"object","properties":{"password":{"type":"string","minLength":1}}},"CreateRoleRequest":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"},"scope":{"type":"string"}}},"OutboundConnectionTestResult":{"type":"object","properties":{"status":{"type":"integer","format":"int32"},"latencyMs":{"type":"integer","format":"int64"},"responseSnippet":{"type":"string"},"tlsProtocol":{"type":"string"},"tlsCipherSuite":{"type":"string"},"peerCertificateSubject":{"type":"string"},"peerCertificateExpiresAtEpochMs":{"type":"integer","format":"int64"},"error":{"type":"string"}}},"OidcTestResult":{"type":"object","description":"OIDC provider connectivity test result","properties":{"status":{"type":"string"},"authorizationEndpoint":{"type":"string"}},"required":["authorizationEndpoint","status"]},"UpdateLicenseRequest":{"type":"object","properties":{"token":{"type":"string"}}},"CreateGroupRequest":{"type":"object","properties":{"name":{"type":"string"},"parentGroupId":{"type":"string","format":"uuid"}}},"CreateEnvironmentRequest":{"type":"object","properties":{"slug":{"type":"string"},"displayName":{"type":"string"},"production":{"type":"boolean"}}},"TestRequest":{"type":"object","properties":{"rules":{"type":"array","items":{"$ref":"#/components/schemas/TestRuleRequest"}},"claims":{"type":"object","additionalProperties":{"type":"object"}}}},"TestRuleRequest":{"type":"object","properties":{"id":{"type":"string"},"claim":{"type":"string"},"matchType":{"type":"string"},"matchValue":{"type":"string"},"action":{"type":"string"},"target":{"type":"string"},"priority":{"type":"integer","format":"int32"}}},"MatchedRuleResponse":{"type":"object","properties":{"ruleId":{"type":"string"},"priority":{"type":"integer","format":"int32"},"claim":{"type":"string"},"matchType":{"type":"string"},"matchValue":{"type":"string"},"action":{"type":"string"},"target":{"type":"string"}}},"TestResponse":{"type":"object","properties":{"matchedRules":{"type":"array","items":{"$ref":"#/components/schemas/MatchedRuleResponse"}},"effectiveRoles":{"type":"array","items":{"type":"string"}},"effectiveGroups":{"type":"array","items":{"type":"string"}},"fallback":{"type":"boolean"}}},"ExecutionDetail":{"type":"object","properties":{"executionId":{"type":"string"},"routeId":{"type":"string"},"instanceId":{"type":"string"},"applicationId":{"type":"string"},"environment":{"type":"string"},"status":{"type":"string"},"startTime":{"type":"string","format":"date-time"},"endTime":{"type":"string","format":"date-time"},"durationMs":{"type":"integer","format":"int64"},"correlationId":{"type":"string"},"exchangeId":{"type":"string"},"errorMessage":{"type":"string"},"errorStackTrace":{"type":"string"},"diagramContentHash":{"type":"string"},"processors":{"type":"array","items":{"$ref":"#/components/schemas/ProcessorNode"}},"inputBody":{"type":"string"},"outputBody":{"type":"string"},"inputHeaders":{"type":"string"},"outputHeaders":{"type":"string"},"inputProperties":{"type":"string"},"outputProperties":{"type":"string"},"attributes":{"type":"object","additionalProperties":{"type":"string"}},"errorType":{"type":"string"},"errorCategory":{"type":"string"},"rootCauseType":{"type":"string"},"rootCauseMessage":{"type":"string"},"traceId":{"type":"string"},"spanId":{"type":"string"}},"required":["applicationId","attributes","correlationId","diagramContentHash","durationMs","endTime","environment","errorCategory","errorMessage","errorStackTrace","errorType","exchangeId","executionId","inputBody","inputHeaders","inputProperties","instanceId","outputBody","outputHeaders","outputProperties","processors","rootCauseMessage","rootCauseType","routeId","spanId","startTime","status","traceId"]},"ProcessorNode":{"type":"object","properties":{"processorId":{"type":"string"},"processorType":{"type":"string"},"status":{"type":"string"},"startTime":{"type":"string","format":"date-time"},"endTime":{"type":"string","format":"date-time"},"durationMs":{"type":"integer","format":"int64"},"errorMessage":{"type":"string"},"errorStackTrace":{"type":"string"},"attributes":{"type":"object","additionalProperties":{"type":"string"}},"iteration":{"type":"integer","format":"int32"},"iterationSize":{"type":"integer","format":"int32"},"loopIndex":{"type":"integer","format":"int32"},"loopSize":{"type":"integer","format":"int32"},"splitIndex":{"type":"integer","format":"int32"},"splitSize":{"type":"integer","format":"int32"},"multicastIndex":{"type":"integer","format":"int32"},"resolvedEndpointUri":{"type":"string"},"errorType":{"type":"string"},"errorCategory":{"type":"string"},"rootCauseType":{"type":"string"},"rootCauseMessage":{"type":"string"},"errorHandlerType":{"type":"string"},"circuitBreakerState":{"type":"string"},"fallbackTriggered":{"type":"boolean"},"filterMatched":{"type":"boolean"},"duplicateMessage":{"type":"boolean"},"hasTraceData":{"type":"boolean"},"children":{"type":"array","items":{"$ref":"#/components/schemas/ProcessorNode"}}},"required":["attributes","children","circuitBreakerState","duplicateMessage","durationMs","endTime","errorCategory","errorHandlerType","errorMessage","errorStackTrace","errorType","fallbackTriggered","filterMatched","hasTraceData","iteration","iterationSize","loopIndex","loopSize","multicastIndex","processorId","processorType","resolvedEndpointUri","rootCauseMessage","rootCauseType","splitIndex","splitSize","startTime","status"]},"ExecutionStats":{"type":"object","properties":{"totalCount":{"type":"integer","format":"int64"},"failedCount":{"type":"integer","format":"int64"},"avgDurationMs":{"type":"integer","format":"int64"},"p99LatencyMs":{"type":"integer","format":"int64"},"activeCount":{"type":"integer","format":"int64"},"totalToday":{"type":"integer","format":"int64"},"prevTotalCount":{"type":"integer","format":"int64"},"prevFailedCount":{"type":"integer","format":"int64"},"prevAvgDurationMs":{"type":"integer","format":"int64"},"prevP99LatencyMs":{"type":"integer","format":"int64"},"slaCompliance":{"type":"number","format":"double"}},"required":["activeCount","avgDurationMs","failedCount","p99LatencyMs","prevAvgDurationMs","prevFailedCount","prevP99LatencyMs","prevTotalCount","slaCompliance","totalCount","totalToday"]},"StatsTimeseries":{"type":"object","properties":{"buckets":{"type":"array","items":{"$ref":"#/components/schemas/TimeseriesBucket"}}},"required":["buckets"]},"TimeseriesBucket":{"type":"object","properties":{"time":{"type":"string","format":"date-time"},"totalCount":{"type":"integer","format":"int64"},"failedCount":{"type":"integer","format":"int64"},"avgDurationMs":{"type":"integer","format":"int64"},"p99DurationMs":{"type":"integer","format":"int64"},"activeCount":{"type":"integer","format":"int64"}},"required":["activeCount","avgDurationMs","failedCount","p99DurationMs","time","totalCount"]},"PunchcardCell":{"type":"object","properties":{"weekday":{"type":"integer","format":"int32"},"hour":{"type":"integer","format":"int32"},"totalCount":{"type":"integer","format":"int64"},"failedCount":{"type":"integer","format":"int64"}}},"AgentSummary":{"type":"object","description":"Summary of an agent instance for sidebar display","properties":{"id":{"type":"string"},"name":{"type":"string"},"status":{"type":"string"},"tps":{"type":"number","format":"double"}},"required":["id","name","status","tps"]},"AppCatalogEntry":{"type":"object","description":"Application catalog entry with routes and agents","properties":{"appId":{"type":"string"},"routes":{"type":"array","items":{"$ref":"#/components/schemas/RouteSummary"}},"agents":{"type":"array","items":{"$ref":"#/components/schemas/AgentSummary"}},"agentCount":{"type":"integer","format":"int32"},"health":{"type":"string"},"exchangeCount":{"type":"integer","format":"int64"}},"required":["agentCount","agents","appId","exchangeCount","health","routes"]},"RouteSummary":{"type":"object","description":"Summary of a route within an application","properties":{"routeId":{"type":"string"},"exchangeCount":{"type":"integer","format":"int64"},"lastSeen":{"type":"string","format":"date-time"},"fromEndpointUri":{"type":"string","description":"The from() endpoint URI, e.g. 'direct:processOrder'"},"routeState":{"type":"string","description":"Operational state of the route: stopped, suspended, or null (started/default)"}},"required":["exchangeCount","fromEndpointUri","lastSeen","routeId","routeState"]},"RouteMetrics":{"type":"object","description":"Aggregated route performance metrics","properties":{"routeId":{"type":"string"},"appId":{"type":"string"},"exchangeCount":{"type":"integer","format":"int64"},"successRate":{"type":"number","format":"double"},"avgDurationMs":{"type":"number","format":"double"},"p99DurationMs":{"type":"number","format":"double"},"errorRate":{"type":"number","format":"double"},"throughputPerSec":{"type":"number","format":"double"},"sparkline":{"type":"array","items":{"type":"number","format":"double"}},"slaCompliance":{"type":"number","format":"double"}},"required":["appId","avgDurationMs","errorRate","exchangeCount","p99DurationMs","routeId","slaCompliance","sparkline","successRate","throughputPerSec"]},"ProcessorMetrics":{"type":"object","properties":{"processorId":{"type":"string"},"processorType":{"type":"string"},"routeId":{"type":"string"},"appId":{"type":"string"},"totalCount":{"type":"integer","format":"int64"},"failedCount":{"type":"integer","format":"int64"},"avgDurationMs":{"type":"number","format":"double"},"p99DurationMs":{"type":"number","format":"double"},"errorRate":{"type":"number","format":"double"}},"required":["appId","avgDurationMs","errorRate","failedCount","p99DurationMs","processorId","processorType","routeId","totalCount"]},"LogEntryResponse":{"type":"object","description":"Application log entry","properties":{"timestamp":{"type":"string","description":"Log timestamp (ISO-8601)"},"level":{"type":"string","description":"Log level (INFO, WARN, ERROR, DEBUG, TRACE)"},"loggerName":{"type":"string","description":"Logger name"},"message":{"type":"string","description":"Log message"},"threadName":{"type":"string","description":"Thread name"},"stackTrace":{"type":"string","description":"Stack trace (if present)"},"exchangeId":{"type":"string","description":"Camel exchange ID (if present)"},"instanceId":{"type":"string","description":"Agent instance ID"},"application":{"type":"string","description":"Application ID"},"mdc":{"type":"object","additionalProperties":{"type":"string"},"description":"MDC context map"},"source":{"type":"string","description":"Log source: app or agent"}}},"LogSearchPageResponse":{"type":"object","description":"Log search response with cursor pagination and level counts","properties":{"data":{"type":"array","description":"Log entries for the current page","items":{"$ref":"#/components/schemas/LogEntryResponse"}},"nextCursor":{"type":"string","description":"Cursor for next page (null if no more results)"},"hasMore":{"type":"boolean","description":"Whether more results exist beyond this page"},"levelCounts":{"type":"object","additionalProperties":{"type":"integer","format":"int64"},"description":"Count of logs per level (unaffected by level filter)"}}},"TopError":{"type":"object","properties":{"errorType":{"type":"string"},"routeId":{"type":"string"},"processorId":{"type":"string"},"count":{"type":"integer","format":"int64"},"velocity":{"type":"number","format":"double"},"trend":{"type":"string"},"lastSeen":{"type":"string","format":"date-time"}}},"App":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"environmentId":{"type":"string","format":"uuid"},"slug":{"type":"string"},"displayName":{"type":"string"},"containerConfig":{"type":"object","additionalProperties":{"type":"object"}},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"DiagramLayout":{"type":"object","properties":{"width":{"type":"number","format":"double"},"height":{"type":"number","format":"double"},"nodes":{"type":"array","items":{"$ref":"#/components/schemas/PositionedNode"}},"edges":{"type":"array","items":{"$ref":"#/components/schemas/PositionedEdge"}}}},"PositionedEdge":{"type":"object","properties":{"sourceId":{"type":"string"},"targetId":{"type":"string"},"label":{"type":"string"},"points":{"type":"array","items":{"type":"array","items":{"type":"number","format":"double"}}}}},"PositionedNode":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"x":{"type":"number","format":"double"},"y":{"type":"number","format":"double"},"width":{"type":"number","format":"double"},"height":{"type":"number","format":"double"},"endpointUri":{"type":"string"}}},"AppConfigResponse":{"type":"object","properties":{"config":{"$ref":"#/components/schemas/ApplicationConfig"},"globalSensitiveKeys":{"type":"array","items":{"type":"string"}},"mergedSensitiveKeys":{"type":"array","items":{"type":"string"}}}},"UnreadCountResponse":{"type":"object","properties":{"count":{"type":"integer","format":"int64"}}},"AgentInstanceResponse":{"type":"object","description":"Agent instance summary with runtime metrics","properties":{"instanceId":{"type":"string"},"displayName":{"type":"string"},"applicationId":{"type":"string"},"environmentId":{"type":"string"},"status":{"type":"string"},"routeIds":{"type":"array","items":{"type":"string"}},"registeredAt":{"type":"string","format":"date-time"},"lastHeartbeat":{"type":"string","format":"date-time"},"version":{"type":"string"},"capabilities":{"type":"object","additionalProperties":{"type":"object"}},"tps":{"type":"number","format":"double"},"errorRate":{"type":"number","format":"double"},"activeRoutes":{"type":"integer","format":"int32"},"totalRoutes":{"type":"integer","format":"int32"},"uptimeSeconds":{"type":"integer","format":"int64"},"cpuUsage":{"type":"number","format":"double","description":"Recent average CPU usage (0.0–1.0), -1 if unavailable"}},"required":["activeRoutes","applicationId","capabilities","cpuUsage","displayName","environmentId","errorRate","instanceId","lastHeartbeat","registeredAt","routeIds","status","totalRoutes","tps","uptimeSeconds","version"]},"AgentMetricsResponse":{"type":"object","properties":{"metrics":{"type":"object","additionalProperties":{"type":"array","items":{"$ref":"#/components/schemas/MetricBucket"}}}},"required":["metrics"]},"MetricBucket":{"type":"object","properties":{"time":{"type":"string","format":"date-time"},"value":{"type":"number","format":"double"}},"required":["time","value"]},"AgentEventPageResponse":{"type":"object","description":"Cursor-paginated agent event list","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AgentEventResponse"}},"nextCursor":{"type":"string"},"hasMore":{"type":"boolean"}}},"AgentEventResponse":{"type":"object","description":"Agent lifecycle event","properties":{"id":{"type":"integer","format":"int64"},"instanceId":{"type":"string"},"applicationId":{"type":"string"},"eventType":{"type":"string"},"detail":{"type":"string"},"timestamp":{"type":"string","format":"date-time"}},"required":["applicationId","detail","eventType","id","instanceId","timestamp"]},"CatalogApp":{"type":"object","description":"Unified catalog entry combining app records with live agent data","properties":{"slug":{"type":"string","description":"Application slug (universal identifier)"},"displayName":{"type":"string","description":"Display name"},"managed":{"type":"boolean","description":"True if a managed App record exists in the database"},"environmentSlug":{"type":"string","description":"Environment slug"},"health":{"type":"string","description":"Composite health: deployment status + agent health"},"healthTooltip":{"type":"string","description":"Human-readable tooltip explaining the health state"},"agentCount":{"type":"integer","format":"int32","description":"Number of connected agents"},"routes":{"type":"array","description":"Live routes from agents","items":{"$ref":"#/components/schemas/RouteSummary"}},"agents":{"type":"array","description":"Connected agent summaries","items":{"$ref":"#/components/schemas/AgentSummary"}},"exchangeCount":{"type":"integer","format":"int64","description":"Total exchange count from ClickHouse"},"deployment":{"$ref":"#/components/schemas/DeploymentSummary","description":"Active deployment info, null if no deployment"}}},"DeploymentSummary":{"type":"object","properties":{"status":{"type":"string"},"replicas":{"type":"string"},"version":{"type":"integer","format":"int32"}}},"OidcPublicConfigResponse":{"type":"object","description":"OIDC configuration for SPA login flow","properties":{"issuer":{"type":"string"},"clientId":{"type":"string"},"authorizationEndpoint":{"type":"string"},"endSessionEndpoint":{"type":"string","description":"Present if the provider supports RP-initiated logout"},"resource":{"type":"string","description":"RFC 8707 resource indicator for the authorization request"},"additionalScopes":{"type":"array","description":"Additional scopes to request beyond openid email profile","items":{"type":"string"}}},"required":["authorizationEndpoint","clientId","issuer"]},"GroupSummary":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"RoleSummary":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"system":{"type":"boolean"},"source":{"type":"string"}}},"UserDetail":{"type":"object","properties":{"userId":{"type":"string"},"provider":{"type":"string"},"email":{"type":"string"},"displayName":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"directRoles":{"type":"array","items":{"$ref":"#/components/schemas/RoleSummary"}},"directGroups":{"type":"array","items":{"$ref":"#/components/schemas/GroupSummary"}},"effectiveRoles":{"type":"array","items":{"$ref":"#/components/schemas/RoleSummary"}},"effectiveGroups":{"type":"array","items":{"$ref":"#/components/schemas/GroupSummary"}}}},"SseEmitter":{"type":"object","properties":{"timeout":{"type":"integer","format":"int64"}}},"UsageStats":{"type":"object","properties":{"key":{"type":"string"},"count":{"type":"integer","format":"int64"},"avgDurationMs":{"type":"integer","format":"int64"}}},"SensitiveKeysConfig":{"type":"object","properties":{"keys":{"type":"array","items":{"type":"string"}}}},"RoleDetail":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":"string"},"scope":{"type":"string"},"system":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"},"assignedGroups":{"type":"array","items":{"$ref":"#/components/schemas/GroupSummary"}},"directUsers":{"type":"array","items":{"$ref":"#/components/schemas/UserSummary"}},"effectivePrincipals":{"type":"array","items":{"$ref":"#/components/schemas/UserSummary"}}}},"UserSummary":{"type":"object","properties":{"userId":{"type":"string"},"displayName":{"type":"string"},"provider":{"type":"string"}}},"RbacStats":{"type":"object","properties":{"userCount":{"type":"integer","format":"int32"},"activeUserCount":{"type":"integer","format":"int32"},"groupCount":{"type":"integer","format":"int32"},"maxGroupDepth":{"type":"integer","format":"int32"},"roleCount":{"type":"integer","format":"int32"}}},"LicenseInfo":{"type":"object","properties":{"tier":{"type":"string"},"features":{"type":"array","items":{"type":"string","enum":["topology","lineage","correlation","debugger","replay"]},"uniqueItems":true},"limits":{"type":"object","additionalProperties":{"type":"integer","format":"int32"}},"issuedAt":{"type":"string","format":"date-time"},"expiresAt":{"type":"string","format":"date-time"},"expired":{"type":"boolean"}}},"GroupDetail":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"parentGroupId":{"type":"string","format":"uuid"},"createdAt":{"type":"string","format":"date-time"},"directRoles":{"type":"array","items":{"$ref":"#/components/schemas/RoleSummary"}},"effectiveRoles":{"type":"array","items":{"$ref":"#/components/schemas/RoleSummary"}},"members":{"type":"array","items":{"$ref":"#/components/schemas/UserSummary"}},"childGroups":{"type":"array","items":{"$ref":"#/components/schemas/GroupSummary"}}}},"TableSizeResponse":{"type":"object","description":"Table size and row count information","properties":{"tableName":{"type":"string","description":"Table name"},"rowCount":{"type":"integer","format":"int64","description":"Approximate row count"},"dataSize":{"type":"string","description":"Human-readable data size"},"indexSize":{"type":"string","description":"Human-readable index size"},"dataSizeBytes":{"type":"integer","format":"int64","description":"Data size in bytes"},"indexSizeBytes":{"type":"integer","format":"int64","description":"Index size in bytes"}}},"DatabaseStatusResponse":{"type":"object","description":"Database connection and version status","properties":{"connected":{"type":"boolean","description":"Whether the database is reachable"},"version":{"type":"string","description":"PostgreSQL version string"},"host":{"type":"string","description":"Database host"},"schema":{"type":"string","description":"Current schema"}}},"ActiveQueryResponse":{"type":"object","description":"Currently running database query","properties":{"pid":{"type":"integer","format":"int32","description":"Backend process ID"},"durationSeconds":{"type":"number","format":"double","description":"Query duration in seconds"},"state":{"type":"string","description":"Backend state (active, idle, etc.)"},"query":{"type":"string","description":"SQL query text"}}},"ConnectionPoolResponse":{"type":"object","description":"HikariCP connection pool statistics","properties":{"activeConnections":{"type":"integer","format":"int32","description":"Number of currently active connections"},"idleConnections":{"type":"integer","format":"int32","description":"Number of idle connections"},"pendingThreads":{"type":"integer","format":"int32","description":"Number of threads waiting for a connection"},"maxWaitMs":{"type":"integer","format":"int64","description":"Maximum wait time in milliseconds"},"maxPoolSize":{"type":"integer","format":"int32","description":"Maximum pool size"}}},"ClickHouseTableInfo":{"type":"object","description":"ClickHouse table information","properties":{"name":{"type":"string"},"engine":{"type":"string"},"rowCount":{"type":"integer","format":"int64"},"dataSize":{"type":"string"},"dataSizeBytes":{"type":"integer","format":"int64"},"partitionCount":{"type":"integer","format":"int32"}}},"ClickHouseStatusResponse":{"type":"object","description":"ClickHouse cluster status","properties":{"reachable":{"type":"boolean"},"version":{"type":"string"},"uptime":{"type":"string"},"host":{"type":"string"}}},"ClickHouseQueryInfo":{"type":"object","description":"Active ClickHouse query information","properties":{"queryId":{"type":"string"},"elapsedSeconds":{"type":"number","format":"double"},"memory":{"type":"string"},"readRows":{"type":"integer","format":"int64"},"query":{"type":"string"}}},"IndexerPipelineResponse":{"type":"object","description":"Search indexer pipeline statistics","properties":{"queueDepth":{"type":"integer","format":"int32"},"maxQueueSize":{"type":"integer","format":"int32"},"failedCount":{"type":"integer","format":"int64"},"indexedCount":{"type":"integer","format":"int64"},"debounceMs":{"type":"integer","format":"int64"},"indexingRate":{"type":"number","format":"double"},"lastIndexedAt":{"type":"string","format":"date-time"}}},"ClickHousePerformanceResponse":{"type":"object","description":"ClickHouse storage and performance metrics","properties":{"diskSize":{"type":"string"},"uncompressedSize":{"type":"string"},"compressionRatio":{"type":"number","format":"double"},"totalRows":{"type":"integer","format":"int64"},"partCount":{"type":"integer","format":"int32"},"memoryUsage":{"type":"string"},"currentQueries":{"type":"integer","format":"int32"}}},"AuditLogPageResponse":{"type":"object","description":"Paginated audit log entries","properties":{"items":{"type":"array","description":"Audit log entries","items":{"$ref":"#/components/schemas/AuditRecord"}},"totalCount":{"type":"integer","format":"int64","description":"Total number of matching entries"},"page":{"type":"integer","format":"int32","description":"Current page number (0-based)"},"pageSize":{"type":"integer","format":"int32","description":"Page size"},"totalPages":{"type":"integer","format":"int32","description":"Total number of pages"}}},"AuditRecord":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"timestamp":{"type":"string","format":"date-time"},"username":{"type":"string"},"action":{"type":"string"},"category":{"type":"string","enum":["INFRA","AUTH","USER_MGMT","CONFIG","RBAC","AGENT","OUTBOUND_CONNECTION_CHANGE","OUTBOUND_HTTP_TRUST_CHANGE","ALERT_RULE_CHANGE","ALERT_SILENCE_CHANGE"]},"target":{"type":"string"},"detail":{"type":"object","additionalProperties":{"type":"object"}},"result":{"type":"string","enum":["SUCCESS","FAILURE"]},"ipAddress":{"type":"string"},"userAgent":{"type":"string"}}}},"securitySchemes":{"bearer":{"type":"http","scheme":"bearer","bearerFormat":"JWT"}}}} \ No newline at end of file diff --git a/ui/src/api/schema.d.ts b/ui/src/api/schema.d.ts index 35fadc91..d7c1517e 100644 --- a/ui/src/api/schema.d.ts +++ b/ui/src/api/schema.d.ts @@ -64,6 +64,38 @@ export interface paths { patch?: never; trace?: never; }; + "/environments/{envSlug}/alerts/silences/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["update_1"]; + post?: never; + delete: operations["delete_1"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/environments/{envSlug}/alerts/rules/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get"]; + put: operations["update_2"]; + post?: never; + delete: operations["delete_2"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/users/{userId}": { parameters: { query?: never; @@ -141,6 +173,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/outbound-connections/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_1"]; + put: operations["update_3"]; + post?: never; + delete: operations["delete_3"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/oidc": { parameters: { query?: never; @@ -243,12 +291,12 @@ export interface paths { cookie?: never; }; /** Get a claim mapping rule by ID */ - get: operations["get"]; + get: operations["get_2"]; /** Update a claim mapping rule */ - put: operations["update_1"]; + put: operations["update_4"]; post?: never; /** Delete a claim mapping rule */ - delete: operations["delete_1"]; + delete: operations["delete_4"]; options?: never; head?: never; patch?: never; @@ -385,6 +433,150 @@ export interface paths { patch?: never; trace?: never; }; + "/environments/{envSlug}/alerts/{id}/read": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["read"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/environments/{envSlug}/alerts/{id}/ack": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["ack"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/environments/{envSlug}/alerts/silences": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["list"]; + put?: never; + post: operations["create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/environments/{envSlug}/alerts/rules": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["list_1"]; + put?: never; + post: operations["create_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/environments/{envSlug}/alerts/rules/{id}/test-evaluate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["testEvaluate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/environments/{envSlug}/alerts/rules/{id}/render-preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["renderPreview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/environments/{envSlug}/alerts/rules/{id}/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["enable"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/environments/{envSlug}/alerts/rules/{id}/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["disable"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/environments/{envSlug}/alerts/bulk-read": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["bulkRead"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/data/metrics": { parameters: { query?: never; @@ -530,6 +722,22 @@ export interface paths { patch?: never; trace?: never; }; + "/alerts/notifications/{id}/retry": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["retry"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/agents/{id}/replay": { parameters: { query?: never; @@ -799,6 +1007,38 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/outbound-connections": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["list_2"]; + put?: never; + post: operations["create_2"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/outbound-connections/{id}/test": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["test"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/oidc/test": { parameters: { query?: never; @@ -827,7 +1067,7 @@ export interface paths { get: operations["getCurrent"]; put?: never; /** Update license token at runtime */ - post: operations["update_2"]; + post: operations["update_5"]; delete?: never; options?: never; head?: never; @@ -913,10 +1153,10 @@ export interface paths { cookie?: never; }; /** List all claim mapping rules */ - get: operations["list"]; + get: operations["list_3"]; put?: never; /** Create a claim mapping rule */ - post: operations["create"]; + post: operations["create_3"]; delete?: never; options?: never; head?: never; @@ -933,7 +1173,7 @@ export interface paths { get?: never; put?: never; /** Test claim mapping rules against a set of claims (accepts unsaved rules) */ - post: operations["test"]; + post: operations["test_1"]; delete?: never; options?: never; head?: never; @@ -1350,6 +1590,70 @@ export interface paths { patch?: never; trace?: never; }; + "/environments/{envSlug}/alerts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["list_4"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/environments/{envSlug}/alerts/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_3"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/environments/{envSlug}/alerts/{alertId}/notifications": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listForInstance"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/environments/{envSlug}/alerts/unread-count": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["unreadCount"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/environments/{envSlug}/agents": { parameters: { query?: never; @@ -1557,6 +1861,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/outbound-connections/{id}/usage": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["usage"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/database/tables": { parameters: { query?: never; @@ -1868,6 +2188,208 @@ export interface components { config?: components["schemas"]["ApplicationConfig"]; pushResult?: components["schemas"]["CommandGroupResponse"]; }; + AlertSilenceRequest: { + matcher: components["schemas"]["SilenceMatcher"]; + reason?: string; + /** Format: date-time */ + startsAt: string; + /** Format: date-time */ + endsAt: string; + }; + SilenceMatcher: { + /** Format: uuid */ + ruleId?: string; + appSlug?: string; + routeId?: string; + agentId?: string; + /** @enum {string} */ + severity?: "CRITICAL" | "WARNING" | "INFO"; + wildcard?: boolean; + }; + AlertSilenceResponse: { + /** Format: uuid */ + id?: string; + /** Format: uuid */ + environmentId?: string; + matcher?: components["schemas"]["SilenceMatcher"]; + reason?: string; + /** Format: date-time */ + startsAt?: string; + /** Format: date-time */ + endsAt?: string; + createdBy?: string; + /** Format: date-time */ + createdAt?: string; + }; + AgentStateCondition: { + kind: "AgentStateCondition"; + } & (Omit & { + scope?: components["schemas"]["AlertScope"]; + state?: string; + /** Format: int32 */ + forSeconds?: number; + /** @enum {string} */ + readonly kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC"; + }); + AlertCondition: { + /** @enum {string} */ + kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC"; + }; + AlertRuleRequest: { + name?: string; + description?: string; + /** @enum {string} */ + severity: "CRITICAL" | "WARNING" | "INFO"; + /** @enum {string} */ + conditionKind: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC"; + condition: components["schemas"]["AgentStateCondition"] | components["schemas"]["DeploymentStateCondition"] | components["schemas"]["ExchangeMatchCondition"] | components["schemas"]["JvmMetricCondition"] | components["schemas"]["LogPatternCondition"] | components["schemas"]["RouteMetricCondition"]; + /** Format: int32 */ + evaluationIntervalSeconds?: number; + /** Format: int32 */ + forDurationSeconds?: number; + /** Format: int32 */ + reNotifyMinutes?: number; + notificationTitleTmpl?: string; + notificationMessageTmpl?: string; + webhooks?: components["schemas"]["WebhookBindingRequest"][]; + targets?: components["schemas"]["AlertRuleTarget"][]; + }; + AlertRuleTarget: { + /** Format: uuid */ + id?: string; + /** Format: uuid */ + ruleId?: string; + /** @enum {string} */ + kind?: "USER" | "GROUP" | "ROLE"; + targetId?: string; + }; + AlertScope: { + appSlug?: string; + routeId?: string; + agentId?: string; + }; + DeploymentStateCondition: { + kind: "DeploymentStateCondition"; + } & (Omit & { + scope?: components["schemas"]["AlertScope"]; + states?: string[]; + /** @enum {string} */ + readonly kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC"; + }); + ExchangeFilter: { + status?: string; + attributes?: { + [key: string]: string; + }; + }; + ExchangeMatchCondition: { + kind: "ExchangeMatchCondition"; + } & (Omit & { + scope?: components["schemas"]["AlertScope"]; + filter?: components["schemas"]["ExchangeFilter"]; + /** @enum {string} */ + fireMode?: "PER_EXCHANGE" | "COUNT_IN_WINDOW"; + /** Format: int32 */ + threshold?: number; + /** Format: int32 */ + windowSeconds?: number; + /** Format: int32 */ + perExchangeLingerSeconds?: number; + /** @enum {string} */ + readonly kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC"; + }); + JvmMetricCondition: { + kind: "JvmMetricCondition"; + } & (Omit & { + scope?: components["schemas"]["AlertScope"]; + metric?: string; + /** @enum {string} */ + aggregation?: "MAX" | "MIN" | "AVG" | "LATEST"; + /** @enum {string} */ + comparator?: "GT" | "GTE" | "LT" | "LTE" | "EQ"; + /** Format: double */ + threshold?: number; + /** Format: int32 */ + windowSeconds?: number; + /** @enum {string} */ + readonly kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC"; + }); + LogPatternCondition: { + kind: "LogPatternCondition"; + } & (Omit & { + scope?: components["schemas"]["AlertScope"]; + level?: string; + pattern?: string; + /** Format: int32 */ + threshold?: number; + /** Format: int32 */ + windowSeconds?: number; + /** @enum {string} */ + readonly kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC"; + }); + RouteMetricCondition: { + kind: "RouteMetricCondition"; + } & (Omit & { + scope?: components["schemas"]["AlertScope"]; + /** @enum {string} */ + metric?: "ERROR_RATE" | "AVG_DURATION_MS" | "P99_LATENCY_MS" | "THROUGHPUT" | "ERROR_COUNT"; + /** @enum {string} */ + comparator?: "GT" | "GTE" | "LT" | "LTE" | "EQ"; + /** Format: double */ + threshold?: number; + /** Format: int32 */ + windowSeconds?: number; + /** @enum {string} */ + readonly kind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC"; + }); + WebhookBindingRequest: { + /** Format: uuid */ + outboundConnectionId: string; + bodyOverride?: string; + headerOverrides?: { + [key: string]: string; + }; + }; + AlertRuleResponse: { + /** Format: uuid */ + id?: string; + /** Format: uuid */ + environmentId?: string; + name?: string; + description?: string; + /** @enum {string} */ + severity?: "CRITICAL" | "WARNING" | "INFO"; + enabled?: boolean; + /** @enum {string} */ + conditionKind?: "ROUTE_METRIC" | "EXCHANGE_MATCH" | "AGENT_STATE" | "DEPLOYMENT_STATE" | "LOG_PATTERN" | "JVM_METRIC"; + condition?: components["schemas"]["AgentStateCondition"] | components["schemas"]["DeploymentStateCondition"] | components["schemas"]["ExchangeMatchCondition"] | components["schemas"]["JvmMetricCondition"] | components["schemas"]["LogPatternCondition"] | components["schemas"]["RouteMetricCondition"]; + /** Format: int32 */ + evaluationIntervalSeconds?: number; + /** Format: int32 */ + forDurationSeconds?: number; + /** Format: int32 */ + reNotifyMinutes?: number; + notificationTitleTmpl?: string; + notificationMessageTmpl?: string; + webhooks?: components["schemas"]["WebhookBindingResponse"][]; + targets?: components["schemas"]["AlertRuleTarget"][]; + /** Format: date-time */ + createdAt?: string; + createdBy?: string; + /** Format: date-time */ + updatedAt?: string; + updatedBy?: string; + }; + WebhookBindingResponse: { + /** Format: uuid */ + id?: string; + /** Format: uuid */ + outboundConnectionId?: string; + bodyOverride?: string; + headerOverrides?: { + [key: string]: string; + }; + }; UpdateUserRequest: { displayName?: string; email?: string; @@ -1926,6 +2448,58 @@ export interface components { description?: string; scope?: string; }; + Basic: components["schemas"]["OutboundAuth"] & { + username?: string; + passwordCiphertext?: string; + }; + Bearer: components["schemas"]["OutboundAuth"] & { + tokenCiphertext?: string; + }; + None: components["schemas"]["OutboundAuth"]; + OutboundAuth: Record; + OutboundConnectionRequest: { + name?: string; + description?: string; + url?: string; + /** @enum {string} */ + method: "POST" | "PUT" | "PATCH"; + defaultHeaders?: { + [key: string]: string; + }; + defaultBodyTmpl?: string; + /** @enum {string} */ + tlsTrustMode: "SYSTEM_DEFAULT" | "TRUST_ALL" | "TRUST_PATHS"; + tlsCaPemPaths?: string[]; + hmacSecret?: string; + auth: components["schemas"]["Basic"] | components["schemas"]["Bearer"] | components["schemas"]["None"]; + allowedEnvironmentIds?: string[]; + }; + OutboundConnectionDto: { + /** Format: uuid */ + id?: string; + name?: string; + description?: string; + url?: string; + /** @enum {string} */ + method?: "POST" | "PUT" | "PATCH"; + defaultHeaders?: { + [key: string]: string; + }; + defaultBodyTmpl?: string; + /** @enum {string} */ + tlsTrustMode?: "SYSTEM_DEFAULT" | "TRUST_ALL" | "TRUST_PATHS"; + tlsCaPemPaths?: string[]; + hmacSecretSet?: boolean; + /** @enum {string} */ + authKind?: "NONE" | "BEARER" | "BASIC"; + allowedEnvironmentIds?: string[]; + /** Format: date-time */ + createdAt?: string; + createdBy?: string; + /** Format: date-time */ + updatedAt?: string; + updatedBy?: string; + }; /** @description OIDC configuration update request */ OidcAdminConfigRequest: { enabled?: boolean; @@ -2122,6 +2696,52 @@ export interface components { result?: string; error?: string; }; + AlertDto: { + /** Format: uuid */ + id?: string; + /** Format: uuid */ + ruleId?: string; + /** Format: uuid */ + environmentId?: string; + /** @enum {string} */ + state?: "PENDING" | "FIRING" | "ACKNOWLEDGED" | "RESOLVED"; + /** @enum {string} */ + severity?: "CRITICAL" | "WARNING" | "INFO"; + title?: string; + message?: string; + /** Format: date-time */ + firedAt?: string; + /** Format: date-time */ + ackedAt?: string; + ackedBy?: string; + /** Format: date-time */ + resolvedAt?: string; + silenced?: boolean; + /** Format: double */ + currentValue?: number; + /** Format: double */ + threshold?: number; + context?: { + [key: string]: Record; + }; + }; + TestEvaluateRequest: Record; + TestEvaluateResponse: { + resultKind?: string; + detail?: string; + }; + RenderPreviewRequest: { + context?: { + [key: string]: Record; + }; + }; + RenderPreviewResponse: { + title?: string; + message?: string; + }; + BulkReadRequest: { + instanceIds: string[]; + }; LogEntry: { /** Format: date-time */ timestamp?: string; @@ -2154,6 +2774,29 @@ export interface components { username?: string; password?: string; }; + AlertNotificationDto: { + /** Format: uuid */ + id?: string; + /** Format: uuid */ + alertInstanceId?: string; + /** Format: uuid */ + webhookId?: string; + /** Format: uuid */ + outboundConnectionId?: string; + /** @enum {string} */ + status?: "PENDING" | "DELIVERED" | "FAILED"; + /** Format: int32 */ + attempts?: number; + /** Format: date-time */ + nextAttemptAt?: string; + /** Format: int32 */ + lastResponseStatus?: number; + lastResponseSnippet?: string; + /** Format: date-time */ + deliveredAt?: string; + /** Format: date-time */ + createdAt?: string; + }; /** @description Request to replay an exchange on an agent */ ReplayRequest: { /** @description Camel route ID to replay on */ @@ -2254,6 +2897,19 @@ export interface components { description?: string; scope?: string; }; + OutboundConnectionTestResult: { + /** Format: int32 */ + status?: number; + /** Format: int64 */ + latencyMs?: number; + responseSnippet?: string; + tlsProtocol?: string; + tlsCipherSuite?: string; + peerCertificateSubject?: string; + /** Format: int64 */ + peerCertificateExpiresAtEpochMs?: number; + error?: string; + }; /** @description OIDC provider connectivity test result */ OidcTestResult: { status: string; @@ -2599,6 +3255,10 @@ export interface components { globalSensitiveKeys?: string[]; mergedSensitiveKeys?: string[]; }; + UnreadCountResponse: { + /** Format: int64 */ + count?: number; + }; /** @description Agent instance summary with runtime metrics */ AgentInstanceResponse: { instanceId: string; @@ -2974,7 +3634,7 @@ export interface components { username?: string; action?: string; /** @enum {string} */ - category?: "INFRA" | "AUTH" | "USER_MGMT" | "CONFIG" | "RBAC" | "AGENT"; + category?: "INFRA" | "AUTH" | "USER_MGMT" | "CONFIG" | "RBAC" | "AGENT" | "OUTBOUND_CONNECTION_CHANGE" | "OUTBOUND_HTTP_TRUST_CHANGE" | "ALERT_RULE_CHANGE" | "ALERT_SILENCE_CHANGE"; target?: string; detail?: { [key: string]: Record; @@ -3167,6 +3827,130 @@ export interface operations { }; }; }; + update_1: { + parameters: { + query: { + env: components["schemas"]["Environment"]; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AlertSilenceRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AlertSilenceResponse"]; + }; + }; + }; + }; + delete_1: { + parameters: { + query: { + env: components["schemas"]["Environment"]; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + get: { + parameters: { + query: { + env: components["schemas"]["Environment"]; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AlertRuleResponse"]; + }; + }; + }; + }; + update_2: { + parameters: { + query: { + env: components["schemas"]["Environment"]; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AlertRuleRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AlertRuleResponse"]; + }; + }; + }; + }; + delete_2: { + parameters: { + query: { + env: components["schemas"]["Environment"]; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; getUser: { parameters: { query?: never; @@ -3449,6 +4233,74 @@ export interface operations { }; }; }; + get_1: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["OutboundConnectionDto"]; + }; + }; + }; + }; + update_3: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["OutboundConnectionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["OutboundConnectionDto"]; + }; + }; + }; + }; + delete_3: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; getConfig_1: { parameters: { query?: never; @@ -3803,7 +4655,7 @@ export interface operations { }; }; }; - get: { + get_2: { parameters: { query?: never; header?: never; @@ -3825,7 +4677,7 @@ export interface operations { }; }; }; - update_1: { + update_4: { parameters: { query?: never; header?: never; @@ -3851,7 +4703,7 @@ export interface operations { }; }; }; - delete_1: { + delete_4: { parameters: { query?: never; header?: never; @@ -4187,6 +5039,276 @@ export interface operations { }; }; }; + read: { + parameters: { + query: { + env: components["schemas"]["Environment"]; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + ack: { + parameters: { + query: { + env: components["schemas"]["Environment"]; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AlertDto"]; + }; + }; + }; + }; + list: { + parameters: { + query: { + env: components["schemas"]["Environment"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AlertSilenceResponse"][]; + }; + }; + }; + }; + create: { + parameters: { + query: { + env: components["schemas"]["Environment"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AlertSilenceRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AlertSilenceResponse"]; + }; + }; + }; + }; + list_1: { + parameters: { + query: { + env: components["schemas"]["Environment"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AlertRuleResponse"][]; + }; + }; + }; + }; + create_1: { + parameters: { + query: { + env: components["schemas"]["Environment"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AlertRuleRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AlertRuleResponse"]; + }; + }; + }; + }; + testEvaluate: { + parameters: { + query: { + env: components["schemas"]["Environment"]; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TestEvaluateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["TestEvaluateResponse"]; + }; + }; + }; + }; + renderPreview: { + parameters: { + query: { + env: components["schemas"]["Environment"]; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RenderPreviewRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["RenderPreviewResponse"]; + }; + }; + }; + }; + enable: { + parameters: { + query: { + env: components["schemas"]["Environment"]; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AlertRuleResponse"]; + }; + }; + }; + }; + disable: { + parameters: { + query: { + env: components["schemas"]["Environment"]; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AlertRuleResponse"]; + }; + }; + }; + }; + bulkRead: { + parameters: { + query: { + env: components["schemas"]["Environment"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BulkReadRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; ingestMetrics: { parameters: { query?: never; @@ -4437,6 +5559,28 @@ export interface operations { }; }; }; + retry: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AlertNotificationDto"]; + }; + }; + }; + }; replayExchange: { parameters: { query?: never; @@ -4987,6 +6131,72 @@ export interface operations { }; }; }; + list_2: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["OutboundConnectionDto"][]; + }; + }; + }; + }; + create_2: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["OutboundConnectionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["OutboundConnectionDto"]; + }; + }; + }; + }; + test: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["OutboundConnectionTestResult"]; + }; + }; + }; + }; testConnection: { parameters: { query?: never; @@ -5036,7 +6246,7 @@ export interface operations { }; }; }; - update_2: { + update_5: { parameters: { query?: never; header?: never; @@ -5235,7 +6445,7 @@ export interface operations { }; }; }; - list: { + list_3: { parameters: { query?: never; header?: never; @@ -5255,7 +6465,7 @@ export interface operations { }; }; }; - create: { + create_3: { parameters: { query?: never; header?: never; @@ -5279,7 +6489,7 @@ export interface operations { }; }; }; - test: { + test_1: { parameters: { query?: never; header?: never; @@ -5995,6 +7205,99 @@ export interface operations { }; }; }; + list_4: { + parameters: { + query: { + env: components["schemas"]["Environment"]; + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AlertDto"][]; + }; + }; + }; + }; + get_3: { + parameters: { + query: { + env: components["schemas"]["Environment"]; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AlertDto"]; + }; + }; + }; + }; + listForInstance: { + parameters: { + query: { + env: components["schemas"]["Environment"]; + }; + header?: never; + path: { + alertId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AlertNotificationDto"][]; + }; + }; + }; + }; + unreadCount: { + parameters: { + query: { + env: components["schemas"]["Environment"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["UnreadCountResponse"]; + }; + }; + }; + }; listAgents: { parameters: { query: { @@ -6318,6 +7621,28 @@ export interface operations { }; }; }; + usage: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string[]; + }; + }; + }; + }; getTables: { parameters: { query?: never; From 1a8b9eb41ba3c70740d5818f112aca7c020075b3 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:00:49 +0200 Subject: [PATCH 05/39] feat(ui/alerts): shared env helper for alerting query hooks --- ui/src/api/queries/alertMeta.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 ui/src/api/queries/alertMeta.ts diff --git a/ui/src/api/queries/alertMeta.ts b/ui/src/api/queries/alertMeta.ts new file mode 100644 index 00000000..9819fd55 --- /dev/null +++ b/ui/src/api/queries/alertMeta.ts @@ -0,0 +1,24 @@ +import { useEnvironmentStore } from '../environment-store'; +import { api } 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); +} + +/** Re-exported openapi-fetch client for alerting query hooks. + * The underlying export in `../client` is named `api`; we re-export as + * `apiClient` so alerting hooks can import it under the plan's canonical name. + */ +export { api as apiClient }; From 83a8912da6a4bbb49163e64f6935ff4b5481bd38 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:07:16 +0200 Subject: [PATCH 06/39] feat(ui/alerts): TanStack Query hooks for /alerts endpoints Adds env-scoped hooks for the alerts inbox: - useAlerts (30s poll, background-paused, filter-aware) - useAlert, useUnreadCount (30s poll) - useAckAlert, useMarkAlertRead, useBulkReadAlerts (mutations that invalidate the alerts query key tree + unread-count) Test file uses .tsx because the QueryClientProvider wrapper relies on JSX; vitest picks up both .ts and .tsx via the configured include glob. Client mock targets the actual export name (`api` in ../client) rather than the `apiClient` alias that alertMeta re-exports. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/src/api/queries/alerts.test.tsx | 73 +++++++++++++ ui/src/api/queries/alerts.ts | 168 +++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 ui/src/api/queries/alerts.test.tsx create mode 100644 ui/src/api/queries/alerts.ts diff --git a/ui/src/api/queries/alerts.test.tsx b/ui/src/api/queries/alerts.test.tsx new file mode 100644 index 00000000..7ae91516 --- /dev/null +++ b/ui/src/api/queries/alerts.test.tsx @@ -0,0 +1,73 @@ +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', () => ({ + api: { GET: vi.fn(), POST: vi.fn() }, +})); + +import { api as apiClient } from '../client'; +import { useAlerts, useUnreadCount } from './alerts'; + +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +describe('useAlerts', () => { + beforeEach(() => { + vi.clearAllMocks(); + useEnvironmentStore.setState({ environment: 'dev' }); + }); + + it('fetches alerts for selected env and passes filter query params', async () => { + (apiClient.GET as any).mockResolvedValue({ data: [], error: null }); + const { result } = renderHook( + () => useAlerts({ state: 'FIRING', severity: ['CRITICAL', 'WARNING'] }), + { wrapper }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiClient.GET).toHaveBeenCalledWith( + '/environments/{envSlug}/alerts', + expect.objectContaining({ + params: expect.objectContaining({ + path: { envSlug: 'dev' }, + query: expect.objectContaining({ + state: ['FIRING'], + severity: ['CRITICAL', 'WARNING'], + limit: 100, + }), + }), + }), + ); + }); + + it('does not fetch when no env is selected', () => { + useEnvironmentStore.setState({ environment: undefined }); + const { result } = renderHook(() => useAlerts(), { wrapper }); + expect(result.current.fetchStatus).toBe('idle'); + expect(apiClient.GET).not.toHaveBeenCalled(); + }); +}); + +describe('useUnreadCount', () => { + beforeEach(() => { + vi.clearAllMocks(); + useEnvironmentStore.setState({ environment: 'dev' }); + }); + + it('returns the server payload unmodified', async () => { + (apiClient.GET as any).mockResolvedValue({ + data: { total: 3, bySeverity: { CRITICAL: 1, WARNING: 2, INFO: 0 } }, + error: null, + }); + const { result } = renderHook(() => useUnreadCount(), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual({ + total: 3, + bySeverity: { CRITICAL: 1, WARNING: 2, INFO: 0 }, + }); + }); +}); diff --git a/ui/src/api/queries/alerts.ts b/ui/src/api/queries/alerts.ts new file mode 100644 index 00000000..d2541c3d --- /dev/null +++ b/ui/src/api/queries/alerts.ts @@ -0,0 +1,168 @@ +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 = NonNullable; +type AlertSeverity = NonNullable; + +export interface AlertsFilter { + state?: AlertState | AlertState[]; + severity?: AlertSeverity | AlertSeverity[]; + ruleId?: string; + limit?: number; +} + +function toArray(v: T | T[] | undefined): T[] | undefined { + if (v === undefined) return undefined; + return Array.isArray(v) ? v : [v]; +} + +// NOTE ON TYPES: the generated OpenAPI schema for env-scoped alert endpoints +// emits `path?: never` plus a `query.env: Environment` parameter because the +// server resolves the env via the `@EnvPath` argument resolver, which the +// OpenAPI scanner does not recognise as a path variable. At runtime the URL +// template `{envSlug}` is substituted from `params.path.envSlug` by +// openapi-fetch regardless of what the TS types say; we therefore cast the +// call options to `any` to bypass the generated type oddity. + +/** List alert instances in the current env. Polls every 30s (pauses in background). */ +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, + }, + }, + } as any, + ); + if (error) throw error; + return data as AlertDto[]; + }, + }); +} + +/** Fetch a single alert instance by id. */ +export function useAlert(id: string | undefined) { + const env = useSelectedEnv(); + return useQuery({ + queryKey: ['alerts', env, 'detail', id], + enabled: !!env && !!id, + queryFn: async () => { + if (!env || !id) throw new Error('no env/id'); + const { data, error } = await apiClient.GET( + '/environments/{envSlug}/alerts/{id}', + { + params: { path: { envSlug: env, id } }, + } as any, + ); + if (error) throw error; + return data as AlertDto; + }, + }); +} + +/** Unread alert count for the current env. Polls every 30s (pauses in background). */ +export function useUnreadCount() { + const env = useSelectedEnv(); + return useQuery({ + queryKey: ['alerts', env, 'unread-count'], + 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/unread-count', + { + params: { path: { envSlug: env } }, + } as any, + ); + if (error) throw error; + return data as UnreadCountResponse; + }, + }); +} + +/** Acknowledge a single alert instance. */ +export function useAckAlert() { + const env = useSelectedEnv(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => { + if (!env) throw new Error('no env'); + const { data, error } = await apiClient.POST( + '/environments/{envSlug}/alerts/{id}/ack', + { + params: { path: { envSlug: env, id } }, + } as any, + ); + if (error) throw error; + return data as AlertDto; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alerts', env] }); + }, + }); +} + +/** Mark a single alert instance as read (inbox semantics). */ +export function useMarkAlertRead() { + const env = useSelectedEnv(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => { + if (!env) throw new Error('no env'); + const { error } = await apiClient.POST( + '/environments/{envSlug}/alerts/{id}/read', + { + params: { path: { envSlug: env, id } }, + } as any, + ); + if (error) throw error; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alerts', env] }); + qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] }); + }, + }); +} + +/** Mark a batch of alert instances as read. */ +export function useBulkReadAlerts() { + const env = useSelectedEnv(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (ids: string[]) => { + if (!env) throw new Error('no env'); + const { error } = await apiClient.POST( + '/environments/{envSlug}/alerts/bulk-read', + { + params: { path: { envSlug: env } }, + body: { alertInstanceIds: ids }, + } as any, + ); + if (error) throw error; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alerts', env] }); + qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] }); + }, + }); +} From 82c29f46a59a67bd11b0a81ba2aef0a10c5f6e25 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:08:22 +0200 Subject: [PATCH 07/39] fix(ui/alerts): bulk-read body uses instanceIds to match BulkReadRequest DTO Plan 03 prose had 'alertInstanceIds'; backend record is 'instanceIds'. --- ui/src/api/queries/alerts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/api/queries/alerts.ts b/ui/src/api/queries/alerts.ts index d2541c3d..63b167e7 100644 --- a/ui/src/api/queries/alerts.ts +++ b/ui/src/api/queries/alerts.ts @@ -155,7 +155,7 @@ export function useBulkReadAlerts() { '/environments/{envSlug}/alerts/bulk-read', { params: { path: { envSlug: env } }, - body: { alertInstanceIds: ids }, + body: { instanceIds: ids }, } as any, ); if (error) throw error; From c6c3dd9cfe70e163658d4edaf99e9e58cc0a91d6 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:13:07 +0200 Subject: [PATCH 08/39] feat(ui/alerts): alert rule query hooks (CRUD, enable/disable, preview, test-evaluate) Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/src/api/queries/alertRules.test.tsx | 66 +++++++++ ui/src/api/queries/alertRules.ts | 187 +++++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 ui/src/api/queries/alertRules.test.tsx create mode 100644 ui/src/api/queries/alertRules.ts diff --git a/ui/src/api/queries/alertRules.test.tsx b/ui/src/api/queries/alertRules.test.tsx new file mode 100644 index 00000000..3ed12c77 --- /dev/null +++ b/ui/src/api/queries/alertRules.test.tsx @@ -0,0 +1,66 @@ +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', () => ({ + api: { GET: vi.fn(), POST: vi.fn(), PUT: vi.fn(), DELETE: vi.fn() }, +})); + +import { api as apiClient } from '../client'; +import { useAlertRules, useSetAlertRuleEnabled } from './alertRules'; + +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return {children}; +} + +describe('useAlertRules', () => { + beforeEach(() => { + vi.clearAllMocks(); + useEnvironmentStore.setState({ environment: 'prod' }); + }); + + it('fetches rules for selected env', async () => { + (apiClient.GET as any).mockResolvedValue({ data: [], error: null }); + const { result } = renderHook(() => useAlertRules(), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiClient.GET).toHaveBeenCalledWith( + '/environments/{envSlug}/alerts/rules', + { params: { path: { envSlug: 'prod' } } }, + ); + }); +}); + +describe('useSetAlertRuleEnabled', () => { + beforeEach(() => { + vi.clearAllMocks(); + useEnvironmentStore.setState({ environment: 'prod' }); + }); + + it('POSTs to /enable when enabling', async () => { + (apiClient.POST as any).mockResolvedValue({ error: null }); + const { result } = renderHook(() => useSetAlertRuleEnabled(), { wrapper }); + await result.current.mutateAsync({ id: 'r1', enabled: true }); + expect(apiClient.POST).toHaveBeenCalledWith( + '/environments/{envSlug}/alerts/rules/{id}/enable', + { params: { path: { envSlug: 'prod', id: 'r1' } } }, + ); + }); + + it('POSTs to /disable when disabling', async () => { + (apiClient.POST as any).mockResolvedValue({ error: null }); + const { result } = renderHook(() => useSetAlertRuleEnabled(), { wrapper }); + await result.current.mutateAsync({ id: 'r1', enabled: false }); + expect(apiClient.POST).toHaveBeenCalledWith( + '/environments/{envSlug}/alerts/rules/{id}/disable', + { params: { path: { envSlug: 'prod', id: 'r1' } } }, + ); + }); +}); diff --git a/ui/src/api/queries/alertRules.ts b/ui/src/api/queries/alertRules.ts new file mode 100644 index 00000000..2db466c8 --- /dev/null +++ b/ui/src/api/queries/alertRules.ts @@ -0,0 +1,187 @@ +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']; + +// NOTE ON TYPES: the generated OpenAPI schema for env-scoped alert-rule endpoints +// emits `path?: never` plus a `query.env: Environment` parameter because the +// server resolves the env via the `@EnvPath` argument resolver, which the +// OpenAPI scanner does not recognise as a path variable. At runtime the URL +// template `{envSlug}` is substituted from `params.path.envSlug` by +// openapi-fetch regardless of what the TS types say; we therefore cast the +// call options to `any` on each call to bypass the generated type oddity. + +/** List alert rules in the current env. */ +export function useAlertRules() { + const env = useSelectedEnv(); + return useQuery({ + queryKey: ['alertRules', env], + enabled: !!env, + queryFn: async () => { + if (!env) throw new Error('no env'); + const { data, error } = await apiClient.GET( + '/environments/{envSlug}/alerts/rules', + { + params: { path: { envSlug: env } }, + } as any, + ); + if (error) throw error; + return data as AlertRuleResponse[]; + }, + }); +} + +/** Fetch a single alert rule by id. */ +export function useAlertRule(id: string | undefined) { + const env = useSelectedEnv(); + return useQuery({ + queryKey: ['alertRules', env, id], + enabled: !!env && !!id, + queryFn: async () => { + if (!env || !id) throw new Error('no env/id'); + const { data, error } = await apiClient.GET( + '/environments/{envSlug}/alerts/rules/{id}', + { + params: { path: { envSlug: env, id } }, + } as any, + ); + if (error) throw error; + return data as AlertRuleResponse; + }, + }); +} + +/** Create a new alert rule in the current env. */ +export function useCreateAlertRule() { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async (req: AlertRuleRequest) => { + if (!env) throw new Error('no env'); + const { data, error } = await apiClient.POST( + '/environments/{envSlug}/alerts/rules', + { + params: { path: { envSlug: env } }, + body: req, + } as any, + ); + if (error) throw error; + return data as AlertRuleResponse; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alertRules', env] }); + }, + }); +} + +/** Update an existing alert rule. */ +export function useUpdateAlertRule(id: string) { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async (req: AlertRuleRequest) => { + if (!env) throw new Error('no env'); + const { data, error } = await apiClient.PUT( + '/environments/{envSlug}/alerts/rules/{id}', + { + params: { path: { envSlug: env, id } }, + body: req, + } as any, + ); + if (error) throw error; + return data as AlertRuleResponse; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alertRules', env] }); + qc.invalidateQueries({ queryKey: ['alertRules', env, id] }); + }, + }); +} + +/** Delete an alert rule. */ +export function useDeleteAlertRule() { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async (id: string) => { + if (!env) throw new Error('no env'); + const { error } = await apiClient.DELETE( + '/environments/{envSlug}/alerts/rules/{id}', + { + params: { path: { envSlug: env, id } }, + } as any, + ); + if (error) throw error; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alertRules', env] }); + }, + }); +} + +/** Enable or disable an alert rule. Routes to /enable or /disable based on the flag. */ +export function useSetAlertRuleEnabled() { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => { + if (!env) throw new Error('no env'); + 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 } }, + } as any); + if (error) throw error; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alertRules', env] }); + }, + }); +} + +/** Render a preview of the notification title + message for a rule using the provided context. */ +export function useRenderPreview() { + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async ({ id, req }: { id: string; req: RenderPreviewRequest }) => { + if (!env) throw new Error('no env'); + const { data, error } = await apiClient.POST( + '/environments/{envSlug}/alerts/rules/{id}/render-preview', + { + params: { path: { envSlug: env, id } }, + body: req, + } as any, + ); + if (error) throw error; + return data as RenderPreviewResponse; + }, + }); +} + +/** Test-evaluate a rule (dry-run) without persisting an alert instance. */ +export function useTestEvaluate() { + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async ({ id, req }: { id: string; req: TestEvaluateRequest }) => { + if (!env) throw new Error('no env'); + const { data, error } = await apiClient.POST( + '/environments/{envSlug}/alerts/rules/{id}/test-evaluate', + { + params: { path: { envSlug: env, id } }, + body: req, + } as any, + ); + if (error) throw error; + return data as TestEvaluateResponse; + }, + }); +} From 51bc796bec37b96e37843557aab9ee2b2c32e9b4 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:16:57 +0200 Subject: [PATCH 09/39] feat(ui/alerts): silence + notification query hooks --- ui/src/api/queries/alertNotifications.ts | 54 ++++++++++++ ui/src/api/queries/alertSilences.test.tsx | 39 +++++++++ ui/src/api/queries/alertSilences.ts | 101 ++++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 ui/src/api/queries/alertNotifications.ts create mode 100644 ui/src/api/queries/alertSilences.test.tsx create mode 100644 ui/src/api/queries/alertSilences.ts diff --git a/ui/src/api/queries/alertNotifications.ts b/ui/src/api/queries/alertNotifications.ts new file mode 100644 index 00000000..68f86ea8 --- /dev/null +++ b/ui/src/api/queries/alertNotifications.ts @@ -0,0 +1,54 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { components } from '../schema'; +import { apiClient, useSelectedEnv } from './alertMeta'; + +export type AlertNotificationDto = components['schemas']['AlertNotificationDto']; + +// NOTE ON TYPES: the generated OpenAPI schema for env-scoped alert-notification +// endpoints emits `path?: never` plus a `query.env: Environment` parameter +// because the server resolves the env via the `@EnvPath` argument resolver, +// which the OpenAPI scanner does not recognise as a path variable. At runtime +// the URL template `{envSlug}` is substituted from `params.path.envSlug` by +// openapi-fetch regardless of what the TS types say; we therefore cast the +// call options to `any` on each call to bypass the generated type oddity. + +/** List notifications for a given alert instance. */ +export function useAlertNotifications(alertId: string | undefined) { + const env = useSelectedEnv(); + return useQuery({ + queryKey: ['alertNotifications', env, alertId], + enabled: !!env && !!alertId, + queryFn: async () => { + if (!env || !alertId) throw new Error('no env/alertId'); + const { data, error } = await apiClient.GET( + '/environments/{envSlug}/alerts/{alertId}/notifications', + { + params: { path: { envSlug: env, alertId } }, + } as any, + ); + if (error) throw error; + return data as AlertNotificationDto[]; + }, + }); +} + +/** Retry a failed notification. Uses the flat path — notification IDs are + * globally unique across environments, so the endpoint is not env-scoped. + */ +export function useRetryNotification() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => { + const { error } = await apiClient.POST( + '/alerts/notifications/{id}/retry', + { + params: { path: { id } }, + } as any, + ); + if (error) throw error; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alertNotifications'] }); + }, + }); +} diff --git a/ui/src/api/queries/alertSilences.test.tsx b/ui/src/api/queries/alertSilences.test.tsx new file mode 100644 index 00000000..d9f5b207 --- /dev/null +++ b/ui/src/api/queries/alertSilences.test.tsx @@ -0,0 +1,39 @@ +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', () => ({ + api: { GET: vi.fn(), POST: vi.fn(), PUT: vi.fn(), DELETE: vi.fn() }, +})); + +import { api as apiClient } from '../client'; +import { useAlertSilences } from './alertSilences'; + +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return {children}; +} + +describe('useAlertSilences', () => { + beforeEach(() => { + vi.clearAllMocks(); + useEnvironmentStore.setState({ environment: 'dev' }); + }); + + it('fetches silences for selected env', async () => { + (apiClient.GET as any).mockResolvedValue({ data: [], error: null }); + const { result } = renderHook(() => useAlertSilences(), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiClient.GET).toHaveBeenCalledWith( + '/environments/{envSlug}/alerts/silences', + { params: { path: { envSlug: 'dev' } } }, + ); + }); +}); diff --git a/ui/src/api/queries/alertSilences.ts b/ui/src/api/queries/alertSilences.ts new file mode 100644 index 00000000..ce03dcab --- /dev/null +++ b/ui/src/api/queries/alertSilences.ts @@ -0,0 +1,101 @@ +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']; + +// NOTE ON TYPES: the generated OpenAPI schema for env-scoped alert-silence +// endpoints emits `path?: never` plus a `query.env: Environment` parameter +// because the server resolves the env via the `@EnvPath` argument resolver, +// which the OpenAPI scanner does not recognise as a path variable. At runtime +// the URL template `{envSlug}` is substituted from `params.path.envSlug` by +// openapi-fetch regardless of what the TS types say; we therefore cast the +// call options to `any` on each call to bypass the generated type oddity. + +/** List alert silences in the current env. */ +export function useAlertSilences() { + const env = useSelectedEnv(); + return useQuery({ + queryKey: ['alertSilences', env], + enabled: !!env, + queryFn: async () => { + if (!env) throw new Error('no env'); + const { data, error } = await apiClient.GET( + '/environments/{envSlug}/alerts/silences', + { + params: { path: { envSlug: env } }, + } as any, + ); + if (error) throw error; + return data as AlertSilenceResponse[]; + }, + }); +} + +/** Create a new alert silence in the current env. */ +export function useCreateSilence() { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async (req: AlertSilenceRequest) => { + if (!env) throw new Error('no env'); + const { data, error } = await apiClient.POST( + '/environments/{envSlug}/alerts/silences', + { + params: { path: { envSlug: env } }, + body: req, + } as any, + ); + if (error) throw error; + return data as AlertSilenceResponse; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alertSilences', env] }); + }, + }); +} + +/** Update an existing alert silence. */ +export function useUpdateSilence(id: string) { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async (req: AlertSilenceRequest) => { + if (!env) throw new Error('no env'); + const { data, error } = await apiClient.PUT( + '/environments/{envSlug}/alerts/silences/{id}', + { + params: { path: { envSlug: env, id } }, + body: req, + } as any, + ); + if (error) throw error; + return data as AlertSilenceResponse; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alertSilences', env] }); + }, + }); +} + +/** Delete an alert silence. */ +export function useDeleteSilence() { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async (id: string) => { + if (!env) throw new Error('no env'); + const { error } = await apiClient.DELETE( + '/environments/{envSlug}/alerts/silences/{id}', + { + params: { path: { envSlug: env, id } }, + } as any, + ); + if (error) throw error; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alertSilences', env] }); + }, + }); +} From 31ee9748305e9e6719715f91ac364ea7a55992e0 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:21:37 +0200 Subject: [PATCH 10/39] 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 section 8 audit-trail surface. --- ui/src/components/AlertStateChip.test.tsx | 25 +++++++++++++++++++++ ui/src/components/AlertStateChip.tsx | 27 +++++++++++++++++++++++ ui/src/components/SeverityBadge.test.tsx | 19 ++++++++++++++++ ui/src/components/SeverityBadge.tsx | 20 +++++++++++++++++ 4 files changed, 91 insertions(+) create mode 100644 ui/src/components/AlertStateChip.test.tsx create mode 100644 ui/src/components/AlertStateChip.tsx create mode 100644 ui/src/components/SeverityBadge.test.tsx create mode 100644 ui/src/components/SeverityBadge.tsx diff --git a/ui/src/components/AlertStateChip.test.tsx b/ui/src/components/AlertStateChip.test.tsx new file mode 100644 index 00000000..dbdc0785 --- /dev/null +++ b/ui/src/components/AlertStateChip.test.tsx @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '@cameleer/design-system'; +import { AlertStateChip } from './AlertStateChip'; + +function renderWithTheme(ui: React.ReactElement) { + return render({ui}); +} + +describe('AlertStateChip', () => { + it.each([ + ['PENDING', /pending/i], + ['FIRING', /firing/i], + ['ACKNOWLEDGED', /acknowledged/i], + ['RESOLVED', /resolved/i], + ] as const)('renders %s label', (state, pattern) => { + renderWithTheme(); + expect(screen.getByText(pattern)).toBeInTheDocument(); + }); + + it('shows silenced suffix when silenced=true', () => { + renderWithTheme(); + expect(screen.getByText(/silenced/i)).toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/AlertStateChip.tsx b/ui/src/components/AlertStateChip.tsx new file mode 100644 index 00000000..a81548f0 --- /dev/null +++ b/ui/src/components/AlertStateChip.tsx @@ -0,0 +1,27 @@ +import { Badge } from '@cameleer/design-system'; +import type { AlertDto } from '../api/queries/alerts'; + +type State = NonNullable; + +const LABELS: Record = { + PENDING: 'Pending', + FIRING: 'Firing', + ACKNOWLEDGED: 'Acknowledged', + RESOLVED: 'Resolved', +}; + +const COLORS: Record = { + PENDING: 'warning', + FIRING: 'error', + ACKNOWLEDGED: 'warning', + RESOLVED: 'success', +}; + +export function AlertStateChip({ state, silenced }: { state: State; silenced?: boolean }) { + return ( + + + {silenced && } + + ); +} diff --git a/ui/src/components/SeverityBadge.test.tsx b/ui/src/components/SeverityBadge.test.tsx new file mode 100644 index 00000000..a685fe12 --- /dev/null +++ b/ui/src/components/SeverityBadge.test.tsx @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '@cameleer/design-system'; +import { SeverityBadge } from './SeverityBadge'; + +function renderWithTheme(ui: React.ReactElement) { + return render({ui}); +} + +describe('SeverityBadge', () => { + it.each([ + ['CRITICAL', /critical/i], + ['WARNING', /warning/i], + ['INFO', /info/i], + ] as const)('renders %s', (severity, pattern) => { + renderWithTheme(); + expect(screen.getByText(pattern)).toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/SeverityBadge.tsx b/ui/src/components/SeverityBadge.tsx new file mode 100644 index 00000000..b4d712ac --- /dev/null +++ b/ui/src/components/SeverityBadge.tsx @@ -0,0 +1,20 @@ +import { Badge } from '@cameleer/design-system'; +import type { AlertDto } from '../api/queries/alerts'; + +type Severity = NonNullable; + +const LABELS: Record = { + CRITICAL: 'Critical', + WARNING: 'Warning', + INFO: 'Info', +}; + +const COLORS: Record = { + CRITICAL: 'error', + WARNING: 'warning', + INFO: 'auto', +}; + +export function SeverityBadge({ severity }: { severity: Severity }) { + return ; +} From 197c60126c9bcb27b2851445acfcf983c36d72a4 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:26:58 +0200 Subject: [PATCH 11/39] feat(ui/alerts): NotificationBell with Page Visibility poll pause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a header bell component linking to /alerts/inbox with an unread-count badge for the selected environment. Polling pauses when the tab is hidden via TanStack Query's refetchIntervalInBackground:false (already set on useUnreadCount); the new usePageVisible hook gives components a re-renders-on-visibility-change signal for future defense-in-depth. Plan-prose deviation: the plan assumed UnreadCountResponse carries a bySeverity map for per-severity badge coloring, but the backend DTO only exposes a scalar `count`. The bell reads `data?.count` and renders a single var(--error) tint; a TODO references spec §13 for future per-severity work that would require expanding the DTO. Tests: usePageVisible toggles on visibilitychange events; NotificationBell renders the bell with no badge at count=0 and shows "3" at count=3. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/src/components/NotificationBell.module.css | 27 ++++++++++ ui/src/components/NotificationBell.test.tsx | 47 +++++++++++++++++ ui/src/components/NotificationBell.tsx | 51 +++++++++++++++++++ ui/src/hooks/usePageVisible.test.ts | 30 +++++++++++ ui/src/hooks/usePageVisible.ts | 22 ++++++++ 5 files changed, 177 insertions(+) create mode 100644 ui/src/components/NotificationBell.module.css create mode 100644 ui/src/components/NotificationBell.test.tsx create mode 100644 ui/src/components/NotificationBell.tsx create mode 100644 ui/src/hooks/usePageVisible.test.ts create mode 100644 ui/src/hooks/usePageVisible.ts diff --git a/ui/src/components/NotificationBell.module.css b/ui/src/components/NotificationBell.module.css new file mode 100644 index 00000000..cf6662a9 --- /dev/null +++ b/ui/src/components/NotificationBell.module.css @@ -0,0 +1,27 @@ +.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; +} diff --git a/ui/src/components/NotificationBell.test.tsx b/ui/src/components/NotificationBell.test.tsx new file mode 100644 index 00000000..0cd05ae3 --- /dev/null +++ b/ui/src/components/NotificationBell.test.tsx @@ -0,0 +1,47 @@ +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', () => ({ api: { GET: vi.fn() } })); + +import { api as apiClient } from '../api/client'; +import { NotificationBell } from './NotificationBell'; + +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ( + + {children} + + ); +} + +describe('NotificationBell', () => { + beforeEach(() => { + vi.clearAllMocks(); + useEnvironmentStore.setState({ environment: 'dev' }); + }); + + it('renders bell with no badge when zero unread', async () => { + (apiClient.GET as any).mockResolvedValue({ + data: { count: 0 }, + error: null, + }); + render(, { wrapper }); + expect(await screen.findByRole('button', { name: /notifications/i })).toBeInTheDocument(); + // Badge is only rendered when count > 0; no numeric text should appear. + expect(screen.queryByText(/^\d+$/)).toBeNull(); + }); + + it('shows unread count badge when unread alerts exist', async () => { + (apiClient.GET as any).mockResolvedValue({ + data: { count: 3 }, + error: null, + }); + render(, { wrapper }); + expect(await screen.findByText('3')).toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/NotificationBell.tsx b/ui/src/components/NotificationBell.tsx new file mode 100644 index 00000000..d2b7738d --- /dev/null +++ b/ui/src/components/NotificationBell.tsx @@ -0,0 +1,51 @@ +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'; + +/** + * Global notification bell shown in the layout header. Links to the alerts + * inbox and renders a badge with the unread-alert count for the currently + * selected environment. + * + * Polling is driven by `useUnreadCount` (30s interval, paused in background + * via TanStack Query's `refetchIntervalInBackground: false`). The + * `usePageVisible` hook is retained as a defense-in-depth signal so future + * UI behavior (e.g. animations, live-region updates) can key off visibility + * without re-wiring the polling logic. + * + * TODO (spec §13): per-severity badge coloring — the backend + * `UnreadCountResponse` currently exposes only a scalar `count` field. To + * colour the badge by max unread severity (CRITICAL → error, WARNING → + * amber, INFO → muted) the DTO must grow a `bySeverity` map; deferred to a + * future task. Until then the badge uses a single `var(--error)` tint. + */ +export function NotificationBell() { + const env = useSelectedEnv(); + // Subscribe to visibility so the component re-renders on tab focus, even + // though the polling pause itself is handled inside useUnreadCount. + usePageVisible(); + const { data } = useUnreadCount(); + + const count = data?.count ?? 0; + + if (!env) return null; + + return ( + + + {count > 0 && ( + + {count > 99 ? '99+' : count} + + )} + + ); +} diff --git a/ui/src/hooks/usePageVisible.test.ts b/ui/src/hooks/usePageVisible.test.ts new file mode 100644 index 00000000..488fcc28 --- /dev/null +++ b/ui/src/hooks/usePageVisible.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, beforeEach } 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); + }); +}); diff --git a/ui/src/hooks/usePageVisible.ts b/ui/src/hooks/usePageVisible.ts new file mode 100644 index 00000000..fbfc4654 --- /dev/null +++ b/ui/src/hooks/usePageVisible.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react'; + +/** + * Tracks Page Visibility API state for the current document. + * + * Returns `true` when the tab is visible, `false` when hidden. Useful for + * pausing work (polling, animations, expensive DOM effects) while the tab + * is backgrounded. SSR-safe: defaults to `true` when `document` is undefined. + */ +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; +} From 38083d7c3f8f4f1f33b6249bc4ad973a6df6c896 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:30:07 +0200 Subject: [PATCH 12/39] fix(ui/alerts): remove dead usePageVisible subscription; align alerts test mock with DTO NotificationBell used a usePageVisible() subscription that re-rendered on every visibilitychange without consuming the value. TanStack Query's refetchIntervalInBackground:false already pauses polling; the extra subscription was speculative generality. Dropped the import + call + JSDoc reference; usePageVisible hook + test retained as a reusable primitive. Also: alerts.test.tsx 'returns the server payload unmodified' asserted a pre-plan {total, bySeverity} shape, but UnreadCountResponse is actually {count}. Fixed mock + assertion to {count: 3}. --- ui/src/api/queries/alerts.test.tsx | 7 ++----- ui/src/components/NotificationBell.tsx | 19 ++++--------------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/ui/src/api/queries/alerts.test.tsx b/ui/src/api/queries/alerts.test.tsx index 7ae91516..b8eb3e66 100644 --- a/ui/src/api/queries/alerts.test.tsx +++ b/ui/src/api/queries/alerts.test.tsx @@ -60,14 +60,11 @@ describe('useUnreadCount', () => { it('returns the server payload unmodified', async () => { (apiClient.GET as any).mockResolvedValue({ - data: { total: 3, bySeverity: { CRITICAL: 1, WARNING: 2, INFO: 0 } }, + data: { count: 3 }, 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 }, - }); + expect(result.current.data).toEqual({ count: 3 }); }); }); diff --git a/ui/src/components/NotificationBell.tsx b/ui/src/components/NotificationBell.tsx index d2b7738d..8c129e4a 100644 --- a/ui/src/components/NotificationBell.tsx +++ b/ui/src/components/NotificationBell.tsx @@ -2,7 +2,6 @@ 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'; /** @@ -10,23 +9,13 @@ import css from './NotificationBell.module.css'; * inbox and renders a badge with the unread-alert count for the currently * selected environment. * - * Polling is driven by `useUnreadCount` (30s interval, paused in background - * via TanStack Query's `refetchIntervalInBackground: false`). The - * `usePageVisible` hook is retained as a defense-in-depth signal so future - * UI behavior (e.g. animations, live-region updates) can key off visibility - * without re-wiring the polling logic. - * - * TODO (spec §13): per-severity badge coloring — the backend - * `UnreadCountResponse` currently exposes only a scalar `count` field. To - * colour the badge by max unread severity (CRITICAL → error, WARNING → - * amber, INFO → muted) the DTO must grow a `bySeverity` map; deferred to a - * future task. Until then the badge uses a single `var(--error)` tint. + * Polling pause when the tab is hidden is handled by `useUnreadCount`'s + * `refetchIntervalInBackground: false`; no separate visibility subscription + * is needed. If per-severity coloring (spec §13) is re-introduced, the + * backend `UnreadCountResponse` must grow a `bySeverity` map. */ export function NotificationBell() { const env = useSelectedEnv(); - // Subscribe to visibility so the component re-renders on tab focus, even - // though the polling pause itself is handled inside useUnreadCount. - usePageVisible(); const { data } = useUnreadCount(); const count = data?.count ?? 0; From 5ddd89f883ff449c0b7557e9e1e32fdcc2c3b5ba Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:31:55 +0200 Subject: [PATCH 13/39] feat(ui/alerts): Mustache variable metadata registry for autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../MustacheEditor/alert-variables.test.ts | 55 +++++++++ .../MustacheEditor/alert-variables.ts | 109 ++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 ui/src/components/MustacheEditor/alert-variables.test.ts create mode 100644 ui/src/components/MustacheEditor/alert-variables.ts diff --git a/ui/src/components/MustacheEditor/alert-variables.test.ts b/ui/src/components/MustacheEditor/alert-variables.test.ts new file mode 100644 index 00000000..fb931125 --- /dev/null +++ b/ui/src/components/MustacheEditor/alert-variables.test.ts @@ -0,0 +1,55 @@ +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']); + }); +}); diff --git a/ui/src/components/MustacheEditor/alert-variables.ts b/ui/src/components/MustacheEditor/alert-variables.ts new file mode 100644 index 00000000..65a9dc76 --- /dev/null +++ b/ui/src/components/MustacheEditor/alert-variables.ts @@ -0,0 +1,109 @@ +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)); +} From 18e6dde67a1fdcabfbc0de365a7e00399b4651de Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:35:42 +0200 Subject: [PATCH 14/39] fix(ui/alerts): align ALERT_VARIABLES registry with NotificationContextBuilder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan prose had spec §8 idealized leaves, but the backend NotificationContext only emits a subset: ROUTE_METRIC / EXCHANGE_MATCH → route.id + route.uri (uri added) LOG_PATTERN → log.pattern + log.matchCount (renamed from log.logger/level/message) app.slug / app.id → scoped to non-env kinds (removed from 'always') exchange.link / alert.comparator / alert.window / app.displayName → removed (backend doesn't emit) Without this alignment the Task 11 linter would (1) flag valid route.uri as unknown, (2) suggest log.{logger,level,message} as valid paths that render empty, and (3) flag app.slug on env-wide rules. --- .../MustacheEditor/alert-variables.test.ts | 16 ++++-- .../MustacheEditor/alert-variables.ts | 57 +++++++++++-------- 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/ui/src/components/MustacheEditor/alert-variables.test.ts b/ui/src/components/MustacheEditor/alert-variables.test.ts index fb931125..9abd1569 100644 --- a/ui/src/components/MustacheEditor/alert-variables.test.ts +++ b/ui/src/components/MustacheEditor/alert-variables.test.ts @@ -10,18 +10,24 @@ describe('availableVariables', () => { 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(); + expect(vars.find((v) => v.path === 'log.pattern')).toBeUndefined(); + expect(vars.find((v) => v.path === 'app.slug')).toBeUndefined(); }); - it('adds exchange.* for EXCHANGE_MATCH kind', () => { + it('adds exchange.* + route.* + app.* 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(); + expect(vars.find((v) => v.path === 'route.uri')).toBeTruthy(); + expect(vars.find((v) => v.path === 'app.slug')).toBeTruthy(); + expect(vars.find((v) => v.path === 'log.pattern')).toBeUndefined(); }); - it('adds log.* for LOG_PATTERN kind', () => { + it('adds log.* + app.* for LOG_PATTERN kind', () => { const vars = availableVariables('LOG_PATTERN'); - expect(vars.find((v) => v.path === 'log.message')).toBeTruthy(); + expect(vars.find((v) => v.path === 'log.pattern')).toBeTruthy(); + expect(vars.find((v) => v.path === 'log.matchCount')).toBeTruthy(); + expect(vars.find((v) => v.path === 'app.slug')).toBeTruthy(); + expect(vars.find((v) => v.path === 'exchange.id')).toBeUndefined(); }); it('reduces to env-only when reducedContext=true (connection URL editor)', () => { diff --git a/ui/src/components/MustacheEditor/alert-variables.ts b/ui/src/components/MustacheEditor/alert-variables.ts index 65a9dc76..c1f1833f 100644 --- a/ui/src/components/MustacheEditor/alert-variables.ts +++ b/ui/src/components/MustacheEditor/alert-variables.ts @@ -35,39 +35,50 @@ export const ALERT_VARIABLES: AlertVariable[] = [ { 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 }, + // App subtree — populated on every kind except env-wide rules + { path: 'app.slug', type: 'string', description: 'App slug', sampleValue: 'orders', + availableForKinds: ['ROUTE_METRIC', 'EXCHANGE_MATCH', 'AGENT_STATE', 'DEPLOYMENT_STATE', 'LOG_PATTERN', 'JVM_METRIC'], mayBeNull: true }, + { path: 'app.id', type: 'uuid', description: 'App UUID', sampleValue: '33333333-...', + availableForKinds: ['ROUTE_METRIC', 'EXCHANGE_MATCH', 'AGENT_STATE', 'DEPLOYMENT_STATE', 'LOG_PATTERN', 'JVM_METRIC'], mayBeNull: true }, - // ROUTE_METRIC - { path: 'route.id', type: 'string', description: 'Route ID', sampleValue: 'route-1', availableForKinds: ['ROUTE_METRIC', 'EXCHANGE_MATCH'] }, + // ROUTE_METRIC + EXCHANGE_MATCH share route.* + { path: 'route.id', type: 'string', description: 'Route ID', sampleValue: 'route-1', + availableForKinds: ['ROUTE_METRIC', 'EXCHANGE_MATCH'] }, + { path: 'route.uri', type: 'string', description: 'Route URI', sampleValue: 'direct:orders', + 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'] }, + { 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'] }, - // 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'] }, + // AGENT_STATE + JVM_METRIC share agent.id/name; AGENT_STATE adds 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'] }, + { 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'] }, + // LOG_PATTERN — leaf names match NotificationContextBuilder (log.pattern + log.matchCount) + { path: 'log.pattern', type: 'string', description: 'Matched log pattern', sampleValue: 'TimeoutException', + availableForKinds: ['LOG_PATTERN'] }, + { path: 'log.matchCount', type: 'number', description: 'Matches in window', sampleValue: '7', + 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'] }, + { 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. From ac2a943feb58805a3afe299440874f07c9de222c Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:39:10 +0200 Subject: [PATCH 15/39] 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(). --- .../mustache-completion.test.ts | 44 +++++++++++++ .../MustacheEditor/mustache-completion.ts | 43 +++++++++++++ .../MustacheEditor/mustache-linter.test.ts | 47 ++++++++++++++ .../MustacheEditor/mustache-linter.ts | 62 +++++++++++++++++++ 4 files changed, 196 insertions(+) create mode 100644 ui/src/components/MustacheEditor/mustache-completion.test.ts create mode 100644 ui/src/components/MustacheEditor/mustache-completion.ts create mode 100644 ui/src/components/MustacheEditor/mustache-linter.test.ts create mode 100644 ui/src/components/MustacheEditor/mustache-linter.ts diff --git a/ui/src/components/MustacheEditor/mustache-completion.test.ts b/ui/src/components/MustacheEditor/mustache-completion.test.ts new file mode 100644 index 00000000..13ae845c --- /dev/null +++ b/ui/src/components/MustacheEditor/mustache-completion.test.ts @@ -0,0 +1,44 @@ +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); + }); +}); diff --git a/ui/src/components/MustacheEditor/mustache-completion.ts b/ui/src/components/MustacheEditor/mustache-completion.ts new file mode 100644 index 00000000..9ad74d83 --- /dev/null +++ b/ui/src/components/MustacheEditor/mustache-completion.ts @@ -0,0 +1,43 @@ +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_.]*$/, + }; + }; +} diff --git a/ui/src/components/MustacheEditor/mustache-linter.test.ts b/ui/src/components/MustacheEditor/mustache-linter.test.ts new file mode 100644 index 00000000..38dc74b7 --- /dev/null +++ b/ui/src/components/MustacheEditor/mustache-linter.test.ts @@ -0,0 +1,47 @@ +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 debounces with a default 750ms delay — wait past that for the source to run. + await new Promise((r) => setTimeout(r, 900)); + 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/); + }); +}); diff --git a/ui/src/components/MustacheEditor/mustache-linter.ts b/ui/src/components/MustacheEditor/mustache-linter.ts new file mode 100644 index 00000000..d88e1dc5 --- /dev/null +++ b/ui/src/components/MustacheEditor/mustache-linter.ts @@ -0,0 +1,62 @@ +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; + }); +} From 019e79a362f66b368be74881e1efd59ccf0db412 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:41:46 +0200 Subject: [PATCH 16/39] 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). --- .../MustacheEditor/MustacheEditor.module.css | 15 +++ .../MustacheEditor/MustacheEditor.test.tsx | 34 ++++++ .../MustacheEditor/MustacheEditor.tsx | 108 ++++++++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 ui/src/components/MustacheEditor/MustacheEditor.module.css create mode 100644 ui/src/components/MustacheEditor/MustacheEditor.test.tsx create mode 100644 ui/src/components/MustacheEditor/MustacheEditor.tsx diff --git a/ui/src/components/MustacheEditor/MustacheEditor.module.css b/ui/src/components/MustacheEditor/MustacheEditor.module.css new file mode 100644 index 00000000..3d51d209 --- /dev/null +++ b/ui/src/components/MustacheEditor/MustacheEditor.module.css @@ -0,0 +1,15 @@ +.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); +} diff --git a/ui/src/components/MustacheEditor/MustacheEditor.test.tsx b/ui/src/components/MustacheEditor/MustacheEditor.test.tsx new file mode 100644 index 00000000..c8641181 --- /dev/null +++ b/ui/src/components/MustacheEditor/MustacheEditor.test.tsx @@ -0,0 +1,34 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MustacheEditor } from './MustacheEditor'; + +describe('MustacheEditor', () => { + it('renders the initial value', () => { + render( + {}} + kind="ROUTE_METRIC" + label="Title template" + />, + ); + expect(screen.getByText(/Hello/)).toBeInTheDocument(); + }); + + it('renders a textbox and does not call onChange before user interaction', () => { + const onChange = vi.fn(); + render( + , + ); + const editor = screen.getByRole('textbox', { name: 'Title template' }); + expect(editor).toBeInTheDocument(); + // CM6 fires onChange via transactions, not DOM input events; without a real + // user interaction the callback must remain untouched. + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/components/MustacheEditor/MustacheEditor.tsx b/ui/src/components/MustacheEditor/MustacheEditor.tsx new file mode 100644 index 00000000..bf66c013 --- /dev/null +++ b/ui/src/components/MustacheEditor/MustacheEditor.tsx @@ -0,0 +1,108 @@ +import { useEffect, useRef } from 'react'; +import { EditorState, type Extension } from '@codemirror/state'; +import { EditorView, keymap, highlightSpecialChars, drawSelection, highlightActiveLine, lineNumbers } from '@codemirror/view'; +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; +import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'; +import { lintKeymap, lintGutter } from '@codemirror/lint'; +import { mustacheCompletionSource } from './mustache-completion'; +import { mustacheLinter } from './mustache-linter'; +import { availableVariables } from './alert-variables'; +import type { ConditionKind } from '../../api/queries/alertRules'; +import css from './MustacheEditor.module.css'; + +export interface MustacheEditorProps { + value: string; + onChange: (value: string) => void; + kind?: ConditionKind; + reducedContext?: boolean; // connection URL editor uses env-only context + label: string; + placeholder?: string; + minHeight?: number; // default 80 + singleLine?: boolean; // used for header values / URL fields +} + +export function MustacheEditor(props: MustacheEditorProps) { + const hostRef = useRef(null); + const viewRef = useRef(null); + + // Keep a ref to the latest onChange so the EditorView effect doesn't re-create on every render. + const onChangeRef = useRef(props.onChange); + onChangeRef.current = props.onChange; + + useEffect(() => { + if (!hostRef.current) return; + const allowed = availableVariables(props.kind, { reducedContext: props.reducedContext }); + + const extensions: Extension[] = [ + history(), + drawSelection(), + highlightSpecialChars(), + highlightActiveLine(), + closeBrackets(), + autocompletion({ override: [mustacheCompletionSource(allowed)] }), + mustacheLinter(allowed), + lintGutter(), + EditorView.updateListener.of((u) => { + if (u.docChanged) onChangeRef.current(u.state.doc.toString()); + }), + keymap.of([ + ...closeBracketsKeymap, + ...defaultKeymap, + ...historyKeymap, + ...completionKeymap, + ...lintKeymap, + ]), + ]; + if (!props.singleLine) extensions.push(lineNumbers()); + if (props.singleLine) { + // Prevent Enter from inserting a newline on single-line fields. + extensions.push( + EditorState.transactionFilter.of((tr) => { + if (tr.newDoc.lines > 1) return []; + return tr; + }), + ); + } + + const view = new EditorView({ + parent: hostRef.current, + state: EditorState.create({ + doc: props.value, + extensions, + }), + }); + viewRef.current = view; + return () => { + view.destroy(); + viewRef.current = null; + }; + // Extensions built once per mount; prop-driven extensions rebuilt in the effect below. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.kind, props.reducedContext, props.singleLine]); + + // If the parent replaces `value` externally (e.g. promotion prefill), sync the doc. + useEffect(() => { + const view = viewRef.current; + if (!view) return; + const current = view.state.doc.toString(); + if (current !== props.value) { + view.dispatch({ changes: { from: 0, to: current.length, insert: props.value } }); + } + }, [props.value]); + + const minH = props.minHeight ?? (props.singleLine ? 32 : 80); + + return ( +
+ +
+
+ ); +} From 167d0ebd42f6751edbed8b5985aa6d89b90fb137 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:44:44 +0200 Subject: [PATCH 17/39] feat(ui/alerts): register /alerts/* routes with placeholder pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 6 lazy-loaded route entries for the alerting UI (Inbox, All, History, Rules list, Rule editor wizard, Silences) plus an `/alerts` → `/alerts/inbox` redirect. Page components are placeholder stubs to be replaced in Phase 5/6/7. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/src/pages/Alerts/AllAlertsPage.tsx | 3 +++ ui/src/pages/Alerts/HistoryPage.tsx | 3 +++ ui/src/pages/Alerts/InboxPage.tsx | 3 +++ .../pages/Alerts/RuleEditor/RuleEditorWizard.tsx | 3 +++ ui/src/pages/Alerts/RulesListPage.tsx | 3 +++ ui/src/pages/Alerts/SilencesPage.tsx | 3 +++ ui/src/router.tsx | 16 ++++++++++++++++ 7 files changed, 34 insertions(+) create mode 100644 ui/src/pages/Alerts/AllAlertsPage.tsx create mode 100644 ui/src/pages/Alerts/HistoryPage.tsx create mode 100644 ui/src/pages/Alerts/InboxPage.tsx create mode 100644 ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx create mode 100644 ui/src/pages/Alerts/RulesListPage.tsx create mode 100644 ui/src/pages/Alerts/SilencesPage.tsx diff --git a/ui/src/pages/Alerts/AllAlertsPage.tsx b/ui/src/pages/Alerts/AllAlertsPage.tsx new file mode 100644 index 00000000..22aec1f6 --- /dev/null +++ b/ui/src/pages/Alerts/AllAlertsPage.tsx @@ -0,0 +1,3 @@ +export default function AllAlertsPage() { + return
AllAlertsPage — coming soon
; +} diff --git a/ui/src/pages/Alerts/HistoryPage.tsx b/ui/src/pages/Alerts/HistoryPage.tsx new file mode 100644 index 00000000..6807b6d4 --- /dev/null +++ b/ui/src/pages/Alerts/HistoryPage.tsx @@ -0,0 +1,3 @@ +export default function HistoryPage() { + return
HistoryPage — coming soon
; +} diff --git a/ui/src/pages/Alerts/InboxPage.tsx b/ui/src/pages/Alerts/InboxPage.tsx new file mode 100644 index 00000000..10f561b6 --- /dev/null +++ b/ui/src/pages/Alerts/InboxPage.tsx @@ -0,0 +1,3 @@ +export default function InboxPage() { + return
InboxPage — coming soon
; +} diff --git a/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx b/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx new file mode 100644 index 00000000..26c55fc7 --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx @@ -0,0 +1,3 @@ +export default function RuleEditorWizard() { + return
RuleEditorWizard — coming soon
; +} diff --git a/ui/src/pages/Alerts/RulesListPage.tsx b/ui/src/pages/Alerts/RulesListPage.tsx new file mode 100644 index 00000000..ee769393 --- /dev/null +++ b/ui/src/pages/Alerts/RulesListPage.tsx @@ -0,0 +1,3 @@ +export default function RulesListPage() { + return
RulesListPage — coming soon
; +} diff --git a/ui/src/pages/Alerts/SilencesPage.tsx b/ui/src/pages/Alerts/SilencesPage.tsx new file mode 100644 index 00000000..156c8960 --- /dev/null +++ b/ui/src/pages/Alerts/SilencesPage.tsx @@ -0,0 +1,3 @@ +export default function SilencesPage() { + return
SilencesPage — coming soon
; +} diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 3144e02f..7bee0798 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -23,6 +23,12 @@ const OutboundConnectionEditor = lazy(() => import('./pages/Admin/OutboundConnec const SensitiveKeysPage = lazy(() => import('./pages/Admin/SensitiveKeysPage')); const AppsTab = lazy(() => import('./pages/AppsTab/AppsTab')); const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage')); +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')); function SuspenseWrapper({ children }: { children: React.ReactNode }) { return ( @@ -75,6 +81,16 @@ export const router = createBrowserRouter([ { path: 'apps/new', element: }, { path: 'apps/:appId', element: }, + // Alerts + { path: 'alerts', element: }, + { path: 'alerts/inbox', element: }, + { path: 'alerts/all', element: }, + { path: 'alerts/history', element: }, + { path: 'alerts/rules', element: }, + { path: 'alerts/rules/new', element: }, + { path: 'alerts/rules/:id', element: }, + { path: 'alerts/silences', element: }, + // Admin (ADMIN role required) { element: , From 54e4217e21bd39de0eadae21cd337a1e3c4a3449 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:46:49 +0200 Subject: [PATCH 18/39] feat(ui/alerts): Alerts sidebar section with Inbox/All/Rules/Silences/History MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `buildAlertsTreeNodes` to sidebar-utils and renders an Alerts section between Applications and Starred in LayoutShell. The section uses an accordion pattern — entering `/alerts/*` collapses apps/admin/starred and restores their state on leave. gitnexus_impact(LayoutContent, upstream) = LOW (0 direct callers; rendered only by LayoutShell's provider wrapper). Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/src/components/LayoutShell.tsx | 64 ++++++++++++++++++++++++- ui/src/components/sidebar-utils.test.ts | 17 +++++++ ui/src/components/sidebar-utils.ts | 16 +++++++ 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 ui/src/components/sidebar-utils.test.ts diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index c9bf83af..4f54c878 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -21,7 +21,7 @@ import { } from '@cameleer/design-system'; import type { SearchResult, SidebarTreeNode, DropdownItem, ButtonGroupItem, ExchangeStatus } from '@cameleer/design-system'; import sidebarLogo from '@cameleer/design-system/assets/cameleer-logo.svg'; -import { Box, Settings, FileText, ChevronRight, Square, Pause, Star, X, User, Plus, EyeOff } from 'lucide-react'; +import { Box, Settings, FileText, ChevronRight, Square, Pause, Star, X, User, Plus, EyeOff, Bell } from 'lucide-react'; import { AboutMeDialog } from './AboutMeDialog'; import css from './LayoutShell.module.css'; import { useQueryClient } from '@tanstack/react-query'; @@ -42,6 +42,7 @@ import { formatDuration } from '../utils/format-utils'; import { buildAppTreeNodes, buildAdminTreeNodes, + buildAlertsTreeNodes, formatCount, readCollapsed, writeCollapsed, @@ -278,6 +279,7 @@ const STATUS_ITEMS: ButtonGroupItem[] = [ const SK_APPS = 'sidebar:section:apps'; const SK_ADMIN = 'sidebar:section:admin'; +const SK_ALERTS = 'sidebar:section:alerts'; const SK_COLLAPSED = 'sidebar:collapsed'; /* ------------------------------------------------------------------ */ @@ -325,6 +327,7 @@ function LayoutContent() { // --- Admin search data (only fetched on admin pages) ---------------- const isAdminPage = location.pathname.startsWith('/admin'); + const isAlertsPage = location.pathname.startsWith('/alerts'); const { data: adminUsers } = useUsers(isAdminPage); const { data: adminGroups } = useGroups(isAdminPage); const { data: adminRoles } = useRoles(isAdminPage); @@ -367,8 +370,9 @@ function LayoutContent() { }, [setSelectedEnvRaw, navigate, location.pathname, location.search, queryClient]); // --- Section open states ------------------------------------------ - const [appsOpen, setAppsOpen] = useState(() => isAdminPage ? false : readCollapsed(SK_APPS, true)); + const [appsOpen, setAppsOpen] = useState(() => (isAdminPage || isAlertsPage) ? false : readCollapsed(SK_APPS, true)); const [adminOpen, setAdminOpen] = useState(() => isAdminPage ? true : readCollapsed(SK_ADMIN, false)); + const [alertsOpen, setAlertsOpen] = useState(() => isAlertsPage ? true : readCollapsed(SK_ALERTS, false)); const [starredOpen, setStarredOpen] = useState(true); // Accordion: entering admin collapses apps + starred; leaving restores @@ -388,6 +392,36 @@ function LayoutContent() { prevAdminRef.current = isAdminPage; }, [isAdminPage]); // eslint-disable-line react-hooks/exhaustive-deps + // Accordion: entering alerts collapses apps + admin + starred; leaving restores + const opsAlertsStateRef = useRef({ apps: appsOpen, admin: adminOpen, starred: starredOpen }); + const prevAlertsRef = useRef(isAlertsPage); + useEffect(() => { + if (isAlertsPage && !prevAlertsRef.current) { + opsAlertsStateRef.current = { apps: appsOpen, admin: adminOpen, starred: starredOpen }; + setAppsOpen(false); + setAdminOpen(false); + setStarredOpen(false); + setAlertsOpen(true); + } else if (!isAlertsPage && prevAlertsRef.current) { + setAppsOpen(opsAlertsStateRef.current.apps); + setAdminOpen(opsAlertsStateRef.current.admin); + setStarredOpen(opsAlertsStateRef.current.starred); + setAlertsOpen(false); + } + prevAlertsRef.current = isAlertsPage; + }, [isAlertsPage]); // eslint-disable-line react-hooks/exhaustive-deps + + const toggleAlerts = useCallback(() => { + if (!isAlertsPage) { + navigate('/alerts/inbox'); + return; + } + setAlertsOpen((prev) => { + writeCollapsed(SK_ALERTS, !prev); + return !prev; + }); + }, [isAlertsPage, navigate]); + const toggleApps = useCallback(() => { if (isAdminPage) { navigate('/exchanges'); @@ -469,6 +503,11 @@ function LayoutContent() { [capabilities?.infrastructureEndpoints], ); + const alertsTreeNodes: SidebarTreeNode[] = useMemo( + () => buildAlertsTreeNodes(), + [], + ); + // --- Starred items ------------------------------------------------ const starredItems = useMemo( () => collectStarredItems(sidebarApps, starredIds), @@ -482,6 +521,7 @@ function LayoutContent() { if (!sidebarRevealPath) return; if (sidebarRevealPath.startsWith('/apps') && !appsOpen) setAppsOpen(true); if (sidebarRevealPath.startsWith('/admin') && !adminOpen) setAdminOpen(true); + if (sidebarRevealPath.startsWith('/alerts') && !alertsOpen) setAlertsOpen(true); }, [sidebarRevealPath]); // eslint-disable-line react-hooks/exhaustive-deps // Normalize path so sidebar highlights the app regardless of which tab is active. @@ -771,6 +811,26 @@ function LayoutContent() {
+ {/* Alerts section */} + + + + {/* Starred section — only when there are starred items */} {starredItems.length > 0 && ( { + it('returns 5 entries with inbox/all/rules/silences/history paths', () => { + const nodes = buildAlertsTreeNodes(); + expect(nodes).toHaveLength(5); + const paths = nodes.map((n) => n.path); + expect(paths).toEqual([ + '/alerts/inbox', + '/alerts/all', + '/alerts/rules', + '/alerts/silences', + '/alerts/history', + ]); + }); +}); diff --git a/ui/src/components/sidebar-utils.ts b/ui/src/components/sidebar-utils.ts index abfbe32d..12aad938 100644 --- a/ui/src/components/sidebar-utils.ts +++ b/ui/src/components/sidebar-utils.ts @@ -1,5 +1,6 @@ import { createElement, type ReactNode } from 'react'; import type { SidebarTreeNode } from '@cameleer/design-system'; +import { AlertTriangle, Inbox, List, ScrollText, BellOff } from 'lucide-react'; /* ------------------------------------------------------------------ */ /* Domain types (moved out of DS — no longer exported there) */ @@ -113,3 +114,18 @@ export function buildAdminTreeNodes(opts?: { infrastructureEndpoints?: boolean } ]; return nodes; } + +/** + * Alerts tree — static nodes for the alerting section. + * Paths: /alerts/{inbox|all|rules|silences|history} + */ +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 })) }, + ]; +} From 891dcaef32b7a1133fa87b2726036137e02e686d Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:47:16 +0200 Subject: [PATCH 19/39] feat(ui/alerts): mount NotificationBell in TopBar Renders the `` as the first child of `` (before ``). The bell links to `/alerts/inbox` and shows the unread alert count for the currently selected environment. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/src/components/LayoutShell.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index 4f54c878..407a79ef 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -23,6 +23,7 @@ import type { SearchResult, SidebarTreeNode, DropdownItem, ButtonGroupItem, Exch import sidebarLogo from '@cameleer/design-system/assets/cameleer-logo.svg'; import { Box, Settings, FileText, ChevronRight, Square, Pause, Star, X, User, Plus, EyeOff, Bell } from 'lucide-react'; import { AboutMeDialog } from './AboutMeDialog'; +import { NotificationBell } from './NotificationBell'; import css from './LayoutShell.module.css'; import { useQueryClient } from '@tanstack/react-query'; import { useCatalog } from '../api/queries/catalog'; @@ -899,6 +900,7 @@ function LayoutContent() { onLogout={handleLogout} onNavigate={navigate} > + setPaletteOpen(true)} /> Date: Mon, 20 Apr 2026 13:49:23 +0200 Subject: [PATCH 20/39] 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. --- ui/src/pages/Alerts/AlertRow.tsx | 48 ++++++++++++++++++++++ ui/src/pages/Alerts/InboxPage.tsx | 47 ++++++++++++++++++++- ui/src/pages/Alerts/alerts-page.module.css | 18 ++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 ui/src/pages/Alerts/AlertRow.tsx create mode 100644 ui/src/pages/Alerts/alerts-page.module.css diff --git a/ui/src/pages/Alerts/AlertRow.tsx b/ui/src/pages/Alerts/AlertRow.tsx new file mode 100644 index 00000000..9f23c9bd --- /dev/null +++ b/ui/src/pages/Alerts/AlertRow.tsx @@ -0,0 +1,48 @@ +import { Link } from 'react-router'; +import { Button, useToast } from '@cameleer/design-system'; +import { AlertStateChip } from '../../components/AlertStateChip'; +import { SeverityBadge } from '../../components/SeverityBadge'; +import type { AlertDto } from '../../api/queries/alerts'; +import { useAckAlert, useMarkAlertRead } from '../../api/queries/alerts'; +import css from './alerts-page.module.css'; + +export function AlertRow({ alert, unread }: { alert: AlertDto; unread: boolean }) { + const ack = useAckAlert(); + const markRead = useMarkAlertRead(); + const { toast } = useToast(); + + const onAck = async () => { + try { + await ack.mutateAsync(alert.id); + toast({ title: 'Acknowledged', description: alert.title, variant: 'success' }); + } catch (e) { + toast({ title: 'Ack failed', description: String(e), variant: 'error' }); + } + }; + + return ( +
+ +
+ markRead.mutate(alert.id)}> + {alert.title} + +
+ + {alert.firedAt} +
+

{alert.message}

+
+
+ {alert.state === 'FIRING' && ( + + )} +
+
+ ); +} diff --git a/ui/src/pages/Alerts/InboxPage.tsx b/ui/src/pages/Alerts/InboxPage.tsx index 10f561b6..ba9f2849 100644 --- a/ui/src/pages/Alerts/InboxPage.tsx +++ b/ui/src/pages/Alerts/InboxPage.tsx @@ -1,3 +1,48 @@ +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() { - return
InboxPage — coming soon
; + const { data, isLoading, error } = useAlerts({ state: ['FIRING', 'ACKNOWLEDGED'], limit: 100 }); + const bulkRead = useBulkReadAlerts(); + const { toast } = useToast(); + + const unreadIds = useMemo( + () => (data ?? []).filter((a) => a.state === 'FIRING').map((a) => a.id), + [data], + ); + + if (isLoading) return ; + if (error) return
Failed to load alerts: {String(error)}
; + + const rows = data ?? []; + + const onMarkAllRead = async () => { + if (unreadIds.length === 0) return; + try { + await bulkRead.mutateAsync(unreadIds); + toast({ title: `Marked ${unreadIds.length} as read`, variant: 'success' }); + } catch (e) { + toast({ title: 'Bulk read failed', description: String(e), variant: 'error' }); + } + }; + + return ( +
+
+ Inbox + +
+ {rows.length === 0 ? ( +
No open alerts for you in this environment.
+ ) : ( + rows.map((a) => ) + )} +
+ ); } diff --git a/ui/src/pages/Alerts/alerts-page.module.css b/ui/src/pages/Alerts/alerts-page.module.css new file mode 100644 index 00000000..71047bfd --- /dev/null +++ b/ui/src/pages/Alerts/alerts-page.module.css @@ -0,0 +1,18 @@ +.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); } From 269a63af1fcda34b0c0ff2b190c92cbd1140ebe0 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:49:52 +0200 Subject: [PATCH 21/39] feat(ui/alerts): AllAlertsPage + HistoryPage AllAlertsPage: state filter chips (Open/Firing/Acked/All). HistoryPage: RESOLVED filter, respects retention window. --- ui/src/pages/Alerts/AllAlertsPage.tsx | 50 ++++++++++++++++++++++++++- ui/src/pages/Alerts/HistoryPage.tsx | 26 +++++++++++++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/ui/src/pages/Alerts/AllAlertsPage.tsx b/ui/src/pages/Alerts/AllAlertsPage.tsx index 22aec1f6..db462ba4 100644 --- a/ui/src/pages/Alerts/AllAlertsPage.tsx +++ b/ui/src/pages/Alerts/AllAlertsPage.tsx @@ -1,3 +1,51 @@ +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'; + +type AlertState = NonNullable; + +const STATE_FILTERS: Array<{ label: string; values: AlertState[] }> = [ + { 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() { - return
AllAlertsPage — coming soon
; + const [filterIdx, setFilterIdx] = useState(0); + const filter = STATE_FILTERS[filterIdx]; + const { data, isLoading, error } = useAlerts({ state: filter.values, limit: 200 }); + + if (isLoading) return ; + if (error) return
Failed to load alerts: {String(error)}
; + + const rows = data ?? []; + + return ( +
+
+ All alerts +
+ {STATE_FILTERS.map((f, i) => ( + + ))} +
+
+ {rows.length === 0 ? ( +
No alerts match this filter.
+ ) : ( + rows.map((a) => ) + )} +
+ ); } diff --git a/ui/src/pages/Alerts/HistoryPage.tsx b/ui/src/pages/Alerts/HistoryPage.tsx index 6807b6d4..27f01bb0 100644 --- a/ui/src/pages/Alerts/HistoryPage.tsx +++ b/ui/src/pages/Alerts/HistoryPage.tsx @@ -1,3 +1,27 @@ +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() { - return
HistoryPage — coming soon
; + const { data, isLoading, error } = useAlerts({ state: 'RESOLVED', limit: 200 }); + + if (isLoading) return ; + if (error) return
Failed to load history: {String(error)}
; + + const rows = data ?? []; + + return ( +
+
+ History +
+ {rows.length === 0 ? ( +
No resolved alerts in retention window.
+ ) : ( + rows.map((a) => ) + )} +
+ ); } From 7e91459cd608bf76c3bdaf8b49ddde879cbfbab3 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:52:14 +0200 Subject: [PATCH 22/39] feat(ui/alerts): RulesListPage with enable/disable, delete, env promotion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui/src/pages/Alerts/RulesListPage.tsx | 114 +++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/ui/src/pages/Alerts/RulesListPage.tsx b/ui/src/pages/Alerts/RulesListPage.tsx index ee769393..cdd2bd6a 100644 --- a/ui/src/pages/Alerts/RulesListPage.tsx +++ b/ui/src/pages/Alerts/RulesListPage.tsx @@ -1,3 +1,115 @@ +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() { - return
RulesListPage — coming soon
; + const navigate = useNavigate(); + const env = useSelectedEnv(); + const { data: rules, isLoading, error } = useAlertRules(); + const { data: envs } = useEnvironments(); + const setEnabled = useSetAlertRuleEnabled(); + const deleteRule = useDeleteAlertRule(); + const { toast } = useToast(); + + if (isLoading) return ; + if (error) return
Failed to load rules: {String(error)}
; + + const rows = rules ?? []; + const otherEnvs = (envs ?? []).filter((e) => e.slug !== env); + + const onToggle = async (r: AlertRuleResponse) => { + try { + await setEnabled.mutateAsync({ id: r.id, enabled: !r.enabled }); + toast({ title: r.enabled ? 'Disabled' : 'Enabled', description: r.name, variant: 'success' }); + } catch (e) { + toast({ title: 'Toggle failed', description: String(e), variant: 'error' }); + } + }; + + const onDelete = async (r: AlertRuleResponse) => { + if (!confirm(`Delete rule "${r.name}"? Fired alerts are preserved via rule_snapshot.`)) return; + try { + await deleteRule.mutateAsync(r.id); + toast({ title: 'Deleted', description: r.name, variant: 'success' }); + } catch (e) { + toast({ title: 'Delete failed', description: String(e), variant: 'error' }); + } + }; + + const onPromote = (r: AlertRuleResponse, targetEnvSlug: string) => { + navigate(`/alerts/rules/new?promoteFrom=${env}&ruleId=${r.id}&targetEnv=${targetEnvSlug}`); + }; + + return ( +
+
+ Alert rules + + + +
+
+ {rows.length === 0 ? ( +

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

+ ) : ( + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + ))} + +
NameKindSeverityEnabledTargets
{r.name} + onToggle(r)} + disabled={setEnabled.isPending} + /> + {r.targets.length} + {otherEnvs.length > 0 && ( + + )} + +
+ )} +
+
+ ); } From 334e815c25ee844fd69737ee24223bb90e0ca99b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:57:30 +0200 Subject: [PATCH 23/39] feat(ui/alerts): rule editor wizard shell + form-state module Wizard navigates 5 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 (6 tests). Step components are stubbed and will be implemented in Tasks 20-24. prefillFromPromotion is a thin wrapper in this commit; Task 24 rewrites it to compute scope-adjustment warnings. Deviation notes: - FormState.targets uses {kind, targetId} to match AlertRuleTarget DTO field names (plan draft had targetKind). - toRequest casts through Record so the spread over the Partial union typechecks. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../pages/Alerts/RuleEditor/ConditionStep.tsx | 5 + ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx | 13 ++ ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx | 11 ++ .../Alerts/RuleEditor/RuleEditorWizard.tsx | 153 +++++++++++++++++- ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx | 5 + .../pages/Alerts/RuleEditor/TriggerStep.tsx | 13 ++ .../Alerts/RuleEditor/form-state.test.ts | 50 ++++++ ui/src/pages/Alerts/RuleEditor/form-state.ts | 151 +++++++++++++++++ .../Alerts/RuleEditor/promotion-prefill.ts | 15 ++ .../pages/Alerts/RuleEditor/wizard.module.css | 57 +++++++ 10 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx create mode 100644 ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx create mode 100644 ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx create mode 100644 ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx create mode 100644 ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx create mode 100644 ui/src/pages/Alerts/RuleEditor/form-state.test.ts create mode 100644 ui/src/pages/Alerts/RuleEditor/form-state.ts create mode 100644 ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts create mode 100644 ui/src/pages/Alerts/RuleEditor/wizard.module.css diff --git a/ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx b/ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx new file mode 100644 index 00000000..a22fe75a --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx @@ -0,0 +1,5 @@ +import type { FormState } from './form-state'; + +export function ConditionStep({ form: _form, setForm: _setForm }: { form: FormState; setForm: (f: FormState) => void }) { + return
Condition step — TODO Task 21
; +} diff --git a/ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx b/ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx new file mode 100644 index 00000000..59d4d853 --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx @@ -0,0 +1,13 @@ +import type { FormState } from './form-state'; + +export function NotifyStep({ + form: _form, + setForm: _setForm, + ruleId: _ruleId, +}: { + form: FormState; + setForm: (f: FormState) => void; + ruleId?: string; +}) { + return
Notify step — TODO Task 23
; +} diff --git a/ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx b/ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx new file mode 100644 index 00000000..6801e71a --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx @@ -0,0 +1,11 @@ +import type { FormState } from './form-state'; + +export function ReviewStep({ + form: _form, + setForm: _setForm, +}: { + form: FormState; + setForm?: (f: FormState) => void; +}) { + return
Review step — TODO Task 24
; +} diff --git a/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx b/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx index 26c55fc7..84ab1605 100644 --- a/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx +++ b/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx @@ -1,3 +1,154 @@ +import { useEffect, 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 css from './wizard.module.css'; + +const STEP_LABELS: Record = { + scope: '1. Scope', + condition: '2. Condition', + trigger: '3. Trigger', + notify: '4. Notify', + review: '5. Review', +}; + export default function RuleEditorWizard() { - return
RuleEditorWizard — coming soon
; + 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. In + // Plan 03 we reuse `useAlertRule` (current-env scoped); cross-env + // fetching is handled server-side in the promote query hook when wired. + const promoteFrom = search.get('promoteFrom') ?? undefined; + const promoteRuleId = search.get('ruleId') ?? undefined; + const sourceRuleQuery = useAlertRule(promoteFrom ? promoteRuleId : undefined); + + const [step, setStep] = useState('scope'); + const [form, setForm] = useState(null); + + // Initialize form once the existing or source rule loads. + useEffect(() => { + if (form) return; + if (isEdit && existingQuery.data) { + setForm(initialForm(existingQuery.data)); + return; + } + if (promoteFrom && sourceRuleQuery.data) { + setForm(prefillFromPromotion(sourceRuleQuery.data)); + return; + } + if (!isEdit && !promoteFrom) { + setForm(initialForm()); + } + }, [form, isEdit, existingQuery.data, promoteFrom, sourceRuleQuery.data]); + + const create = useCreateAlertRule(); + const update = useUpdateAlertRule(id ?? ''); + + if (!form) return ; + + const idx = WIZARD_STEPS.indexOf(step); + const errors = validateStep(step, form); + + const onNext = () => { + if (errors.length > 0) { + toast({ title: 'Fix validation errors before continuing', description: errors.join(' \u00b7 '), variant: 'error' }); + return; + } + if (idx < WIZARD_STEPS.length - 1) setStep(WIZARD_STEPS[idx + 1]); + }; + const onBack = () => { + if (idx > 0) setStep(WIZARD_STEPS[idx - 1]); + }; + + const onSave = async () => { + try { + if (isEdit) { + await update.mutateAsync(toRequest(form)); + toast({ title: 'Rule updated', description: form.name, variant: 'success' }); + } else { + await create.mutateAsync(toRequest(form)); + toast({ title: 'Rule created', description: form.name, variant: 'success' }); + } + navigate('/alerts/rules'); + } catch (e) { + toast({ title: 'Save failed', description: String(e), variant: 'error' }); + } + }; + + const body = + step === 'scope' ? ( + + ) : step === 'condition' ? ( + + ) : step === 'trigger' ? ( + + ) : step === 'notify' ? ( + + ) : ( + + ); + + return ( +
+
+ {isEdit ? `Edit rule: ${form.name}` : 'New alert rule'} + {promoteFrom && ( +
+ Promoting from {promoteFrom} — review and adjust, then save. +
+ )} +
+ +
{body}
+
+ + {idx < WIZARD_STEPS.length - 1 ? ( + + ) : ( + + )} +
+
+ ); } diff --git a/ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx b/ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx new file mode 100644 index 00000000..649457f0 --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx @@ -0,0 +1,5 @@ +import type { FormState } from './form-state'; + +export function ScopeStep({ form: _form, setForm: _setForm }: { form: FormState; setForm: (f: FormState) => void }) { + return
Scope step — TODO Task 20
; +} diff --git a/ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx b/ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx new file mode 100644 index 00000000..7ab844be --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx @@ -0,0 +1,13 @@ +import type { FormState } from './form-state'; + +export function TriggerStep({ + form: _form, + setForm: _setForm, + ruleId: _ruleId, +}: { + form: FormState; + setForm: (f: FormState) => void; + ruleId?: string; +}) { + return
Trigger step — TODO Task 22
; +} diff --git a/ui/src/pages/Alerts/RuleEditor/form-state.test.ts b/ui/src/pages/Alerts/RuleEditor/form-state.test.ts new file mode 100644 index 00000000..40e68df8 --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/form-state.test.ts @@ -0,0 +1,50 @@ +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 unknown as { scope: Record }).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); + const scope = (req.condition as unknown as { scope: Record }).scope; + expect(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).some((e) => /Evaluation interval/.test(e))).toBe(true); + }); +}); diff --git a/ui/src/pages/Alerts/RuleEditor/form-state.ts b/ui/src/pages/Alerts/RuleEditor/form-state.ts new file mode 100644 index 00000000..e6cee874 --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/form-state.ts @@ -0,0 +1,151 @@ +import type { + AlertRuleRequest, + AlertRuleResponse, + ConditionKind, + AlertCondition, +} from '../../../api/queries/alertRules'; + +export type WizardStep = 'scope' | 'condition' | 'trigger' | 'notify' | 'review'; +export const WIZARD_STEPS: WizardStep[] = ['scope', 'condition', 'trigger', 'notify', 'review']; + +export interface FormState { + name: string; + description: string; + severity: 'CRITICAL' | 'WARNING' | 'INFO'; + enabled: boolean; + + // Scope (radio: env-wide | app | route | agent) + scopeKind: 'env' | 'app' | 'route' | 'agent'; + appSlug: string; + routeId: string; + agentId: string; + + conditionKind: ConditionKind; + condition: Partial; + + evaluationIntervalSeconds: number; + forDurationSeconds: number; + reNotifyMinutes: number; + + notificationTitleTmpl: string; + notificationMessageTmpl: string; + + webhooks: Array<{ + outboundConnectionId: string; + bodyOverride: string; + headerOverrides: Array<{ key: string; value: string }>; + }>; + + targets: Array<{ kind: 'USER' | 'GROUP' | 'ROLE'; targetId: string }>; +} + +export function initialForm(existing?: AlertRuleResponse): FormState { + if (!existing) { + return { + name: '', + description: '', + severity: 'WARNING', + enabled: true, + scopeKind: 'env', + appSlug: '', + routeId: '', + agentId: '', + conditionKind: 'ROUTE_METRIC', + condition: { kind: 'ROUTE_METRIC' } as Partial, + evaluationIntervalSeconds: 60, + forDurationSeconds: 0, + reNotifyMinutes: 60, + notificationTitleTmpl: '{{rule.name}} is firing', + notificationMessageTmpl: 'Alert {{alert.id}} fired at {{alert.firedAt}}', + webhooks: [], + targets: [], + }; + } + const scope = ((existing.condition as { scope?: { appSlug?: string; routeId?: string; agentId?: string } } | undefined)?.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 ?? 'WARNING') as FormState['severity'], + enabled: existing.enabled ?? true, + scopeKind, + appSlug: scope.appSlug ?? '', + routeId: scope.routeId ?? '', + agentId: scope.agentId ?? '', + conditionKind: (existing.conditionKind ?? 'ROUTE_METRIC') as ConditionKind, + condition: (existing.condition ?? { kind: existing.conditionKind }) as Partial, + evaluationIntervalSeconds: existing.evaluationIntervalSeconds ?? 60, + forDurationSeconds: existing.forDurationSeconds ?? 0, + reNotifyMinutes: existing.reNotifyMinutes ?? 60, + notificationTitleTmpl: existing.notificationTitleTmpl ?? '{{rule.name}} is firing', + notificationMessageTmpl: existing.notificationMessageTmpl ?? 'Alert {{alert.id}} fired at {{alert.firedAt}}', + webhooks: (existing.webhooks ?? []).map((w) => ({ + outboundConnectionId: (w.outboundConnectionId ?? '') as string, + bodyOverride: w.bodyOverride ?? '', + headerOverrides: Object.entries((w.headerOverrides ?? {}) as Record) + .map(([key, value]) => ({ key, value })), + })), + targets: (existing.targets ?? []).map((t) => ({ + kind: (t.kind ?? 'USER') as 'USER' | 'GROUP' | 'ROLE', + targetId: t.targetId ?? '', + })), + }; +} + +export function toRequest(f: FormState): AlertRuleRequest { + const scope: Record = {}; + if (f.scopeKind === 'app' || f.scopeKind === 'route' || f.scopeKind === 'agent') scope.appSlug = f.appSlug || undefined; + if (f.scopeKind === 'route') scope.routeId = f.routeId || undefined; + if (f.scopeKind === 'agent') scope.agentId = f.agentId || undefined; + + const condition = { ...(f.condition as Record), kind: f.conditionKind, scope } as unknown 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.map((t) => ({ kind: t.kind, targetId: t.targetId })), + } 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 \u2265 5 s.'); + if (f.forDurationSeconds < 0) errs.push('For-duration must be \u2265 0.'); + if (f.reNotifyMinutes < 0) errs.push('Re-notify cadence must be \u2265 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; +} diff --git a/ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts b/ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts new file mode 100644 index 00000000..4f3ec43d --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts @@ -0,0 +1,15 @@ +import { initialForm, type FormState } from './form-state'; +import type { AlertRuleResponse } from '../../../api/queries/alertRules'; + +/** + * Prefill the wizard form from a source rule being promoted from another env. + * + * Task 19 scaffolding: reuses the edit-prefill path and renames the rule. + * Task 24 rewrites this to compute scope-adjustment warnings and return + * `{ form, warnings }`. + */ +export function prefillFromPromotion(source: AlertRuleResponse): FormState { + const f = initialForm(source); + f.name = `${source.name ?? 'rule'} (copy)`; + return f; +} diff --git a/ui/src/pages/Alerts/RuleEditor/wizard.module.css b/ui/src/pages/Alerts/RuleEditor/wizard.module.css new file mode 100644 index 00000000..4dfd103f --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/wizard.module.css @@ -0,0 +1,57 @@ +.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; +} From f48fc750f23db15e2fcc51b07812892ec1c795ff Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:58:21 +0200 Subject: [PATCH 24/39] feat(ui/alerts): ScopeStep (name, severity, env/app/route/agent selectors) Name, description, severity, scope-kind radio, and cascading app/route/ agent selectors driven by catalog + agents data. Adjusts condition routing by clearing routeId/agentId when the app changes. Deviation: DS Select uses native event-based onChange; plan draft had a value-based signature. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx | 102 ++++++++++++++++++- 1 file changed, 100 insertions(+), 2 deletions(-) diff --git a/ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx b/ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx index 649457f0..68955926 100644 --- a/ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx +++ b/ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx @@ -1,5 +1,103 @@ +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'; -export function ScopeStep({ form: _form, setForm: _setForm }: { form: FormState; setForm: (f: FormState) => void }) { - return
Scope step — TODO Task 20
; +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' }, +]; + +type AgentSummary = { + instanceId?: string; + displayName?: string; + applicationId?: string; +}; + +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) => ({ + 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: AgentSummary[] = Array.isArray(agents) + ? (agents as AgentSummary[]).filter((a) => a.applicationId === form.appSlug) + : []; + + return ( +
+ + setForm({ ...form, name: e.target.value })} + placeholder="Order API error rate" + /> + + + setForm({ ...form, description: e.target.value })} + /> + + + setForm({ ...form, scopeKind: e.target.value as FormState['scopeKind'] })} + options={SCOPE_OPTIONS} + /> + + {form.scopeKind !== 'env' && ( + + setForm({ ...form, routeId: e.target.value })} + options={[{ value: '', label: '-- select --' }, ...routes.map((r) => ({ value: r.routeId, label: r.routeId }))]} + /> + + )} + {form.scopeKind === 'agent' && ( + + onKindChange(e.target.value)} + options={KIND_OPTIONS} + /> + + {form.conditionKind === 'ROUTE_METRIC' && } + {form.conditionKind === 'EXCHANGE_MATCH' && } + {form.conditionKind === 'AGENT_STATE' && } + {form.conditionKind === 'DEPLOYMENT_STATE' && } + {form.conditionKind === 'LOG_PATTERN' && } + {form.conditionKind === 'JVM_METRIC' && } +
+ ); } diff --git a/ui/src/pages/Alerts/RuleEditor/condition-forms/AgentStateForm.tsx b/ui/src/pages/Alerts/RuleEditor/condition-forms/AgentStateForm.tsx new file mode 100644 index 00000000..e50026cc --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/condition-forms/AgentStateForm.tsx @@ -0,0 +1,30 @@ +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 = form.condition as Record; + const patch = (p: Record) => + setForm({ ...form, condition: { ...(form.condition as Record), ...p } as FormState['condition'] }); + + return ( + <> + + patch({ forSeconds: Number(e.target.value) })} + /> + + + ); +} diff --git a/ui/src/pages/Alerts/RuleEditor/condition-forms/DeploymentStateForm.tsx b/ui/src/pages/Alerts/RuleEditor/condition-forms/DeploymentStateForm.tsx new file mode 100644 index 00000000..5497add4 --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/condition-forms/DeploymentStateForm.tsx @@ -0,0 +1,26 @@ +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 = form.condition as Record; + const states: string[] = (c.states as string[] | undefined) ?? []; + const toggle = (s: string) => { + const next = states.includes(s) ? states.filter((x) => x !== s) : [...states, s]; + setForm({ ...form, condition: { ...(form.condition as Record), states: next } as FormState['condition'] }); + }; + + return ( + +
+ {OPTIONS.map((s) => ( + + ))} +
+
+ ); +} diff --git a/ui/src/pages/Alerts/RuleEditor/condition-forms/ExchangeMatchForm.tsx b/ui/src/pages/Alerts/RuleEditor/condition-forms/ExchangeMatchForm.tsx new file mode 100644 index 00000000..e2e360f4 --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/condition-forms/ExchangeMatchForm.tsx @@ -0,0 +1,67 @@ +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 = form.condition as Record; + const filter = (c.filter as Record | undefined) ?? {}; + const patch = (p: Record) => + setForm({ ...form, condition: { ...(form.condition as Record), ...p } as FormState['condition'] }); + + return ( + <> + + patch({ filter: { ...filter, status: e.target.value || undefined } })} + options={STATUSES} + /> + + {c.fireMode === 'PER_EXCHANGE' && ( + + patch({ perExchangeLingerSeconds: Number(e.target.value) })} + /> + + )} + {c.fireMode === 'COUNT_IN_WINDOW' && ( + <> + + patch({ threshold: Number(e.target.value) })} + /> + + + patch({ windowSeconds: Number(e.target.value) })} + /> + + + )} + + ); +} diff --git a/ui/src/pages/Alerts/RuleEditor/condition-forms/JvmMetricForm.tsx b/ui/src/pages/Alerts/RuleEditor/condition-forms/JvmMetricForm.tsx new file mode 100644 index 00000000..59b7bcd9 --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/condition-forms/JvmMetricForm.tsx @@ -0,0 +1,57 @@ +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 = form.condition as Record; + const patch = (p: Record) => + setForm({ ...form, condition: { ...(form.condition as Record), ...p } as FormState['condition'] }); + + return ( + <> + + patch({ metric: e.target.value })} + placeholder="heap_used_percent" + /> + + + patch({ comparator: e.target.value })} + options={[ + { value: 'GT', label: '>' }, + { value: 'GTE', label: '\u2265' }, + { value: 'LT', label: '<' }, + { value: 'LTE', label: '\u2264' }, + ]} + /> + + + patch({ threshold: Number(e.target.value) })} + /> + + + patch({ windowSeconds: Number(e.target.value) })} + /> + + + ); +} diff --git a/ui/src/pages/Alerts/RuleEditor/condition-forms/LogPatternForm.tsx b/ui/src/pages/Alerts/RuleEditor/condition-forms/LogPatternForm.tsx new file mode 100644 index 00000000..b34efba4 --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/condition-forms/LogPatternForm.tsx @@ -0,0 +1,50 @@ +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 = form.condition as Record; + const patch = (p: Record) => + setForm({ ...form, condition: { ...(form.condition as Record), ...p } as FormState['condition'] }); + + return ( + <> + + patch({ logger: e.target.value || undefined })} + /> + + + patch({ pattern: e.target.value })} + /> + + + patch({ threshold: Number(e.target.value) })} + /> + + + patch({ windowSeconds: Number(e.target.value) })} + /> + + + ); +} diff --git a/ui/src/pages/Alerts/RuleEditor/condition-forms/RouteMetricForm.tsx b/ui/src/pages/Alerts/RuleEditor/condition-forms/RouteMetricForm.tsx new file mode 100644 index 00000000..e8138110 --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/condition-forms/RouteMetricForm.tsx @@ -0,0 +1,57 @@ +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: '\u2265' }, + { value: 'LT', label: '<' }, + { value: 'LTE', label: '\u2264' }, +]; + +export function RouteMetricForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) { + const c = form.condition as Record; + const patch = (p: Record) => + setForm({ ...form, condition: { ...(form.condition as Record), ...p } as FormState['condition'] }); + + return ( + <> + + patch({ comparator: e.target.value })} + options={COMPARATORS} + /> + + + patch({ threshold: Number(e.target.value) })} + /> + + + patch({ windowSeconds: Number(e.target.value) })} + /> + + + ); +} From d42a6ca6a8eaf0e9ead7f57cc0b2a0705d94f1f4 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:00:33 +0200 Subject: [PATCH 26/39] feat(ui/alerts): TriggerStep (evaluation interval, for-duration, re-notify, test-evaluate) Three numeric inputs for evaluation cadence, for-duration, and re-notification window, plus a Test evaluate button for saved rules. TestEvaluateRequest is empty on the wire (server uses the rule id), so we send {} and rely on the backend to evaluate the current saved state. Deviation: plan draft passed {condition: toRequest(form).condition} into the request body. The generated TestEvaluateRequest type is Record, so we send an empty body. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../pages/Alerts/RuleEditor/TriggerStep.tsx | 80 ++++++++++++++++++- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx b/ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx index 7ab844be..77da5c56 100644 --- a/ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx +++ b/ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx @@ -1,13 +1,85 @@ +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'; export function TriggerStep({ - form: _form, - setForm: _setForm, - ruleId: _ruleId, + form, + setForm, + ruleId, }: { form: FormState; setForm: (f: FormState) => void; ruleId?: string; }) { - return
Trigger step — TODO Task 22
; + const testEvaluate = useTestEvaluate(); + const { toast } = useToast(); + const [lastResult, setLastResult] = useState(null); + + const onTest = async () => { + if (!ruleId) { + toast({ title: 'Save rule first to run test evaluate', variant: 'error' }); + return; + } + try { + const result = await testEvaluate.mutateAsync({ id: ruleId, req: {} }); + setLastResult(JSON.stringify(result, null, 2)); + } catch (e) { + toast({ title: 'Test-evaluate failed', description: String(e), variant: 'error' }); + } + }; + + return ( +
+ + setForm({ ...form, evaluationIntervalSeconds: Number(e.target.value) })} + /> + + + setForm({ ...form, forDurationSeconds: Number(e.target.value) })} + /> + + + setForm({ ...form, reNotifyMinutes: Number(e.target.value) })} + /> + +
+ + {!ruleId && ( +

+ Save the rule first to enable test-evaluate. +

+ )} + {lastResult && ( +
+            {lastResult}
+          
+ )} +
+
+ ); } From 816096f4d1b6d5c68873722e8dbe94b7fa5dd0d4 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:02:26 +0200 Subject: [PATCH 27/39] feat(ui/alerts): NotifyStep (MustacheEditor for title/message/body, targets, webhook bindings) Title and message use MustacheEditor with kind-specific autocomplete. Preview button posts to the render-preview endpoint and shows rendered title/message inline. Targets combine users/groups/roles into a unified Badge pill list. Webhook picker filters to outbound connections allowed in the current env (spec 6, allowed_environment_ids). Header overrides use plain Input rather than MustacheEditor for now. Deviations: - RenderPreviewRequest is Record, so we send {} instead of {titleTemplate, messageTemplate}; backend resolves from rule state. - RenderPreviewResponse has {title, message} (plan draft used renderedTitle/renderedMessage). - Button size="sm" not "small" (DS only accepts sm|md). - Target kind field renamed from targetKind to kind to match AlertRuleTarget DTO. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx | 245 +++++++++++++++++- 1 file changed, 241 insertions(+), 4 deletions(-) diff --git a/ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx b/ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx index 59d4d853..d8272ce7 100644 --- a/ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx +++ b/ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx @@ -1,13 +1,250 @@ +import { useState } from 'react'; +import { Badge, Button, FormField, Input, Select, useToast } 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 type { FormState } from './form-state'; +type TargetKind = FormState['targets'][number]['kind']; + export function NotifyStep({ - form: _form, - setForm: _setForm, - ruleId: _ruleId, + form, + setForm, + ruleId, }: { form: FormState; setForm: (f: FormState) => void; ruleId?: string; }) { - return
Notify step — TODO Task 23
; + const env = useSelectedEnv(); + const { data: users } = useUsers(true); + const { data: groups } = useGroups(true); + const { data: roles } = useRoles(true); + const { data: connections } = useOutboundConnections(); + const preview = useRenderPreview(); + const { toast } = useToast(); + const [lastPreview, setLastPreview] = useState(null); + + // Filter connections to those that allow the current env. + const availableConnections = (connections ?? []).filter( + (c) => c.allowedEnvironmentIds.length === 0 || (!!env && 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: {} }); + setLastPreview(`TITLE:\n${res.title ?? ''}\n\nMESSAGE:\n${res.message ?? ''}`); + } catch (e) { + toast({ title: 'Preview failed', description: String(e), variant: 'error' }); + } + }; + + const addTarget = (kind: TargetKind, targetId: string) => { + if (!targetId) return; + if (form.targets.some((t) => t.kind === kind && t.targetId === targetId)) return; + setForm({ ...form, targets: [...form.targets, { kind, targetId }] }); + }; + const removeTarget = (idx: number) => { + setForm({ ...form, targets: form.targets.filter((_, i) => i !== idx) }); + }; + + const addWebhook = (outboundConnectionId: string) => { + setForm({ + ...form, + webhooks: [...form.webhooks, { outboundConnectionId, bodyOverride: '', headerOverrides: [] }], + }); + }; + const removeWebhook = (idx: number) => { + setForm({ ...form, webhooks: form.webhooks.filter((_, i) => i !== idx) }); + }; + const updateWebhook = (idx: number, patch: Partial) => { + setForm({ + ...form, + webhooks: form.webhooks.map((w, i) => (i === idx ? { ...w, ...patch } : w)), + }); + }; + + return ( +
+ setForm({ ...form, notificationTitleTmpl: v })} + kind={form.conditionKind} + singleLine + /> + setForm({ ...form, notificationMessageTmpl: v })} + kind={form.conditionKind} + minHeight={120} + /> +
+ + {!ruleId && ( +

+ Save the rule first to preview rendered output. +

+ )} + {lastPreview && ( +
+            {lastPreview}
+          
+ )} +
+ + +
+ {form.targets.map((t, i) => ( + removeTarget(i)} + /> + ))} +
+
+ { + addTarget('GROUP', e.target.value); + e.target.value = ''; + }} + options={[ + { value: '', label: '+ Group' }, + ...(groups ?? []).map((g) => ({ value: g.id, label: g.name })), + ]} + /> + { + if (e.target.value) addWebhook(e.target.value); + e.target.value = ''; + }} + options={[ + { value: '', label: '+ Add webhook' }, + ...availableConnections.map((c) => ({ value: c.id, label: c.name })), + ]} + /> + {form.webhooks.map((w, i) => { + const conn = availableConnections.find((c) => c.id === w.outboundConnectionId); + return ( +
+
+ {conn?.name ?? w.outboundConnectionId} + +
+ updateWebhook(i, { bodyOverride: v })} + kind={form.conditionKind} + placeholder="Leave empty to use connection default" + minHeight={80} + /> + + {w.headerOverrides.map((h, hi) => ( +
+ { + const heads = [...w.headerOverrides]; + heads[hi] = { ...heads[hi], key: e.target.value }; + updateWebhook(i, { headerOverrides: heads }); + }} + /> + { + const heads = [...w.headerOverrides]; + heads[hi] = { ...heads[hi], value: e.target.value }; + updateWebhook(i, { headerOverrides: heads }); + }} + /> + +
+ ))} + +
+
+ ); + })} + +
+ ); } From 3963ea5591595dc7d30d009ede3256629ae89301 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:04:04 +0200 Subject: [PATCH 28/39] feat(ui/alerts): ReviewStep + promotion prefill warnings Review step dumps a human summary plus raw request JSON, and (when a setter is supplied) offers an Enabled-on-save Toggle. Promotion prefill now returns {form, warnings}: clears agent IDs (per-env), flags missing apps in target env, and flags webhook connections not allowed in target env. 4 Vitest cases cover copy-name, agent clear, app-missing, and webhook-not-allowed paths. The wizard now consumes {form, warnings}; Task 25 renders the warnings banner. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx | 59 ++++++++++++++- .../Alerts/RuleEditor/RuleEditorWizard.tsx | 3 +- .../RuleEditor/promotion-prefill.test.ts | 74 +++++++++++++++++++ .../Alerts/RuleEditor/promotion-prefill.ts | 66 ++++++++++++++--- 4 files changed, 186 insertions(+), 16 deletions(-) create mode 100644 ui/src/pages/Alerts/RuleEditor/promotion-prefill.test.ts diff --git a/ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx b/ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx index 6801e71a..bda16fd5 100644 --- a/ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx +++ b/ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx @@ -1,11 +1,62 @@ -import type { FormState } from './form-state'; +import { Toggle } from '@cameleer/design-system'; +import { toRequest, type FormState } from './form-state'; export function ReviewStep({ - form: _form, - setForm: _setForm, + form, + setForm, }: { form: FormState; setForm?: (f: FormState) => void; }) { - return
Review step — TODO Task 24
; + const req = toRequest(form); + return ( +
+
+ Name: {form.name} +
+
+ Severity: {form.severity} +
+
+ Scope: {form.scopeKind} + {form.scopeKind !== 'env' && + ` (app=${form.appSlug}${form.routeId ? `, route=${form.routeId}` : ''}${form.agentId ? `, agent=${form.agentId}` : ''})`} +
+
+ Condition kind: {form.conditionKind} +
+
+ Intervals: eval {form.evaluationIntervalSeconds}s · for {form.forDurationSeconds}s · re-notify {form.reNotifyMinutes}m +
+
+ Targets: {form.targets.length} +
+
+ Webhooks: {form.webhooks.length} +
+ {setForm && ( +
+ setForm({ ...form, enabled: e.target.checked })} + label="Enabled on save" + /> +
+ )} +
+ Raw request JSON +
+          {JSON.stringify(req, null, 2)}
+        
+
+
+ ); } diff --git a/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx b/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx index 84ab1605..250f5bdc 100644 --- a/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx +++ b/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx @@ -58,7 +58,8 @@ export default function RuleEditorWizard() { return; } if (promoteFrom && sourceRuleQuery.data) { - setForm(prefillFromPromotion(sourceRuleQuery.data)); + const { form: prefilled } = prefillFromPromotion(sourceRuleQuery.data); + setForm(prefilled); return; } if (!isEdit && !promoteFrom) { diff --git a/ui/src/pages/Alerts/RuleEditor/promotion-prefill.test.ts b/ui/src/pages/Alerts/RuleEditor/promotion-prefill.test.ts new file mode 100644 index 00000000..fa56d760 --- /dev/null +++ b/ui/src/pages/Alerts/RuleEditor/promotion-prefill.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { prefillFromPromotion } from './promotion-prefill'; +import type { AlertRuleResponse } from '../../../api/queries/alertRules'; + +function fakeRule(overrides: Partial = {}): AlertRuleResponse { + return { + id: '11111111-1111-1111-1111-111111111111', + environmentId: '22222222-2222-2222-2222-222222222222', + name: 'High error rate', + description: undefined, + severity: 'CRITICAL', + enabled: true, + conditionKind: 'ROUTE_METRIC', + condition: { + kind: 'RouteMetricCondition', + scope: { appSlug: 'orders' }, + } as unknown as AlertRuleResponse['condition'], + evaluationIntervalSeconds: 60, + forDurationSeconds: 0, + reNotifyMinutes: 60, + notificationTitleTmpl: '{{rule.name}}', + notificationMessageTmpl: 'msg', + webhooks: [], + targets: [], + createdAt: '2026-04-01T00:00:00Z', + createdBy: 'alice', + updatedAt: '2026-04-01T00:00:00Z', + 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({ + conditionKind: 'AGENT_STATE', + condition: { + kind: 'AgentStateCondition', + scope: { appSlug: 'orders', agentId: 'orders-0' }, + state: 'DEAD', + forSeconds: 60, + } as unknown as AlertRuleResponse['condition'], + }), + ); + 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: undefined, + headerOverrides: {}, + }, + ], + }); + const { warnings } = prefillFromPromotion(rule, { targetEnvAllowedConnectionIds: ['conn-dev'] }); + expect(warnings.find((w) => w.field.startsWith('webhooks['))).toBeTruthy(); + }); +}); diff --git a/ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts b/ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts index 4f3ec43d..51e3c52f 100644 --- a/ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts +++ b/ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts @@ -1,15 +1,59 @@ import { initialForm, type FormState } from './form-state'; import type { AlertRuleResponse } from '../../../api/queries/alertRules'; -/** - * Prefill the wizard form from a source rule being promoted from another env. - * - * Task 19 scaffolding: reuses the edit-prefill path and renames the rule. - * Task 24 rewrites this to compute scope-adjustment warnings and return - * `{ form, warnings }`. - */ -export function prefillFromPromotion(source: AlertRuleResponse): FormState { - const f = initialForm(source); - f.name = `${source.name ?? 'rule'} (copy)`; - return f; +export interface PrefillWarning { + field: string; + message: string; +} + +export interface PrefillOptions { + targetEnvAppSlugs?: string[]; + /** IDs of outbound connections allowed in the target env. */ + targetEnvAllowedConnectionIds?: string[]; +} + +/** + * Client-side prefill when promoting a rule from another env. Emits warnings for + * fields that cross env boundaries (agent IDs, apps missing in target env, + * outbound connections not allowed in target env). + */ +export function prefillFromPromotion( + source: AlertRuleResponse, + opts: PrefillOptions = {}, +): { form: FormState; warnings: PrefillWarning[] } { + const form = initialForm(source); + form.name = `${source.name ?? 'rule'} (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 \u2014 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 \u2014 remove or pick another before saving.`, + }); + } + } + } + + return { form, warnings }; } From 0191ca4b13815e80c6cb718a9259ae0f25da432f Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:05:08 +0200 Subject: [PATCH 29/39] feat(ui/alerts): render promotion warnings in wizard banner Fetches target-env apps (useCatalog) and env-allowed outbound connections, passes them to prefillFromPromotion, and renders the returned warnings in an amber banner above the step nav. Warnings list the field name and the remediation message so users see crossings that need manual adjustment before saving. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Alerts/RuleEditor/RuleEditorWizard.tsx | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx b/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx index 250f5bdc..d33f916b 100644 --- a/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx +++ b/ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx @@ -20,7 +20,10 @@ import { ConditionStep } from './ConditionStep'; import { TriggerStep } from './TriggerStep'; import { NotifyStep } from './NotifyStep'; import { ReviewStep } from './ReviewStep'; -import { prefillFromPromotion } from './promotion-prefill'; +import { prefillFromPromotion, type PrefillWarning } from './promotion-prefill'; +import { useCatalog } from '../../../api/queries/catalog'; +import { useOutboundConnections } from '../../../api/queries/admin/outboundConnections'; +import { useSelectedEnv } from '../../../api/queries/alertMeta'; import css from './wizard.module.css'; const STEP_LABELS: Record = { @@ -47,8 +50,20 @@ export default function RuleEditorWizard() { const promoteRuleId = search.get('ruleId') ?? undefined; const sourceRuleQuery = useAlertRule(promoteFrom ? promoteRuleId : undefined); + // Target-env data for promotion warnings. + const env = useSelectedEnv(); + const targetEnv = search.get('targetEnv') ?? env; + const { data: targetCatalog } = useCatalog(targetEnv ?? undefined); + const { data: connections } = useOutboundConnections(); + + const targetAppSlugs = (targetCatalog ?? []).map((a) => a.slug); + const targetAllowedConnIds = (connections ?? []) + .filter((c) => c.allowedEnvironmentIds.length === 0 || (!!targetEnv && c.allowedEnvironmentIds.includes(targetEnv))) + .map((c) => c.id); + const [step, setStep] = useState('scope'); const [form, setForm] = useState(null); + const [warnings, setWarnings] = useState([]); // Initialize form once the existing or source rule loads. useEffect(() => { @@ -58,14 +73,29 @@ export default function RuleEditorWizard() { return; } if (promoteFrom && sourceRuleQuery.data) { - const { form: prefilled } = prefillFromPromotion(sourceRuleQuery.data); + const { form: prefilled, warnings: w } = prefillFromPromotion(sourceRuleQuery.data, { + targetEnvAppSlugs: targetAppSlugs, + targetEnvAllowedConnectionIds: targetAllowedConnIds, + }); setForm(prefilled); + setWarnings(w); return; } if (!isEdit && !promoteFrom) { setForm(initialForm()); } - }, [form, isEdit, existingQuery.data, promoteFrom, sourceRuleQuery.data]); + // Intentionally depend on join()'d slug/id strings so the effect + // doesn't retrigger on new array identities when contents are equal. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + form, + isEdit, + existingQuery.data, + promoteFrom, + sourceRuleQuery.data, + targetAppSlugs.join(','), + targetAllowedConnIds.join(','), + ]); const create = useCreateAlertRule(); const update = useUpdateAlertRule(id ?? ''); @@ -124,6 +154,18 @@ export default function RuleEditorWizard() {
)}
+ {warnings.length > 0 && ( +
+ Review before saving: +
    + {warnings.map((w) => ( +
  • + {w.field}: {w.message} +
  • + ))} +
+
+ )}
+ +
+ {rows.length === 0 ? ( +

No active or scheduled silences.

+ ) : ( + + + + + + + + + + + + {rows.map((s) => ( + + + + + + + + ))} + +
MatcherReasonStartsEnds
{JSON.stringify(s.matcher)}{s.reason ?? '—'}{s.startsAt}{s.endsAt} + +
+ )} +
+ + ); } From f4c2cb120bce8ecc8cb4bd92f0299986e230ad35 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:09:39 +0200 Subject: [PATCH 31/39] feat(ui/alerts): CMD-K sources for alerts + alert rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui/src/components/LayoutShell.tsx | 72 ++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index 407a79ef..5f017d21 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -31,6 +31,8 @@ import { useAgents } from '../api/queries/agents'; import { useSearchExecutions, useAttributeKeys } from '../api/queries/executions'; import { useUsers, useGroups, useRoles } from '../api/queries/admin/rbac'; import { useEnvironments } from '../api/queries/admin/environments'; +import { useAlerts } from '../api/queries/alerts'; +import { useAlertRules } from '../api/queries/alertRules'; import type { UserDetail, GroupDetail, RoleDetail } from '../api/queries/admin/rbac'; import { useAuthStore, useIsAdmin, useCanControl } from '../auth/auth-store'; import { useEnvironmentStore } from '../api/environment-store'; @@ -161,6 +163,58 @@ function buildAdminSearchData( return results; } +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'; +} + function healthToSearchColor(health: string): string { switch (health) { case 'live': return 'success'; @@ -313,6 +367,10 @@ function LayoutContent() { const { data: attributeKeys } = useAttributeKeys(); const { data: envRecords = [] } = useEnvironments(); + // Open alerts + rules for CMD-K (env-scoped). + const { data: cmdkAlerts } = useAlerts({ state: ['FIRING', 'ACKNOWLEDGED'], limit: 100 }); + const { data: cmdkRules } = useAlertRules(); + // Merge environments from both the environments table and agent heartbeats const environments: string[] = useMemo(() => { const envSet = new Set(); @@ -569,6 +627,11 @@ function LayoutContent() { [adminUsers, adminGroups, adminRoles], ); + const alertingSearchData: SearchResult[] = useMemo( + () => buildAlertSearchData(cmdkAlerts, cmdkRules), + [cmdkAlerts, cmdkRules], + ); + const operationalSearchData: SearchResult[] = useMemo(() => { if (isAdminPage) return []; @@ -604,8 +667,8 @@ function LayoutContent() { } } - return [...catalogRef.current, ...exchangeItems, ...attributeItems]; - }, [isAdminPage, catalogRef.current, exchangeResults, debouncedQuery]); + return [...catalogRef.current, ...exchangeItems, ...attributeItems, ...alertingSearchData]; + }, [isAdminPage, catalogRef.current, exchangeResults, debouncedQuery, alertingSearchData]); const searchData = isAdminPage ? adminSearchData : operationalSearchData; @@ -653,6 +716,11 @@ function LayoutContent() { const ADMIN_TAB_MAP: Record = { user: 'users', group: 'groups', role: 'roles' }; const handlePaletteSelect = useCallback((result: any) => { + if (result.category === 'alert' || result.category === 'alertRule') { + if (result.path) navigate(result.path); + setPaletteOpen(false); + return; + } if (result.path) { if (ADMIN_CATEGORIES.has(result.category)) { const itemId = result.id.split(':').slice(1).join(':'); From 5ebc729b8277a6a33941a811141000100942ef7b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:17:44 +0200 Subject: [PATCH 32/39] feat(alerting): SSRF guard on outbound connection URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rejects webhook URLs that resolve to loopback, link-local, or RFC-1918 private ranges (IPv4 + IPv6 ULA fc00::/7). Enforced on both create and update in OutboundConnectionServiceImpl before persistence; returns 400 Bad Request with "private or loopback" in the body. Bypass via `cameleer.server.outbound-http.allow-private-targets=true` for dev environments where webhooks legitimately point at local services. Production default is `false`. Test profile sets the flag to `true` in application-test.yml so the existing ITs that post webhooks to WireMock on https://localhost:PORT keep working. A dedicated OutboundConnectionSsrfIT overrides the flag back to false (via @TestPropertySource + @DirtiesContext) to exercise the reject path end-to-end through the admin controller. Plan 01 scope; required before SaaS exposure (spec §17). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../OutboundConnectionServiceImpl.java | 26 +++++++ .../server/app/outbound/SsrfGuard.java | 69 ++++++++++++++++++ .../outbound/config/OutboundBeanConfig.java | 4 +- .../server/app/outbound/SsrfGuardTest.java | 73 +++++++++++++++++++ .../controller/OutboundConnectionSsrfIT.java | 67 +++++++++++++++++ .../src/test/resources/application-test.yml | 2 + 6 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionSsrfIT.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java index 328a68e6..81d6719f 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java @@ -7,6 +7,8 @@ import com.cameleer.server.core.outbound.OutboundConnectionService; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; +import java.net.URI; +import java.net.URISyntaxException; import java.time.Instant; import java.util.List; import java.util.UUID; @@ -15,20 +17,24 @@ public class OutboundConnectionServiceImpl implements OutboundConnectionService private final OutboundConnectionRepository repo; private final AlertRuleRepository ruleRepo; + private final SsrfGuard ssrfGuard; private final String tenantId; public OutboundConnectionServiceImpl( OutboundConnectionRepository repo, AlertRuleRepository ruleRepo, + SsrfGuard ssrfGuard, String tenantId) { this.repo = repo; this.ruleRepo = ruleRepo; + this.ssrfGuard = ssrfGuard; this.tenantId = tenantId; } @Override public OutboundConnection create(OutboundConnection draft, String actingUserId) { assertNameUnique(draft.name(), null); + validateUrl(draft.url()); OutboundConnection c = new OutboundConnection( UUID.randomUUID(), tenantId, draft.name(), draft.description(), draft.url(), draft.method(), draft.defaultHeaders(), draft.defaultBodyTmpl(), @@ -46,6 +52,7 @@ public class OutboundConnectionServiceImpl implements OutboundConnectionService if (!existing.name().equals(draft.name())) { assertNameUnique(draft.name(), id); } + validateUrl(draft.url()); // Narrowing allowed-envs guard: if the new draft restricts to a non-empty set of envs, // find any envs that existed before but are absent in the draft. @@ -107,4 +114,23 @@ public class OutboundConnectionServiceImpl implements OutboundConnectionService } }); } + + /** + * Validate the webhook URL against SSRF pitfalls. Translates the guard's + * {@link IllegalArgumentException} into a 400 Bad Request with the guard's + * message preserved, so the client sees e.g. "private or loopback". + */ + private void validateUrl(String url) { + URI uri; + try { + uri = new URI(url); + } catch (URISyntaxException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid URL: " + url); + } + try { + ssrfGuard.validate(uri); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage(), e); + } + } } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java new file mode 100644 index 00000000..557f6f19 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java @@ -0,0 +1,69 @@ +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; + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java index bea1fab5..6c8a2182 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java @@ -1,6 +1,7 @@ package com.cameleer.server.app.outbound.config; import com.cameleer.server.app.outbound.OutboundConnectionServiceImpl; +import com.cameleer.server.app.outbound.SsrfGuard; import com.cameleer.server.app.outbound.crypto.SecretCipher; import com.cameleer.server.app.outbound.storage.PostgresOutboundConnectionRepository; import com.cameleer.server.core.alerting.AlertRuleRepository; @@ -31,7 +32,8 @@ public class OutboundBeanConfig { public OutboundConnectionService outboundConnectionService( OutboundConnectionRepository repo, AlertRuleRepository ruleRepo, + SsrfGuard ssrfGuard, @Value("${cameleer.server.tenant.id:default}") String tenantId) { - return new OutboundConnectionServiceImpl(repo, ruleRepo, tenantId); + return new OutboundConnectionServiceImpl(repo, ruleRepo, ssrfGuard, tenantId); } } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java new file mode 100644 index 00000000..9614d0c6 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java @@ -0,0 +1,73 @@ +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 + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionSsrfIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionSsrfIT.java new file mode 100644 index 00000000..6f791b8d --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionSsrfIT.java @@ -0,0 +1,67 @@ +package com.cameleer.server.app.outbound.controller; + +import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.app.TestSecurityHelper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Dedicated IT that overrides the test-profile default `allow-private-targets=true` + * back to `false` so the SSRF guard's production behavior (reject loopback) is + * exercised end-to-end through the admin controller. + * + * Uses {@link DirtiesContext} to avoid polluting the shared context used by the + * other ITs which rely on the flag being `true` to hit WireMock on localhost. + */ +@TestPropertySource(properties = "cameleer.server.outbound-http.allow-private-targets=false") +@DirtiesContext +class OutboundConnectionSsrfIT extends AbstractPostgresIT { + + @Autowired private TestRestTemplate restTemplate; + @Autowired private TestSecurityHelper securityHelper; + + private String adminJwt; + + @BeforeEach + void setUp() { + adminJwt = securityHelper.adminToken(); + // Seed admin user row since users(user_id) is an FK target. + jdbcTemplate.update( + "INSERT INTO users (user_id, provider, email, display_name) VALUES (?, 'test', ?, ?) ON CONFLICT (user_id) DO NOTHING", + "test-admin", "test-admin@example.com", "test-admin"); + jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = 'default'"); + } + + @AfterEach + void cleanup() { + jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = 'default'"); + jdbcTemplate.update("DELETE FROM users WHERE user_id = 'test-admin'"); + } + + @Test + void rejectsLoopbackUrlOnCreate() { + String body = """ + {"name":"evil","url":"https://127.0.0.1/abuse","method":"POST", + "tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}"""; + + ResponseEntity resp = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)), + String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(resp.getBody()).isNotNull(); + assertThat(resp.getBody()).contains("private or loopback"); + } +} diff --git a/cameleer-server-app/src/test/resources/application-test.yml b/cameleer-server-app/src/test/resources/application-test.yml index ce02814a..ec845496 100644 --- a/cameleer-server-app/src/test/resources/application-test.yml +++ b/cameleer-server-app/src/test/resources/application-test.yml @@ -17,3 +17,5 @@ cameleer: bootstraptokenprevious: old-bootstrap-token infrastructureendpoints: true jwtsecret: test-jwt-secret-for-integration-tests-only + outbound-http: + allow-private-targets: true From 9f109b20fd3fc3f8b49f2d9ce60f2b06d4c58f66 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:22:54 +0200 Subject: [PATCH 33/39] perf(alerting): 30s TTL cache on AlertingMetrics gauge suppliers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. - Introduces a package-private TtlCache that wraps a Supplier and memoises the last read for a configurable Duration against a Supplier clock. - Wraps each gauge supplier (alerting_rules_total{enabled|disabled}, alerting_instances_total{state}) in its own TtlCache. - Adds a test-friendly constructor (package-private) taking explicit Duration + Supplier so AlertingMetricsCachingTest can advance a fake clock without waiting wall-clock time. - Adds AlertingMetricsCachingTest covering: * supplier invoked once per TTL across repeated scrapes * 29 s elapsed → still cached; 31 s elapsed → re-queried * gauge value reflects the cached result even after delegate mutates Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/alerting/metrics/AlertingMetrics.java | 158 ++++++++++++++---- .../metrics/AlertingMetricsCachingTest.java | 111 ++++++++++++ 2 files changed, 241 insertions(+), 28 deletions(-) create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/metrics/AlertingMetrics.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/metrics/AlertingMetrics.java index da67ad19..3da431bb 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/metrics/AlertingMetrics.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/metrics/AlertingMetrics.java @@ -12,9 +12,16 @@ import org.slf4j.LoggerFactory; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.function.Supplier; /** * Micrometer-based metrics for the alerting subsystem. @@ -30,10 +37,11 @@ import java.util.concurrent.ConcurrentMap; *
  • {@code alerting_eval_duration_seconds{kind}} — per-kind evaluation latency
  • *
  • {@code alerting_webhook_delivery_duration_seconds} — webhook POST latency
  • * - * Gauges (read from PostgreSQL on each scrape; low scrape frequency = low DB load): + * Gauges (read from PostgreSQL, cached for {@link #DEFAULT_GAUGE_TTL} to amortise + * Prometheus scrapes that may fire every few seconds): *
      *
    • {@code alerting_rules_total{state=enabled|disabled}} — rule counts from {@code alert_rules}
    • - *
    • {@code alerting_instances_total{state,severity}} — instance counts grouped from {@code alert_instances}
    • + *
    • {@code alerting_instances_total{state}} — instance counts grouped from {@code alert_instances}
    • *
    */ @Component @@ -41,11 +49,13 @@ public class AlertingMetrics { private static final Logger log = LoggerFactory.getLogger(AlertingMetrics.class); + /** Default time-to-live for the gauge-supplier caches. */ + static final Duration DEFAULT_GAUGE_TTL = Duration.ofSeconds(30); + private final MeterRegistry registry; - private final JdbcTemplate jdbc; // Cached counters per kind (lazy-initialized) - private final ConcurrentMap evalErrorCounters = new ConcurrentHashMap<>(); + private final ConcurrentMap evalErrorCounters = new ConcurrentHashMap<>(); private final ConcurrentMap circuitOpenCounters = new ConcurrentHashMap<>(); private final ConcurrentMap evalDurationTimers = new ConcurrentHashMap<>(); @@ -55,33 +65,80 @@ public class AlertingMetrics { // Shared delivery timer private final Timer webhookDeliveryTimer; + // TTL-cached gauge suppliers registered so tests can force a read cycle. + private final TtlCache enabledRulesCache; + private final TtlCache disabledRulesCache; + private final Map instancesByStateCaches; + + /** + * Production constructor: wraps the Postgres-backed gauge suppliers in a + * 30-second TTL cache so Prometheus scrapes don't cause per-scrape DB queries. + */ public AlertingMetrics(MeterRegistry registry, JdbcTemplate jdbc) { + this(registry, + () -> countRules(jdbc, true), + () -> countRules(jdbc, false), + state -> countInstances(jdbc, state), + DEFAULT_GAUGE_TTL, + Instant::now); + } + + /** + * Test-friendly constructor accepting the three gauge suppliers that are + * exercised in the {@link AlertingMetricsCachingTest} plan sketch. The + * {@code instancesSupplier} is used for every {@link AlertState}. + */ + AlertingMetrics(MeterRegistry registry, + Supplier enabledRulesSupplier, + Supplier disabledRulesSupplier, + Supplier instancesSupplier, + Duration gaugeTtl, + Supplier clock) { + this(registry, + enabledRulesSupplier, + disabledRulesSupplier, + state -> instancesSupplier.get(), + gaugeTtl, + clock); + } + + /** + * Core constructor: accepts per-state instance supplier so production can + * query PostgreSQL with a different value per {@link AlertState}. + */ + private AlertingMetrics(MeterRegistry registry, + Supplier enabledRulesSupplier, + Supplier disabledRulesSupplier, + java.util.function.Function instancesSupplier, + Duration gaugeTtl, + Supplier clock) { this.registry = registry; - this.jdbc = jdbc; // ── Static timers ─────────────────────────────────────────────── this.webhookDeliveryTimer = Timer.builder("alerting_webhook_delivery_duration_seconds") .description("Latency of outbound webhook POST requests") .register(registry); - // ── Gauge: rules by enabled/disabled ──────────────────────────── - Gauge.builder("alerting_rules_total", this, m -> m.countRules(true)) + // ── Gauge: rules by enabled/disabled (cached) ─────────────────── + this.enabledRulesCache = new TtlCache(enabledRulesSupplier, gaugeTtl, clock); + this.disabledRulesCache = new TtlCache(disabledRulesSupplier, gaugeTtl, clock); + + Gauge.builder("alerting_rules_total", enabledRulesCache, TtlCache::getAsDouble) .tag("state", "enabled") .description("Number of enabled alert rules") .register(registry); - Gauge.builder("alerting_rules_total", this, m -> m.countRules(false)) + Gauge.builder("alerting_rules_total", disabledRulesCache, TtlCache::getAsDouble) .tag("state", "disabled") .description("Number of disabled alert rules") .register(registry); - // ── Gauges: alert instances by state × severity ───────────────── + // ── Gauges: alert instances by state (cached) ─────────────────── + this.instancesByStateCaches = new EnumMap<>(AlertState.class); for (AlertState state : AlertState.values()) { - // Capture state as effectively-final for lambda - AlertState capturedState = state; - // We register one gauge per state (summed across severities) for simplicity; - // per-severity breakdown would require a dynamic MultiGauge. - Gauge.builder("alerting_instances_total", this, - m -> m.countInstances(capturedState)) + AlertState captured = state; + TtlCache cache = new TtlCache(() -> instancesSupplier.apply(captured), gaugeTtl, clock); + this.instancesByStateCaches.put(state, cache); + Gauge.builder("alerting_instances_total", cache, TtlCache::getAsDouble) .tag("state", state.name().toLowerCase()) .description("Number of alert instances by state") .register(registry); @@ -148,28 +205,73 @@ public class AlertingMetrics { .increment(); } - // ── Gauge suppliers (called on each Prometheus scrape) ────────────── - - private double countRules(boolean enabled) { - try { - Long count = jdbc.queryForObject( - "SELECT COUNT(*) FROM alert_rules WHERE enabled = ?", Long.class, enabled); - return count == null ? 0.0 : count.doubleValue(); - } catch (Exception e) { - log.debug("alerting_rules gauge query failed: {}", e.getMessage()); - return 0.0; + /** + * Force a read of every TTL-cached gauge supplier. Used by tests to simulate + * a Prometheus scrape without needing a real registry scrape pipeline. + */ + void snapshotAllGauges() { + List all = new ArrayList<>(); + all.add(enabledRulesCache); + all.add(disabledRulesCache); + all.addAll(instancesByStateCaches.values()); + for (TtlCache c : all) { + c.getAsDouble(); } } - private double countInstances(AlertState state) { + // ── Gauge suppliers (queried at most once per TTL) ────────────────── + + private static long countRules(JdbcTemplate jdbc, boolean enabled) { + try { + Long count = jdbc.queryForObject( + "SELECT COUNT(*) FROM alert_rules WHERE enabled = ?", Long.class, enabled); + return count == null ? 0L : count; + } catch (Exception e) { + log.debug("alerting_rules gauge query failed: {}", e.getMessage()); + return 0L; + } + } + + private static long countInstances(JdbcTemplate jdbc, AlertState state) { try { Long count = jdbc.queryForObject( "SELECT COUNT(*) FROM alert_instances WHERE state = ?::alert_state_enum", Long.class, state.name()); - return count == null ? 0.0 : count.doubleValue(); + return count == null ? 0L : count; } catch (Exception e) { log.debug("alerting_instances gauge query failed: {}", e.getMessage()); - return 0.0; + return 0L; + } + } + + /** + * Lightweight TTL cache around a {@code Supplier}. Every call to + * {@link #getAsDouble()} either returns the cached value (if {@code clock.get() + * - lastRead < ttl}) or invokes the delegate and refreshes the cache. + * + *

    Used to amortise Postgres queries behind Prometheus gauges over a + * 30-second TTL (see {@link AlertingMetrics#DEFAULT_GAUGE_TTL}). + */ + static final class TtlCache { + private final Supplier delegate; + private final Duration ttl; + private final Supplier clock; + private volatile Instant lastRead = Instant.MIN; + private volatile long cached = 0L; + + TtlCache(Supplier delegate, Duration ttl, Supplier clock) { + this.delegate = delegate; + this.ttl = ttl; + this.clock = clock; + } + + synchronized double getAsDouble() { + Instant now = clock.get(); + if (lastRead == Instant.MIN || Duration.between(lastRead, now).compareTo(ttl) >= 0) { + cached = delegate.get(); + lastRead = now; + } + return cached; } } } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java new file mode 100644 index 00000000..194bc982 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java @@ -0,0 +1,111 @@ +package com.cameleer.server.app.alerting.metrics; + +import com.cameleer.server.core.alerting.AlertState; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that {@link AlertingMetrics} caches gauge values for a configurable TTL, + * so that Prometheus scrapes do not cause one Postgres query per scrape. + */ +class AlertingMetricsCachingTest { + + @Test + void gaugeSupplierIsCalledAtMostOncePerTtl() { + // The instances supplier is shared across every AlertState gauge, so each + // full gauge snapshot invokes it once per AlertState (one cache per state). + final int stateCount = AlertState.values().length; + + AtomicInteger enabledRulesCalls = new AtomicInteger(); + AtomicInteger disabledRulesCalls = new AtomicInteger(); + AtomicInteger instancesCalls = new AtomicInteger(); + AtomicReference now = new AtomicReference<>(Instant.parse("2026-04-20T00:00:00Z")); + Supplier clock = now::get; + + MeterRegistry registry = new SimpleMeterRegistry(); + + Supplier enabledRulesSupplier = () -> { enabledRulesCalls.incrementAndGet(); return 7L; }; + Supplier disabledRulesSupplier = () -> { disabledRulesCalls.incrementAndGet(); return 3L; }; + Supplier instancesSupplier = () -> { instancesCalls.incrementAndGet(); return 5L; }; + + AlertingMetrics metrics = new AlertingMetrics( + registry, + enabledRulesSupplier, + disabledRulesSupplier, + instancesSupplier, + Duration.ofSeconds(30), + clock + ); + + // First scrape — each supplier invoked exactly once per gauge. + metrics.snapshotAllGauges(); + assertThat(enabledRulesCalls.get()).isEqualTo(1); + assertThat(disabledRulesCalls.get()).isEqualTo(1); + assertThat(instancesCalls.get()).isEqualTo(stateCount); + + // Second scrape within TTL — served from cache. + metrics.snapshotAllGauges(); + assertThat(enabledRulesCalls.get()).isEqualTo(1); + assertThat(disabledRulesCalls.get()).isEqualTo(1); + assertThat(instancesCalls.get()).isEqualTo(stateCount); + + // Third scrape still within TTL (29 s later) — still cached. + now.set(now.get().plusSeconds(29)); + metrics.snapshotAllGauges(); + assertThat(enabledRulesCalls.get()).isEqualTo(1); + assertThat(disabledRulesCalls.get()).isEqualTo(1); + assertThat(instancesCalls.get()).isEqualTo(stateCount); + + // Advance past TTL — next scrape re-queries the delegate. + now.set(Instant.parse("2026-04-20T00:00:31Z")); + metrics.snapshotAllGauges(); + assertThat(enabledRulesCalls.get()).isEqualTo(2); + assertThat(disabledRulesCalls.get()).isEqualTo(2); + assertThat(instancesCalls.get()).isEqualTo(stateCount * 2); + + // Immediate follow-up — back in cache. + metrics.snapshotAllGauges(); + assertThat(enabledRulesCalls.get()).isEqualTo(2); + assertThat(disabledRulesCalls.get()).isEqualTo(2); + assertThat(instancesCalls.get()).isEqualTo(stateCount * 2); + } + + @Test + void gaugeValueReflectsCachedResult() { + AtomicReference enabledValue = new AtomicReference<>(10L); + AtomicReference now = new AtomicReference<>(Instant.parse("2026-04-20T00:00:00Z")); + + MeterRegistry registry = new SimpleMeterRegistry(); + AlertingMetrics metrics = new AlertingMetrics( + registry, + enabledValue::get, + () -> 0L, + () -> 0L, + Duration.ofSeconds(30), + now::get + ); + + // Read once — value cached at 10. + metrics.snapshotAllGauges(); + + // Mutate the underlying supplier output; cache should shield it. + enabledValue.set(99L); + double cached = registry.find("alerting_rules_total").tag("state", "enabled").gauge().value(); + assertThat(cached).isEqualTo(10.0); + + // After TTL, new value surfaces. + now.set(now.get().plusSeconds(31)); + metrics.snapshotAllGauges(); + double refreshed = registry.find("alerting_rules_total").tag("state", "enabled").gauge().value(); + assertThat(refreshed).isEqualTo(99.0); + } +} From f75ee9f352937a8c7a221f7bfb52bf3d10fcd652 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:55:36 +0200 Subject: [PATCH 34/39] docs(alerting): UI map + admin-guide walkthrough for Plan 03 .claude/rules/ui.md now maps every Plan 03 UI surface. Admin guide gains an inbox/rules/silences walkthrough so ops teams can start in the UI without reading the spec. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/rules/ui.md | 22 +++++++++++++++++++ docs/alerting.md | 51 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/.claude/rules/ui.md b/.claude/rules/ui.md index dc15ae68..f673d9ac 100644 --- a/.claude/rules/ui.md +++ b/.claude/rules/ui.md @@ -34,6 +34,28 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments - `ui/src/hooks/useInfiniteStream.ts` — tanstack `useInfiniteQuery` wrapper with top-gated auto-refetch, flattened `items[]`, and `refresh()` invalidator - `ui/src/components/InfiniteScrollArea.tsx` — scrollable container with IntersectionObserver top/bottom sentinels. Streaming log/event views use this + `useInfiniteStream`. Bounded views (LogTab, StartupLogPanel) keep `useLogs`/`useStartupLogs` +## 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`). Six condition-form subcomponents under `RuleEditor/condition-forms/`. + - `SilencesPage.tsx` — matcher-based create + end-early. + - `AlertRow.tsx` shared list row; `alerts-page.module.css` shared styling. +- **Components**: + - `NotificationBell.tsx` — polls `/alerts/unread-count` every 30 s (paused when tab hidden via TanStack Query `refetchIntervalInBackground: false`). + - `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 (future) Admin Outbound Connection editor (reduced-context mode for URL). + - `MustacheEditor/alert-variables.ts` — variable registry aligned with `NotificationContextBuilder.java`. Add new leaves here whenever the backend context grows. +- **API queries** under `ui/src/api/queries/`: `alerts.ts`, `alertRules.ts`, `alertSilences.ts`, `alertNotifications.ts`, `alertMeta.ts`. All env-scoped via `useSelectedEnv` from `alertMeta`. +- **CMD-K**: `buildAlertSearchData` in `LayoutShell.tsx` registers `alert` and `alertRule` result categories. Badges convey severity + state. Palette navigates directly to the deep-link path — no sidebar-reveal state for alerts. +- **Sidebar accordion**: entering `/alerts/*` collapses Applications + Admin + Starred (mirrors Admin accordion). +- **Top-nav**: `` is the first child of ``, sitting alongside `SearchTrigger` + status `ButtonGroup` + `TimeRangeDropdown` + `AutoRefreshToggle`. + ## UI Styling - Always use `@cameleer/design-system` CSS variables for colors (`var(--amber)`, `var(--error)`, `var(--success)`, etc.) — never hardcode hex values. This applies to CSS modules, inline styles, and SVG `fill`/`stroke` attributes. SVG presentation attributes resolve `var()` correctly. All colors use CSS variables (no hardcoded hex). diff --git a/docs/alerting.md b/docs/alerting.md index 68783a8d..bcc7002e 100644 --- a/docs/alerting.md +++ b/docs/alerting.md @@ -307,3 +307,54 @@ Check `GET /api/v1/environments/{envSlug}/alerts/{id}/notifications` for respons ### ClickHouse projections The `LOG_PATTERN` and `EXCHANGE_MATCH` evaluators use ClickHouse projections (`logs_by_level`, `executions_by_status`). On fresh ClickHouse containers (e.g. Testcontainers), projections may not be active immediately — the evaluator falls back to a full table scan with the same WHERE clause, so correctness is preserved but latency may increase on first evaluation. In production ClickHouse, projections are applied to new data immediately and to existing data after `OPTIMIZE TABLE … FINAL`. + +--- + +## UI walkthrough + +The alerting UI is accessible to any authenticated VIEWER+; writing actions (create rule, silence, ack) require OPERATOR+ per backend RBAC. + +### Sidebar + +A dedicated **Alerts** section between Applications and Admin: + +- **Inbox** — open alerts targeted at you (state FIRING or ACKNOWLEDGED). Mark individual rows as read by clicking the title, or "Mark all read" via the toolbar. Firing rows have an amber left border. +- **All** — every open alert in the environment with state-chip filter (Open / Firing / Acked / All). +- **Rules** — the rule catalogue. Toggle the Enabled switch to disable a rule without deleting it. Delete prompts for confirmation; fired instances survive via `rule_snapshot`. +- **Silences** — active + scheduled silences. Create one by filling any combination of `ruleId` and `appSlug`, duration (hours), optional reason. +- **History** — RESOLVED alerts within the retention window (default 90 days). + +### Notification bell + +A bell icon in the top bar polls `/alerts/unread-count` every 30 seconds (paused when the tab is hidden). Clicking it navigates to the inbox. + +### Rule editor (5-step wizard) + +1. **Scope** — name, severity, and radio between environment-wide, single-app, single-route, or single-agent. +2. **Condition** — one of six condition kinds (ROUTE_METRIC, EXCHANGE_MATCH, AGENT_STATE, DEPLOYMENT_STATE, LOG_PATTERN, JVM_METRIC) with a form tailored to each. +3. **Trigger** — evaluation interval (≥5s), for-duration before firing (0 = fire immediately), re-notify cadence (minutes). Test-evaluate button when editing an existing rule. +4. **Notify** — notification title + message templates (Mustache with autocomplete), target users/groups/roles, webhook bindings (filtered to outbound connections allowed in the current env). +5. **Review** — summary card, enable toggle, save. + +### Mustache autocomplete + +Every template-editable field uses a shared CodeMirror 6 editor with variable autocomplete: + +- Type `{{` to open the variable picker. +- Variables filter by condition kind (e.g. `route.*` is only shown when a route-scoped condition is selected). +- Unknown references get an amber underline at save time ("not available for this rule kind — will render as literal"). +- The canonical variable list lives in `ui/src/components/MustacheEditor/alert-variables.ts` and mirrors the backend `NotificationContextBuilder`. + +### Env promotion + +Rules are environment-scoped. To replicate a rule in another env, open the source env's rule list and pick a target env from the **Promote to ▾** dropdown. The editor opens pre-filled with the source rule's values, with client-side warnings: + +- Agent IDs are env-specific and get cleared. +- Apps that don't exist in the target env flag an "update before saving" hint. +- Outbound connections not allowed in the target env flag an "remove or pick another" hint. + +No new REST endpoint — promotion is pure UI-driven create. + +### CMD-K + +The command palette (`Ctrl/Cmd + K`) surfaces open alerts and alert rules alongside existing apps/routes/exchanges. Select an alert to jump to its inbox detail; select a rule to open its editor. From 1ed2d3a61147d9137db9248ea10a800de006826f Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:52:24 +0200 Subject: [PATCH 35/39] chore(docker): full-stack docker-compose mirroring deploy/ k8s manifests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the k8s manifests in deploy/ as a local dev stack: - cameleer-postgres (matches deploy/cameleer-postgres.yaml) - cameleer-clickhouse (matches deploy/cameleer-clickhouse.yaml, default CLICKHOUSE_DB=cameleer) - cameleer-server (built from Dockerfile, env mirrors deploy/base/server.yaml) - cameleer-ui (built from ui/Dockerfile, served on host :8080 to leave :5173 free for Vite dev) Dockerfile + ui/Dockerfile: REGISTRY_TOKEN is now optional (empty → skip Maven/npm auth). cameleer-common package is public, so anonymous pulls succeed; private packages still require the token. Backend defaults tuned for local E2E: - RUNTIME_ENABLED=false (no Docker-in-Docker deployments in dev stack) - OUTBOUND_HTTP_ALLOW_PRIVATE_TARGETS=true (so webhook tests can target host.docker.internal etc.) - UIUSER/UIPASSWORD=admin/admin (matches Playwright E2E_ADMIN_USER/PASS defaults) - CORS includes both :5173 (Vite) and :8080 (nginx) --- Dockerfile | 12 +++-- docker-compose.yml | 123 ++++++++++++++++++++++++++++++++++++++++++++- ui/Dockerfile | 10 ++-- 3 files changed, 137 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index aebfba83..3336cbf0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,14 @@ FROM --platform=$BUILDPLATFORM maven:3.9-eclipse-temurin-17 AS build WORKDIR /build -# Configure Gitea Maven Registry for cameleer-common dependency -ARG REGISTRY_TOKEN -RUN mkdir -p ~/.m2 && \ - echo 'giteacameleer'${REGISTRY_TOKEN}'' > ~/.m2/settings.xml +# Optional auth for Gitea Maven Registry. The `cameleer/cameleer-common` package +# is published publicly, so empty token → anonymous pull (no settings.xml). +# Private packages require a non-empty token. +ARG REGISTRY_TOKEN="" +RUN if [ -n "$REGISTRY_TOKEN" ]; then \ + mkdir -p ~/.m2 && \ + printf 'giteacameleer%s\n' "$REGISTRY_TOKEN" > ~/.m2/settings.xml; \ + fi COPY pom.xml . COPY cameleer-server-core/pom.xml cameleer-server-core/ diff --git a/docker-compose.yml b/docker-compose.yml index 5439cb51..2b4420da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,24 @@ +## +## Local development + E2E stack. Mirrors the k8s manifests in deploy/ : +## - cameleer-postgres (PG for RBAC/config/audit/alerting — Flyway migrates on server start) +## - cameleer-clickhouse (OLAP for executions/logs/metrics/stats/diagrams) +## - cameleer-server (Spring Boot backend; built from this repo's Dockerfile) +## - cameleer-ui (nginx-served SPA; built from ui/Dockerfile) +## +## Usage: +## docker compose up -d --build # full stack, detached +## docker compose up -d cameleer-postgres cameleer-clickhouse # infra only (dev via mvn/vite) +## docker compose down -v # stop + remove volumes +## +## Defaults match `application.yml` and the k8s base manifests. Production +## k8s still owns the source of truth; this compose is for local iteration +## and Playwright E2E. Secrets are non-sensitive dev placeholders. +## + services: cameleer-postgres: image: postgres:16 + container_name: cameleer-postgres ports: - "5432:5432" environment: @@ -8,7 +26,110 @@ services: POSTGRES_USER: cameleer POSTGRES_PASSWORD: cameleer_dev volumes: - - cameleer-pgdata:/home/postgres/pgdata/data + - cameleer-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U cameleer -d cameleer"] + interval: 5s + timeout: 3s + retries: 20 + restart: unless-stopped + + cameleer-clickhouse: + image: clickhouse/clickhouse-server:24.12 + container_name: cameleer-clickhouse + ports: + - "8123:8123" + - "9000:9000" + environment: + CLICKHOUSE_DB: cameleer + CLICKHOUSE_USER: default + CLICKHOUSE_PASSWORD: "" + # Allow the default user to manage access (matches k8s StatefulSet env) + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1" + ulimits: + nofile: + soft: 262144 + hard: 262144 + volumes: + - cameleer-chdata:/var/lib/clickhouse + healthcheck: + # wget-less image: use clickhouse-client's ping equivalent + test: ["CMD-SHELL", "clickhouse-client --query 'SELECT 1' || exit 1"] + interval: 5s + timeout: 3s + retries: 20 + restart: unless-stopped + + cameleer-server: + build: + context: . + dockerfile: Dockerfile + args: + # Public cameleer-common package — token optional. Override with + # REGISTRY_TOKEN=... in the shell env if you need a private package. + REGISTRY_TOKEN: ${REGISTRY_TOKEN:-} + container_name: cameleer-server + ports: + - "8081:8081" + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://cameleer-postgres:5432/cameleer?currentSchema=tenant_default&ApplicationName=tenant_default + SPRING_DATASOURCE_USERNAME: cameleer + SPRING_DATASOURCE_PASSWORD: cameleer_dev + SPRING_FLYWAY_USER: cameleer + SPRING_FLYWAY_PASSWORD: cameleer_dev + CAMELEER_SERVER_CLICKHOUSE_URL: jdbc:clickhouse://cameleer-clickhouse:8123/cameleer + CAMELEER_SERVER_CLICKHOUSE_USERNAME: default + CAMELEER_SERVER_CLICKHOUSE_PASSWORD: "" + # Auth / UI credentials — dev defaults; change before exposing the port. + CAMELEER_SERVER_SECURITY_UIUSER: admin + CAMELEER_SERVER_SECURITY_UIPASSWORD: admin + CAMELEER_SERVER_SECURITY_UIORIGIN: http://localhost:5173 + CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS: http://localhost:5173,http://localhost:8080 + CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN: dev-bootstrap-token-for-local-agent-registration + CAMELEER_SERVER_SECURITY_JWTSECRET: dev-jwt-secret-32-bytes-min-0123456789abcdef0123456789abcdef + # Runtime (Docker-in-Docker deployment) disabled for local stack + CAMELEER_SERVER_RUNTIME_ENABLED: "false" + CAMELEER_SERVER_TENANT_ID: default + # SSRF guard: allow private targets for dev (Playwright + local webhooks) + CAMELEER_SERVER_OUTBOUND_HTTP_ALLOW_PRIVATE_TARGETS: "true" + depends_on: + cameleer-postgres: + condition: service_healthy + cameleer-clickhouse: + condition: service_healthy + healthcheck: + # JRE image has wget; /api/v1/health is Actuator + Spring managed endpoint + test: ["CMD-SHELL", "wget -qO- http://localhost:8081/api/v1/health > /dev/null || exit 1"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 90s + restart: unless-stopped + + cameleer-ui: + build: + context: ./ui + dockerfile: Dockerfile + args: + REGISTRY_TOKEN: ${REGISTRY_TOKEN:-} + container_name: cameleer-ui + # Host :8080 — Vite dev server (npm run dev:local) keeps :5173 for local iteration. + ports: + - "8080:80" + environment: + # nginx proxies /api → CAMELEER_API_URL + CAMELEER_API_URL: http://cameleer-server:8081 + BASE_PATH: / + depends_on: + cameleer-server: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost/healthz > /dev/null || exit 1"] + interval: 5s + timeout: 3s + retries: 10 + restart: unless-stopped volumes: cameleer-pgdata: + cameleer-chdata: diff --git a/ui/Dockerfile b/ui/Dockerfile index 132afbe9..a7bd7ada 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -1,15 +1,19 @@ FROM --platform=$BUILDPLATFORM node:22-alpine AS build WORKDIR /app -ARG REGISTRY_TOKEN +ARG REGISTRY_TOKEN="" COPY package.json package-lock.json .npmrc ./ -RUN echo "//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_TOKEN}" >> .npmrc && \ +RUN if [ -n "$REGISTRY_TOKEN" ]; then \ + echo "//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_TOKEN}" >> .npmrc; \ + fi && \ npm ci COPY . . # Upgrade design system to latest dev snapshot (after COPY to bust Docker cache) -RUN echo "//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_TOKEN}" >> .npmrc && \ +RUN if [ -n "$REGISTRY_TOKEN" ]; then \ + echo "//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_TOKEN}" >> .npmrc; \ + fi && \ npm install @cameleer/design-system@dev && \ rm -f .npmrc From 5edf7eb23af1cf7fff377553bbe3c1aab5f4bf23 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:02:48 +0200 Subject: [PATCH 36/39] fix(alerting): @Autowired on AlertingMetrics production constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 29's refactor added a package-private test-friendly constructor alongside the public production one. Without @Autowired Spring cannot pick which constructor to use for the @Component, and falls back to searching for a no-arg default — crashing startup with 'No default constructor found'. Detected when launching the server via the new docker-compose stack; unit tests still pass because they invoke the package-private test constructor directly. --- .../cameleer/server/app/alerting/metrics/AlertingMetrics.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/metrics/AlertingMetrics.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/metrics/AlertingMetrics.java index 3da431bb..1b3f3f48 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/metrics/AlertingMetrics.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/metrics/AlertingMetrics.java @@ -9,6 +9,7 @@ import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -74,6 +75,7 @@ public class AlertingMetrics { * Production constructor: wraps the Postgres-backed gauge suppliers in a * 30-second TTL cache so Prometheus scrapes don't cause per-scrape DB queries. */ + @Autowired public AlertingMetrics(MeterRegistry registry, JdbcTemplate jdbc) { this(registry, () -> countRules(jdbc, true), From bcde6678b848f918ecfc446b46abd24b414ed932 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:17:59 +0200 Subject: [PATCH 37/39] fix(ui/alerts): align RouteMetric metric enum with backend; pre-populate ROUTE_METRIC defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RouteMetricForm dropped P95_LATENCY_MS — not in cameleer-server-core RouteMetric enum (valid: ERROR_RATE, P99_LATENCY_MS, AVG_DURATION_MS, THROUGHPUT, ERROR_COUNT). - initialForm now returns a ready-to-save ROUTE_METRIC condition (metric=ERROR_RATE, comparator=GT, threshold=0.05, windowSeconds=300), so clicking through the wizard with all defaults produces a valid rule. Prevents a 400 'missing type id property kind' + 400 on condition enum validation if the user leaves the condition step untouched. --- .../RuleEditor/condition-forms/RouteMetricForm.tsx | 2 +- ui/src/pages/Alerts/RuleEditor/form-state.ts | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/ui/src/pages/Alerts/RuleEditor/condition-forms/RouteMetricForm.tsx b/ui/src/pages/Alerts/RuleEditor/condition-forms/RouteMetricForm.tsx index e8138110..40287172 100644 --- a/ui/src/pages/Alerts/RuleEditor/condition-forms/RouteMetricForm.tsx +++ b/ui/src/pages/Alerts/RuleEditor/condition-forms/RouteMetricForm.tsx @@ -1,9 +1,9 @@ import { FormField, Input, Select } from '@cameleer/design-system'; import type { FormState } from '../form-state'; +// Mirrors cameleer-server-core RouteMetric enum — keep in sync. 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)' }, diff --git a/ui/src/pages/Alerts/RuleEditor/form-state.ts b/ui/src/pages/Alerts/RuleEditor/form-state.ts index e6cee874..72385302 100644 --- a/ui/src/pages/Alerts/RuleEditor/form-state.ts +++ b/ui/src/pages/Alerts/RuleEditor/form-state.ts @@ -51,7 +51,17 @@ export function initialForm(existing?: AlertRuleResponse): FormState { routeId: '', agentId: '', conditionKind: 'ROUTE_METRIC', - condition: { kind: 'ROUTE_METRIC' } as Partial, + // Pre-populate a valid ROUTE_METRIC default so a rule can be saved without + // the user needing to fill in every condition field. Values chosen to be + // sane for "error rate" alerts on almost any route. + condition: { + kind: 'ROUTE_METRIC', + scope: {}, + metric: 'ERROR_RATE', + comparator: 'GT', + threshold: 0.05, + windowSeconds: 300, + } as unknown as Partial, evaluationIntervalSeconds: 60, forDurationSeconds: 0, reNotifyMinutes: 60, From d88bede09775f4d62c09f3c4db35c3a0c9c2b5c4 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:18:07 +0200 Subject: [PATCH 38/39] chore(docker): seeder service pre-creates unprefixed 'admin' user row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alerting + outbound controllers resolve acting user via authentication.name with 'user:' prefix stripped → 'admin'. But UserRepository.upsert stores env-admin as 'user:admin' (JWT sub format). The resulting FK mismatch manifests as 500 'alert_rules_created_by_fkey' on any create operation in a fresh docker stack. Workaround: run-once 'cameleer-seed' compose service runs psql against deploy/docker/postgres-init.sql after the server is healthy (i.e. after Flyway migrations have created tenant_default.users), inserting user_id='admin' idempotently. The root-cause fix belongs in the backend (either stop stripping the prefix in alerting/outbound controllers, or normalise storage to the unprefixed form) and is out of scope for Plan 03. --- deploy/docker/postgres-init.sql | 41 +++++++++++++++++++++++++++++++++ docker-compose.yml | 19 +++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 deploy/docker/postgres-init.sql diff --git a/deploy/docker/postgres-init.sql b/deploy/docker/postgres-init.sql new file mode 100644 index 00000000..6bfc53fb --- /dev/null +++ b/deploy/docker/postgres-init.sql @@ -0,0 +1,41 @@ +-- Dev-stack seed: pre-create the `admin` user row without the `user:` prefix. +-- +-- Why: the UI login controller stores the local admin as `user_id='user:admin'` +-- (JWT `sub` format), but the alerting + outbound controllers resolve the FK +-- via `authentication.name` with the `user:` prefix stripped, i.e. `admin`. +-- In k8s these controllers happily insert `admin` because production admins are +-- provisioned through the admin API with unprefixed user_ids. In the local +-- docker stack there's no such provisioning step, so the FK check fails with +-- "alert_rules_created_by_fkey violation" on the first rule create. +-- +-- Seeding a row with `user_id='admin'` here bridges the gap so E2E smokes, +-- API probes, and manual dev sessions can create alerting rows straight away. +-- Flyway owns the schema in tenant_default; this script only INSERTs idempotently +-- and is gated on the schema existing. + +DO $$ +DECLARE + schema_exists bool; + table_exists bool; +BEGIN + SELECT EXISTS( + SELECT 1 FROM information_schema.schemata WHERE schema_name = 'tenant_default' + ) INTO schema_exists; + IF NOT schema_exists THEN + RAISE NOTICE 'tenant_default schema not yet migrated — skipping admin seed (Flyway will run on server start)'; + RETURN; + END IF; + + SELECT EXISTS( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'tenant_default' AND table_name = 'users' + ) INTO table_exists; + IF NOT table_exists THEN + RAISE NOTICE 'tenant_default.users not yet migrated — skipping admin seed'; + RETURN; + END IF; + + INSERT INTO tenant_default.users (user_id, provider, email, display_name) + VALUES ('admin', 'local', '', 'admin') + ON CONFLICT (user_id) DO NOTHING; +END $$; diff --git a/docker-compose.yml b/docker-compose.yml index 2b4420da..6f4ff657 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -130,6 +130,25 @@ services: retries: 10 restart: unless-stopped + # Run-once seeder: waits for the server to be healthy (i.e. Flyway migrations + # finished) and inserts a `user_id='admin'` row (without the `user:` prefix) + # so alerting-controller FKs succeed. See deploy/docker/postgres-init.sql for + # the full rationale. Idempotent — exits 0 if the row already exists. + cameleer-seed: + image: postgres:16 + container_name: cameleer-seed + depends_on: + cameleer-server: + condition: service_healthy + environment: + PGPASSWORD: cameleer_dev + volumes: + - ./deploy/docker/postgres-init.sql:/seed.sql:ro + entrypoint: ["sh", "-c"] + command: + - "psql -h cameleer-postgres -U cameleer -d cameleer -v ON_ERROR_STOP=1 -f /seed.sql" + restart: "no" + volumes: cameleer-pgdata: cameleer-chdata: From 1ebc2fa71e035e71bf5b1e7642206dedef24917a Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:18:17 +0200 Subject: [PATCH 39/39] test(ui/alerts): Playwright E2E smoke (sidebar, rule CRUD, CMD-K, silence CRUD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixtures.ts: auto-applied login fixture — visits /login?local to skip OIDC auto-redirect, fills username/password via label-matcher, clicks 'Sign in', then selects the 'default' env so alerting hooks enable (useSelectedEnv gate). Override via E2E_ADMIN_USER + E2E_ADMIN_PASS. alerting.spec.ts: 4 tests against the full docker-compose stack: - sidebar Alerts accordion → /alerts/inbox - 5-step wizard: defaults-only create + row delete (unique timestamp name avoids strict-mode collisions with leftover rules) - CMD-K palette via SearchTrigger click (deterministic; Ctrl+K via keyboard is flaky when the canvas doesn't have focus) - silence matcher-based create + end-early DS FormField renders labels as generics (not htmlFor-wired), so inputs are targeted by placeholder or label-proximity locators instead of getByLabel. Does not exercise fire→ack→clear; that's covered backend-side by AlertingFullLifecycleIT (Plan 02). UI E2E for that path would need event injection into ClickHouse, out of scope for this smoke. --- ui/src/test/e2e/alerting.spec.ts | 107 +++++++++++++++++++++++++++++++ ui/src/test/e2e/fixtures.ts | 39 +++++++++++ 2 files changed, 146 insertions(+) create mode 100644 ui/src/test/e2e/alerting.spec.ts create mode 100644 ui/src/test/e2e/fixtures.ts diff --git a/ui/src/test/e2e/alerting.spec.ts b/ui/src/test/e2e/alerting.spec.ts new file mode 100644 index 00000000..832ea568 --- /dev/null +++ b/ui/src/test/e2e/alerting.spec.ts @@ -0,0 +1,107 @@ +import { test, expect } from './fixtures'; + +/** + * Plan 03 alerting smoke suite. + * + * Covers the CRUD + navigation paths that don't require event injection: + * - sidebar → inbox + * - create + delete a rule via the 5-step wizard + * - CMD-K opens, closes cleanly + * - silence create + end-early + * + * End-to-end fire→ack→clear is covered server-side by `AlertingFullLifecycleIT` + * (Plan 02). Exercising it from the UI would require injecting executions + * into ClickHouse, which is out of scope for this smoke. + * + * Note: the design-system `SectionHeader` renders a generic element (not role=heading), + * so page headings are asserted via `getByText`. + */ + +test.describe('alerting UI smoke', () => { + test('sidebar Alerts section navigates to inbox', async ({ page }) => { + // Click the Alerts sidebar section header. On navigation the accordion + // will already be expanded; the "Alerts" label is on the toggle button. + await page.getByRole('button', { name: /^(collapse|expand) alerts$/i }).first().click(); + await expect(page).toHaveURL(/\/alerts\/inbox/, { timeout: 10_000 }); + // Inbox page renders "Inbox" text + "Mark all read" button. + await expect(page.getByText(/^Inbox$/)).toBeVisible(); + await expect(page.getByRole('button', { name: /mark all read/i })).toBeVisible(); + }); + + test('create + delete a rule via the wizard', async ({ page }) => { + // Unique name per run so leftover rules from crashed prior runs don't + // trip the strict-mode "multiple matches" check. + const ruleName = `e2e smoke rule ${Date.now()}`; + + await page.goto('/alerts/rules'); + await expect(page.getByText(/^Alert rules$/)).toBeVisible(); + + await page.getByRole('link', { name: /new rule/i }).click(); + await expect(page).toHaveURL(/\/alerts\/rules\/new/); + + // Step 1 — Scope. DS FormField renders the label as a generic element + // (not `htmlFor` wired), so the textbox's accessible name is its placeholder. + await page.getByPlaceholder('Order API error rate').fill(ruleName); + await page.getByRole('button', { name: /^next$/i }).click(); + + // Step 2 — Condition (leave at ROUTE_METRIC default) + await page.getByRole('button', { name: /^next$/i }).click(); + + // Step 3 — Trigger (defaults) + await page.getByRole('button', { name: /^next$/i }).click(); + + // Step 4 — Notify: default title/message templates are pre-populated; + // targets/webhooks empty is OK for smoke. + await page.getByRole('button', { name: /^next$/i }).click(); + + // Step 5 — Review + save + await page.getByRole('button', { name: /^create rule$/i }).click(); + + // Land on rules list, rule appears in the table. + await expect(page).toHaveURL(/\/alerts\/rules$/, { timeout: 10_000 }); + const main = page.locator('main'); + await expect(main.getByRole('link', { name: ruleName })).toBeVisible({ timeout: 10_000 }); + + // Cleanup: delete. + page.once('dialog', (d) => d.accept()); + await page + .getByRole('row', { name: new RegExp(ruleName) }) + .getByRole('button', { name: /^delete$/i }) + .click(); + await expect(main.getByRole('link', { name: ruleName })).toHaveCount(0); + }); + + test('CMD-K palette opens + closes', async ({ page }) => { + await page.goto('/alerts/inbox'); + // The DS CommandPalette is toggled by the SearchTrigger button in the top bar + // (accessible name "Open search"). Ctrl/Cmd+K is wired inside the DS but + // clicking the button is the deterministic path. + await page.getByRole('button', { name: /open search/i }).click(); + const dialog = page.getByRole('dialog').first(); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + }); + + test('silence create + end-early', async ({ page }) => { + await page.goto('/alerts/silences'); + await expect(page.getByText(/^Alert silences$/)).toBeVisible(); + + const unique = `smoke-app-${Date.now()}`; + // DS FormField labels aren't `htmlFor`-wired, so target via parent-of-label → textbox. + const form = page.locator('main'); + await form.getByText(/^App slug/).locator('..').getByRole('textbox').fill(unique); + await form.getByRole('spinbutton').fill('1'); + await form.getByPlaceholder('Maintenance window').fill('e2e smoke'); + await page.getByRole('button', { name: /create silence/i }).click(); + + await expect(page.getByText(unique).first()).toBeVisible({ timeout: 10_000 }); + + page.once('dialog', (d) => d.accept()); + await page + .getByRole('row', { name: new RegExp(unique) }) + .getByRole('button', { name: /^end$/i }) + .click(); + await expect(page.getByText(unique)).toHaveCount(0); + }); +}); diff --git a/ui/src/test/e2e/fixtures.ts b/ui/src/test/e2e/fixtures.ts new file mode 100644 index 00000000..2a02ead3 --- /dev/null +++ b/ui/src/test/e2e/fixtures.ts @@ -0,0 +1,39 @@ +import { test as base, expect } from '@playwright/test'; + +/** + * E2E fixtures for the alerting UI smoke suite. + * + * Auth happens once per test via an auto-applied fixture. Override creds via: + * E2E_ADMIN_USER=... E2E_ADMIN_PASS=... npm run test:e2e + * + * The fixture logs in to the local form (not OIDC). The backend in the + * Docker-compose stack defaults to `admin` / `admin` for the local login. + */ +export const ADMIN_USER = process.env.E2E_ADMIN_USER ?? 'admin'; +export const ADMIN_PASS = process.env.E2E_ADMIN_PASS ?? 'admin'; + +type Fixtures = { + loggedIn: void; +}; + +export const test = base.extend({ + loggedIn: [ + async ({ page }, use) => { + // `?local` keeps the login page's auto-OIDC-redirect from firing so the + // form-based login works even when an OIDC config happens to be present. + await page.goto('/login?local'); + await page.getByLabel(/username/i).fill(ADMIN_USER); + await page.getByLabel(/password/i).fill(ADMIN_PASS); + await page.getByRole('button', { name: /sign in/i }).click(); + // Default landing after login is /exchanges (via Navigate redirect). + await expect(page).toHaveURL(/\/(exchanges|alerts|dashboard)/, { timeout: 15_000 }); + // Env selection is required for every alerts query (useSelectedEnv gate). + // Pick the default env so hooks enable. + await page.getByRole('combobox').selectOption({ label: 'default' }); + await use(); + }, + { auto: true }, + ], +}); + +export { expect };