diff --git a/docs/superpowers/plans/2026-04-21-alerts-design-system-alignment.md b/docs/superpowers/plans/2026-04-21-alerts-design-system-alignment.md new file mode 100644 index 00000000..e1ae29c5 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-alerts-design-system-alignment.md @@ -0,0 +1,1906 @@ +# Alerts design-system alignment — 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:** Migrate all pages under `/alerts` to `@cameleer/design-system` components and CSS tokens, matching the visual and structural conventions used by Admin/Audit/Apps pages. + +**Architecture:** Unified `DataTable` shell for Inbox/All/History with expandable rows; `DataTable + Dropdown + ConfirmDialog` for Rules list; `FormField` grid + `DataTable` for Silences; DS `Alert` for wizard info/warning banners. Undefined CSS variables (`--bg`, `--fg`, `--muted`, `--accent`) replaced with DS tokens (`--bg-surface`, `--text-primary`, etc.). + +**Tech Stack:** React 19, TypeScript, `@cameleer/design-system` 0.1.56, TanStack Query v5, Vitest, Playwright. + +--- + +## File Structure + +**New files:** +- `ui/src/pages/Alerts/time-utils.ts` — pure function `formatRelativeTime(iso, now?)`. +- `ui/src/pages/Alerts/time-utils.test.ts` — Vitest unit tests. +- `ui/src/pages/Alerts/severity-utils.ts` — pure function `severityToAccent(severity)`. +- `ui/src/pages/Alerts/severity-utils.test.ts` — Vitest unit tests. +- `ui/src/pages/Alerts/alert-expanded.tsx` — shared `expandedContent` renderer for `DataTable`. + +**Rewritten files:** +- `ui/src/pages/Alerts/InboxPage.tsx` +- `ui/src/pages/Alerts/AllAlertsPage.tsx` +- `ui/src/pages/Alerts/HistoryPage.tsx` +- `ui/src/pages/Alerts/RulesListPage.tsx` +- `ui/src/pages/Alerts/SilencesPage.tsx` +- `ui/src/pages/Alerts/alerts-page.module.css` (slimmed) +- `ui/src/pages/Alerts/RuleEditor/wizard.module.css` (token replacement) + +**Modified files:** +- `ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx` — banners become DS `Alert`s; step body wraps in `sectionStyles.section`. +- `ui/src/test/e2e/alerting.spec.ts` — adapt selectors for `ConfirmDialog` (replaces `dialog.accept()` pattern). + +**Deleted files:** +- `ui/src/pages/Alerts/AlertRow.tsx` + +**Key design-system contracts discovered during planning:** +- Severity enum is **3-value**: `CRITICAL | WARNING | INFO` (confirmed in `SeverityBadge.tsx`). +- `DataTable` `rowAccent` prop returns `'error' | 'warning' | undefined` — no `'info'`. INFO severity → `undefined` (no row tint). +- `DataTable` requires row type `extends { id: string }` — `AlertDto` already satisfies this. +- `ButtonGroup` uses `value: Set` (multi-select). For single-select state filter, use DS **`SegmentedTabs`** (`value: string`, `onChange(value: string)`) — cleaner fit. +- `ConfirmDialog` required props: `open`, `onClose`, `onConfirm`, `message`, `confirmText`. +- `SectionHeader` has an `action` prop — preferred over inline-flex toolbars. + +--- + +## Pre-flight + +### Task 0: Baseline verification + +Run once before starting to confirm the working tree builds and existing tests pass. No code changes. + +- [ ] **Step 1: Confirm clean working tree** + +```bash +git status --short +``` +Expected: only untracked files left over from earlier sessions (`runtime-*.png`, `ui/playwright.config.js`, `ui/vitest.config.js`). No staged/unstaged edits to `ui/src/**`. + +- [ ] **Step 2: Install / refresh UI deps** + +```bash +cd ui && npm install +``` +Expected: exits 0. `@cameleer/design-system@0.1.56` in `node_modules`. + +- [ ] **Step 3: Run UI unit tests baseline** + +```bash +cd ui && npm run test -- --run +``` +Expected: all green. Record count — any new task that adds tests should raise this count. + +- [ ] **Step 4: Run Maven compile baseline (sanity)** + +```bash +mvn -pl cameleer-server-app -am compile -q +``` +Expected: exits 0. + +- [ ] **Step 5: Inspect DS tokens** + +```bash +grep -oE "\--[a-z][a-z0-9-]+" ui/node_modules/@cameleer/design-system/dist/style.css | sort -u > /tmp/ds-tokens.txt +head -20 /tmp/ds-tokens.txt +``` +Expected output includes: `--amber`, `--bg-surface`, `--border-subtle`, `--error`, `--radius-lg`, `--shadow-card`, `--space-md`, `--text-muted`, `--text-primary`, `--warning`. **Do not** expect `--bg`, `--fg`, `--muted`, `--accent` (these are the undefined tokens we're replacing). + +--- + +## Helpers (TDD) + +### Task 1: `severity-utils.ts` — severity-to-accent helper + +**Files:** +- Create: `ui/src/pages/Alerts/severity-utils.ts` +- Test: `ui/src/pages/Alerts/severity-utils.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `ui/src/pages/Alerts/severity-utils.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { severityToAccent } from './severity-utils'; + +describe('severityToAccent', () => { + it('maps CRITICAL → error', () => { + expect(severityToAccent('CRITICAL')).toBe('error'); + }); + + it('maps WARNING → warning', () => { + expect(severityToAccent('WARNING')).toBe('warning'); + }); + + it('maps INFO → undefined (no row tint)', () => { + expect(severityToAccent('INFO')).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd ui && npm run test -- --run src/pages/Alerts/severity-utils.test.ts +``` +Expected: FAIL with `Cannot find module './severity-utils'`. + +- [ ] **Step 3: Implement the helper** + +Create `ui/src/pages/Alerts/severity-utils.ts`: + +```typescript +import type { AlertDto } from '../../api/queries/alerts'; + +type Severity = NonNullable; +export type RowAccent = 'error' | 'warning' | undefined; + +export function severityToAccent(severity: Severity): RowAccent { + switch (severity) { + case 'CRITICAL': return 'error'; + case 'WARNING': return 'warning'; + case 'INFO': return undefined; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +cd ui && npm run test -- --run src/pages/Alerts/severity-utils.test.ts +``` +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/pages/Alerts/severity-utils.ts ui/src/pages/Alerts/severity-utils.test.ts +git commit -m "$(cat <<'EOF' +feat(alerts/ui): add severityToAccent helper for DataTable rowAccent + +Pure function mapping the 3-value AlertDto.severity enum to the 2-value +DataTable rowAccent prop. INFO maps to undefined (no tint) because the +DS DataTable rowAccent only supports error|warning. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 2: `time-utils.ts` — relative-time helper + +**Files:** +- Create: `ui/src/pages/Alerts/time-utils.ts` +- Test: `ui/src/pages/Alerts/time-utils.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `ui/src/pages/Alerts/time-utils.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { formatRelativeTime } from './time-utils'; + +const NOW = new Date('2026-04-21T12:00:00Z'); + +describe('formatRelativeTime', () => { + it('returns "just now" for < 30s', () => { + expect(formatRelativeTime('2026-04-21T11:59:50Z', NOW)).toBe('just now'); + }); + + it('returns minutes for < 60m', () => { + expect(formatRelativeTime('2026-04-21T11:57:00Z', NOW)).toBe('3m ago'); + }); + + it('returns hours for < 24h', () => { + expect(formatRelativeTime('2026-04-21T10:00:00Z', NOW)).toBe('2h ago'); + }); + + it('returns days for < 30d', () => { + expect(formatRelativeTime('2026-04-18T12:00:00Z', NOW)).toBe('3d ago'); + }); + + it('returns locale date string for older than 30d', () => { + // Absolute fallback — we don't assert format, just that it isn't "Xd ago". + const out = formatRelativeTime('2025-01-01T00:00:00Z', NOW); + expect(out).not.toMatch(/ago$/); + expect(out.length).toBeGreaterThan(0); + }); + + it('handles future timestamps by clamping to "just now"', () => { + expect(formatRelativeTime('2026-04-21T12:00:30Z', NOW)).toBe('just now'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd ui && npm run test -- --run src/pages/Alerts/time-utils.test.ts +``` +Expected: FAIL with `Cannot find module './time-utils'`. + +- [ ] **Step 3: Implement the helper** + +Create `ui/src/pages/Alerts/time-utils.ts`: + +```typescript +export function formatRelativeTime(iso: string, now: Date = new Date()): string { + const then = new Date(iso).getTime(); + const diffSec = Math.max(0, Math.floor((now.getTime() - then) / 1000)); + if (diffSec < 30) return 'just now'; + if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`; + if (diffSec < 86_400) return `${Math.floor(diffSec / 3600)}h ago`; + const diffDays = Math.floor(diffSec / 86_400); + if (diffDays < 30) return `${diffDays}d ago`; + return new Date(iso).toLocaleDateString('en-GB', { + year: 'numeric', month: 'short', day: '2-digit', + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +cd ui && npm run test -- --run src/pages/Alerts/time-utils.test.ts +``` +Expected: 6 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/pages/Alerts/time-utils.ts ui/src/pages/Alerts/time-utils.test.ts +git commit -m "$(cat <<'EOF' +feat(alerts/ui): add formatRelativeTime helper + +Formats ISO timestamps as `Nm ago` / `Nh ago` / `Nd ago`, falling back +to an absolute locale date string for values older than 30 days. Used +by the alert DataTable Age column. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Shared renderer + +### Task 3: `alert-expanded.tsx` — shared expandedContent component + +**Files:** +- Create: `ui/src/pages/Alerts/alert-expanded.tsx` + +- [ ] **Step 1: Implement the component** + +Create `ui/src/pages/Alerts/alert-expanded.tsx`: + +```tsx +import type { AlertDto } from '../../api/queries/alerts'; +import css from './alerts-page.module.css'; + +/** + * Shared DataTable expandedContent renderer for alert rows. + * Used by Inbox, All alerts, and History pages. + */ +export function renderAlertExpanded(alert: AlertDto) { + return ( +
+ {alert.message && ( +
+ Message +

{alert.message}

+
+ )} +
+
+ Fired at + {alert.firedAt ?? '—'} +
+ {alert.resolvedAt && ( +
+ Resolved at + {alert.resolvedAt} +
+ )} + {alert.ackedAt && ( +
+ Acknowledged at + {alert.ackedAt} +
+ )} +
+ Rule + {alert.ruleName ?? alert.ruleId ?? '—'} +
+ {alert.appSlug && ( +
+ App + {alert.appSlug} +
+ )} +
+
+ ); +} +``` + +Note: referenced classes (`.expanded`, `.expandedGrid`, `.expandedField`, `.expandedLabel`, `.expandedValue`) are added to `alerts-page.module.css` in Task 4. Any field name not present on `AlertDto` (check via Task 3 Step 2 below) must be removed. + +- [ ] **Step 2: Typecheck against generated schema** + +```bash +cd ui && npx tsc --noEmit -p tsconfig.app.json 2>&1 | head -40 +``` +Expected: no errors referencing `alert-expanded.tsx`. If `alert.ruleName`, `alert.resolvedAt`, `alert.ackedAt`, or `alert.appSlug` do not exist on `AlertDto`: + +1. Open `ui/src/api/schema.d.ts` and search for `AlertDto` to see the actual field names. +2. Remove or rename the missing fields in `alert-expanded.tsx`. +3. Re-run typecheck until clean. + +Do NOT add fields that don't exist — the expansion is best-effort and safely renders partial data. + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/pages/Alerts/alert-expanded.tsx +git commit -m "$(cat <<'EOF' +feat(alerts/ui): add shared renderAlertExpanded for DataTable rows + +Extracts the per-row detail block used by Inbox/All/History DataTables +so the three pages share one rendering. Consumes AlertDto fields that +are nullable in the schema; hides missing fields instead of rendering +placeholders. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## CSS token migration + +### Task 4: Slim `alerts-page.module.css` + +**Files:** +- Modify: `ui/src/pages/Alerts/alerts-page.module.css` + +- [ ] **Step 1: Replace entire file** + +Overwrite `ui/src/pages/Alerts/alerts-page.module.css`: + +```css +.page { + padding: var(--space-md); + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-sm); + flex-wrap: wrap; +} + +.filterBar { + display: flex; + gap: var(--space-sm); + align-items: center; + flex-wrap: wrap; +} + +.bulkBar { + display: flex; + gap: var(--space-sm); + align-items: center; + padding: var(--space-sm) var(--space-md); + background: var(--bg-hover); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); +} + +.titleCell { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.titleCell a { + color: var(--text-primary); + font-weight: 500; + text-decoration: none; +} + +.titleCell a:hover { + text-decoration: underline; +} + +.titleCellUnread a { + font-weight: 600; +} + +.titlePreview { + font-size: 12px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 48ch; +} + +.expanded { + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); +} + +.expandedGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: var(--space-sm); +} + +.expandedField { + display: flex; + flex-direction: column; + gap: 2px; +} + +.expandedLabel { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); +} + +.expandedValue { + font-size: 13px; + color: var(--text-primary); + margin: 0; + word-break: break-word; +} +``` + +- [ ] **Step 2: Verify no other files still depend on the removed classes** + +```bash +grep -rn "css\.row\|css\.rowUnread\|css\.body\|css\.meta\|css\.time\|css\.message\|css\.actions\|css\.empty" ui/src/pages/Alerts/ +``` +Expected: all matches are inside `InboxPage.tsx`, `AllAlertsPage.tsx`, `HistoryPage.tsx`, or `AlertRow.tsx` — these are all being rewritten in Tasks 5-7 and deleted in Task 10. Do NOT stop here just because matches exist; they will be removed downstream. + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/pages/Alerts/alerts-page.module.css +git commit -m "$(cat <<'EOF' +refactor(alerts/ui): slim alerts-page.module.css to layout-only DS tokens + +Drop the feed-row classes (.row, .rowUnread, .body, .meta, .time, +.message, .actions, .empty) — these are replaced by DS DataTable + +EmptyState in follow-up tasks. Keep layout helpers for page shell, +toolbar, filter bar, bulk-action bar, title cell, and DataTable +expanded content. All colors / spacing use DS tokens. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## List pages + +### Task 5: Rewrite `InboxPage.tsx` + +**Files:** +- Modify: `ui/src/pages/Alerts/InboxPage.tsx` + +- [ ] **Step 1: Replace entire file** + +Overwrite `ui/src/pages/Alerts/InboxPage.tsx`: + +```tsx +import { useMemo, useState } from 'react'; +import { Link } from 'react-router'; +import { Inbox } from 'lucide-react'; +import { + Button, SectionHeader, DataTable, EmptyState, useToast, +} from '@cameleer/design-system'; +import type { Column } from '@cameleer/design-system'; +import { PageLoader } from '../../components/PageLoader'; +import { SeverityBadge } from '../../components/SeverityBadge'; +import { AlertStateChip } from '../../components/AlertStateChip'; +import { + useAlerts, useAckAlert, useBulkReadAlerts, useMarkAlertRead, + type AlertDto, +} from '../../api/queries/alerts'; +import { severityToAccent } from './severity-utils'; +import { formatRelativeTime } from './time-utils'; +import { renderAlertExpanded } from './alert-expanded'; +import css from './alerts-page.module.css'; +import tableStyles from '../../styles/table-section.module.css'; + +export default function InboxPage() { + const { data, isLoading, error } = useAlerts({ state: ['FIRING', 'ACKNOWLEDGED'], limit: 100 }); + const bulkRead = useBulkReadAlerts(); + const markRead = useMarkAlertRead(); + const ack = useAckAlert(); + const { toast } = useToast(); + + const [selected, setSelected] = useState>(new Set()); + const rows = data ?? []; + + const unreadIds = useMemo( + () => rows.filter((a) => a.state === 'FIRING').map((a) => a.id), + [rows], + ); + + const toggleSelected = (id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }; + + const onAck = async (id: string, title?: string) => { + try { + await ack.mutateAsync(id); + toast({ title: 'Acknowledged', description: title, variant: 'success' }); + } catch (e) { + toast({ title: 'Ack failed', description: String(e), variant: 'error' }); + } + }; + + const onBulkRead = async (ids: string[]) => { + if (ids.length === 0) return; + try { + await bulkRead.mutateAsync(ids); + setSelected(new Set()); + toast({ title: `Marked ${ids.length} as read`, variant: 'success' }); + } catch (e) { + toast({ title: 'Bulk read failed', description: String(e), variant: 'error' }); + } + }; + + const columns: Column[] = [ + { + key: 'select', header: '', width: '40px', + render: (_, row) => ( + toggleSelected(row.id)} + aria-label={`Select ${row.title ?? row.id}`} + onClick={(e) => e.stopPropagation()} + /> + ), + }, + { + key: 'severity', header: 'Severity', width: '110px', + render: (_, row) => + row.severity ? : null, + }, + { + key: 'state', header: 'State', width: '140px', + render: (_, row) => + row.state ? : null, + }, + { + key: 'title', header: 'Title', + render: (_, row) => { + const unread = row.state === 'FIRING'; + return ( +
+ markRead.mutate(row.id)}> + {row.title ?? '(untitled)'} + + {row.message && {row.message}} +
+ ); + }, + }, + { + key: 'age', header: 'Age', width: '100px', sortable: true, + render: (_, row) => + row.firedAt ? ( + + {formatRelativeTime(row.firedAt)} + + ) : '—', + }, + { + key: 'ack', header: '', width: '70px', + render: (_, row) => + row.state === 'FIRING' ? ( + + ) : null, + }, + ]; + + if (isLoading) return ; + if (error) return
Failed to load alerts: {String(error)}
; + + const selectedIds = Array.from(selected); + + return ( +
+
+ Inbox +
+ +
+ + {selectedIds.length > 0 + ? `${selectedIds.length} selected` + : `${unreadIds.length} unread`} + +
+ + +
+
+ + {rows.length === 0 ? ( + } + title="All clear" + description="No open alerts for you in this environment." + /> + ) : ( +
+ + columns={columns} + data={rows} + sortable + flush + rowAccent={(row) => row.severity ? severityToAccent(row.severity) : undefined} + expandedContent={renderAlertExpanded} + /> +
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Typecheck** + +```bash +cd ui && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "InboxPage\.tsx|severity-utils|time-utils|alert-expanded" +``` +Expected: no output. + +- [ ] **Step 3: Unit tests still green** + +```bash +cd ui && npm run test -- --run +``` +Expected: no regressions; same or higher count than Task 0 Step 3 baseline. + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/pages/Alerts/InboxPage.tsx +git commit -m "$(cat <<'EOF' +refactor(alerts/ui): rewrite Inbox as DataTable with expandable rows + +Replaces custom feed-row layout with the shared DataTable shell used +elsewhere in the app. Adds checkbox selection + bulk "Mark selected +read" toolbar alongside the existing "Mark all read". Uses DS +EmptyState for empty lists, severity-driven rowAccent for unread +tinting, and renderAlertExpanded for row detail. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 6: Rewrite `AllAlertsPage.tsx` + +**Files:** +- Modify: `ui/src/pages/Alerts/AllAlertsPage.tsx` + +- [ ] **Step 1: Replace entire file** + +Overwrite `ui/src/pages/Alerts/AllAlertsPage.tsx`: + +```tsx +import { useState } from 'react'; +import { Link } from 'react-router'; +import { Bell } from 'lucide-react'; +import { + SectionHeader, DataTable, EmptyState, SegmentedTabs, +} from '@cameleer/design-system'; +import type { Column } from '@cameleer/design-system'; +import { PageLoader } from '../../components/PageLoader'; +import { SeverityBadge } from '../../components/SeverityBadge'; +import { AlertStateChip } from '../../components/AlertStateChip'; +import { + useAlerts, useMarkAlertRead, + type AlertDto, +} from '../../api/queries/alerts'; +import { severityToAccent } from './severity-utils'; +import { formatRelativeTime } from './time-utils'; +import { renderAlertExpanded } from './alert-expanded'; +import css from './alerts-page.module.css'; +import tableStyles from '../../styles/table-section.module.css'; + +type AlertState = NonNullable; + +const STATE_FILTERS: Record = { + open: { label: 'Open', values: ['PENDING', 'FIRING', 'ACKNOWLEDGED'] }, + firing: { label: 'Firing', values: ['FIRING'] }, + acked: { label: 'Acked', values: ['ACKNOWLEDGED'] }, + all: { label: 'All', values: ['PENDING', 'FIRING', 'ACKNOWLEDGED', 'RESOLVED'] }, +}; + +export default function AllAlertsPage() { + const [filterKey, setFilterKey] = useState('open'); + const filter = STATE_FILTERS[filterKey]; + const { data, isLoading, error } = useAlerts({ state: filter.values, limit: 200 }); + const markRead = useMarkAlertRead(); + + const rows = data ?? []; + + const columns: Column[] = [ + { + key: 'severity', header: 'Severity', width: '110px', + render: (_, row) => row.severity ? : null, + }, + { + key: 'state', header: 'State', width: '140px', + render: (_, row) => row.state ? : null, + }, + { + key: 'title', header: 'Title', + render: (_, row) => ( +
+ markRead.mutate(row.id)}> + {row.title ?? '(untitled)'} + + {row.message && {row.message}} +
+ ), + }, + { + key: 'firedAt', header: 'Fired at', width: '140px', sortable: true, + render: (_, row) => + row.firedAt ? ( + + {formatRelativeTime(row.firedAt)} + + ) : '—', + }, + ]; + + if (isLoading) return ; + if (error) return
Failed to load alerts: {String(error)}
; + + return ( +
+
+ All alerts +
+ +
+ ({ value, label: f.label }))} + active={filterKey} + onChange={setFilterKey} + /> +
+ + {rows.length === 0 ? ( + } + title="No alerts match this filter" + description={`Try switching to a different state or widening your criteria.`} + /> + ) : ( +
+ + columns={columns} + data={rows} + sortable + flush + rowAccent={(row) => row.severity ? severityToAccent(row.severity) : undefined} + expandedContent={renderAlertExpanded} + /> +
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Typecheck** + +```bash +cd ui && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "AllAlertsPage\.tsx" +``` +Expected: no output. + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/pages/Alerts/AllAlertsPage.tsx +git commit -m "$(cat <<'EOF' +refactor(alerts/ui): rewrite All alerts as DataTable + SegmentedTabs filter + +Replaces 4-Button filter row with DS SegmentedTabs and custom row +rendering with DataTable. Shares expandedContent renderer and +severity-driven rowAccent with Inbox. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 7: Rewrite `HistoryPage.tsx` + +**Files:** +- Modify: `ui/src/pages/Alerts/HistoryPage.tsx` + +- [ ] **Step 1: Replace entire file** + +Overwrite `ui/src/pages/Alerts/HistoryPage.tsx`: + +```tsx +import { useState } from 'react'; +import { Link } from 'react-router'; +import { History } from 'lucide-react'; +import { + SectionHeader, DataTable, EmptyState, DateRangePicker, +} from '@cameleer/design-system'; +import type { Column } from '@cameleer/design-system'; +import { PageLoader } from '../../components/PageLoader'; +import { SeverityBadge } from '../../components/SeverityBadge'; +import { + useAlerts, type AlertDto, +} from '../../api/queries/alerts'; +import { severityToAccent } from './severity-utils'; +import { formatRelativeTime } from './time-utils'; +import { renderAlertExpanded } from './alert-expanded'; +import css from './alerts-page.module.css'; +import tableStyles from '../../styles/table-section.module.css'; + +/** Duration in minutes/hours/days. Pure, best-effort. */ +function formatDuration(from?: string | null, to?: string | null): string { + if (!from || !to) return '—'; + const ms = new Date(to).getTime() - new Date(from).getTime(); + if (ms < 0 || Number.isNaN(ms)) return '—'; + const sec = Math.floor(ms / 1000); + if (sec < 60) return `${sec}s`; + if (sec < 3600) return `${Math.floor(sec / 60)}m`; + if (sec < 86_400) return `${Math.floor(sec / 3600)}h`; + return `${Math.floor(sec / 86_400)}d`; +} + +export default function HistoryPage() { + const [dateRange, setDateRange] = useState({ + start: new Date(Date.now() - 7 * 24 * 3600_000), + end: new Date(), + }); + + // useAlerts doesn't accept a time range today; we fetch RESOLVED and + // filter client-side. A server-side range param is a future enhancement. + const { data, isLoading, error } = useAlerts({ state: 'RESOLVED', limit: 200 }); + + const filtered = (data ?? []).filter((a) => { + if (!a.firedAt) return false; + const t = new Date(a.firedAt).getTime(); + return t >= dateRange.start.getTime() && t <= dateRange.end.getTime(); + }); + + const columns: Column[] = [ + { + key: 'severity', header: 'Severity', width: '110px', + render: (_, row) => row.severity ? : null, + }, + { + key: 'title', header: 'Title', + render: (_, row) => ( +
+ {row.title ?? '(untitled)'} + {row.message && {row.message}} +
+ ), + }, + { + key: 'firedAt', header: 'Fired at', width: '140px', sortable: true, + render: (_, row) => + row.firedAt ? ( + + {formatRelativeTime(row.firedAt)} + + ) : '—', + }, + { + key: 'resolvedAt', header: 'Resolved at', width: '140px', sortable: true, + render: (_, row) => + row.resolvedAt ? ( + + {formatRelativeTime(row.resolvedAt)} + + ) : '—', + }, + { + key: 'duration', header: 'Duration', width: '90px', + render: (_, row) => formatDuration(row.firedAt, row.resolvedAt), + }, + ]; + + if (isLoading) return ; + if (error) return
Failed to load history: {String(error)}
; + + return ( +
+
+ History +
+ +
+ +
+ + {filtered.length === 0 ? ( + } + title="No resolved alerts" + description="Nothing in the selected date range. Try widening it." + /> + ) : ( +
+ + columns={columns} + data={filtered} + sortable + flush + rowAccent={(row) => row.severity ? severityToAccent(row.severity) : undefined} + expandedContent={renderAlertExpanded} + /> +
+ )} +
+ ); +} +``` + +If `AlertDto.resolvedAt` does not exist in the generated schema (TSC will report it), remove the `resolvedAt` and `duration` columns and omit the field from the table. Do NOT add a backend field. + +- [ ] **Step 2: Typecheck** + +```bash +cd ui && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "HistoryPage\.tsx" +``` +Expected: no output. If `resolvedAt` errors appear, adjust per note above. + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/pages/Alerts/HistoryPage.tsx +git commit -m "$(cat <<'EOF' +refactor(alerts/ui): rewrite History as DataTable + DateRangePicker + +Replaces custom feed rows with DataTable. Adds a DateRangePicker +filter (client-side) defaulting to the last 7 days. Client-side +range filter is a stopgap; a server-side range param is a future +enhancement captured in the design spec. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 8: Rewrite `RulesListPage.tsx` + +**Files:** +- Modify: `ui/src/pages/Alerts/RulesListPage.tsx` + +- [ ] **Step 1: Replace entire file** + +Overwrite `ui/src/pages/Alerts/RulesListPage.tsx`: + +```tsx +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router'; +import { FilePlus } from 'lucide-react'; +import { + Button, SectionHeader, Toggle, useToast, Badge, DataTable, + EmptyState, Dropdown, ConfirmDialog, +} from '@cameleer/design-system'; +import type { Column } 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 tableStyles from '../../styles/table-section.module.css'; +import css from './alerts-page.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(); + + const [pendingDelete, setPendingDelete] = useState(null); + + 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 confirmDelete = async () => { + if (!pendingDelete) return; + try { + await deleteRule.mutateAsync(pendingDelete.id); + toast({ title: 'Deleted', description: pendingDelete.name, variant: 'success' }); + } catch (e) { + toast({ title: 'Delete failed', description: String(e), variant: 'error' }); + } finally { + setPendingDelete(null); + } + }; + + const onPromote = (r: AlertRuleResponse, targetEnvSlug: string) => { + navigate(`/alerts/rules/new?promoteFrom=${env}&ruleId=${r.id}&targetEnv=${targetEnvSlug}`); + }; + + const columns: Column[] = [ + { + key: 'name', header: 'Name', + render: (_, r) => {r.name}, + }, + { + key: 'conditionKind', header: 'Kind', width: '160px', + render: (_, r) => , + }, + { + key: 'severity', header: 'Severity', width: '110px', + render: (_, r) => , + }, + { + key: 'enabled', header: 'Enabled', width: '90px', + render: (_, r) => ( + onToggle(r)} + disabled={setEnabled.isPending} + /> + ), + }, + { + key: 'targets', header: 'Targets', width: '90px', + render: (_, r) => String(r.targets.length), + }, + { + key: 'actions', header: '', width: '220px', + render: (_, r) => ( +
+ {otherEnvs.length > 0 && ( + Promote to ▾} + items={otherEnvs.map((e) => ({ + label: e.slug, + onClick: () => onPromote(r, e.slug), + }))} + /> + )} + +
+ ), + }, + ]; + + return ( +
+
+ + + + } + > + Alert rules + +
+ + {rows.length === 0 ? ( + } + title="No alert rules" + description="Create one to start evaluating alerts for this environment." + action={ + + + + } + /> + ) : ( +
+ + columns={columns} + data={rows} + flush + /> +
+ )} + + setPendingDelete(null)} + onConfirm={confirmDelete} + title="Delete alert rule?" + message={ + pendingDelete + ? `Delete rule "${pendingDelete.name}"? Fired alerts are preserved via rule_snapshot.` + : '' + } + confirmText="Delete" + variant="danger" + loading={deleteRule.isPending} + /> +
+ ); +} +``` + +- [ ] **Step 2: Typecheck** + +```bash +cd ui && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "RulesListPage\.tsx" +``` +Expected: no output. + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/pages/Alerts/RulesListPage.tsx +git commit -m "$(cat <<'EOF' +refactor(alerts/ui): rewrite Rules list with DataTable + Dropdown + ConfirmDialog + +Replaces raw with DataTable, raw setMatcherRuleId(e.target.value)} /> + + + setMatcherAppSlug(e.target.value)} /> + + + setHours(Number(e.target.value))} + /> + + + setReason(e.target.value)} + placeholder="Maintenance window" + /> + + + + + + {rows.length === 0 ? ( + } + title="No silences" + description="Nothing is currently silenced in this environment." + /> + ) : ( +
+ + columns={columns} + data={rows.map((s) => ({ ...s, id: s.id ?? '' }))} + flush + /> +
+ )} + + setPendingEnd(null)} + onConfirm={confirmEnd} + title="End silence?" + message="End this silence early? Affected rules will resume firing." + confirmText="End silence" + variant="warning" + loading={remove.isPending} + /> + + ); +} +``` + +- [ ] **Step 2: Typecheck** + +```bash +cd ui && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "SilencesPage\.tsx" +``` +Expected: no output. If `AlertSilenceResponse` doesn't have a definite `id`, the `.map((s) => ({ ...s, id: s.id ?? '' }))` cast keeps DataTable happy without altering semantics. + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/pages/Alerts/SilencesPage.tsx +git commit -m "$(cat <<'EOF' +refactor(alerts/ui): rewrite Silences with DataTable + FormField + ConfirmDialog + +Replaces raw
with DataTable, inline-styled form with proper +FormField hints, and native confirm() end-early with ConfirmDialog +(warning variant). Adds DS EmptyState for no-silences case. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 10: Delete `AlertRow.tsx` + +**Files:** +- Delete: `ui/src/pages/Alerts/AlertRow.tsx` + +- [ ] **Step 1: Verify no remaining imports** + +```bash +grep -rn "AlertRow" ui/src/ | grep -v "\.test\.\|\.d\.ts" +``` +Expected: no matches. (The `AlertStateChip` tests mention "state chip" strings, not `AlertRow`.) + +If any matches remain, they must be from a previous task that wasn't finished — fix before proceeding. + +- [ ] **Step 2: Delete the file** + +```bash +git rm ui/src/pages/Alerts/AlertRow.tsx +``` + +- [ ] **Step 3: Typecheck** + +```bash +cd ui && npx tsc --noEmit -p tsconfig.app.json +``` +Expected: exits 0. + +- [ ] **Step 4: Commit** + +```bash +git commit -m "$(cat <<'EOF' +chore(alerts/ui): remove obsolete AlertRow.tsx + +The feed-row component is replaced by DataTable column renderers and +the shared renderAlertExpanded content renderer. No callers remain. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Wizard + +### Task 11: Token + layout fix in `wizard.module.css` + +**Files:** +- Modify: `ui/src/pages/Alerts/RuleEditor/wizard.module.css` + +- [ ] **Step 1: Replace entire file** + +Overwrite `ui/src/pages/Alerts/RuleEditor/wizard.module.css`: + +```css +.wizard { + padding: var(--space-md); + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-sm); + flex-wrap: wrap; +} + +.steps { + display: flex; + gap: var(--space-sm); + border-bottom: 1px solid var(--border-subtle); + padding-bottom: var(--space-sm); +} + +.step { + background: none; + border: none; + padding: 8px 12px; + border-bottom: 2px solid transparent; + cursor: pointer; + color: var(--text-muted); + font-size: 13px; + font-family: inherit; +} + +.step:hover { + color: var(--text-primary); +} + +.stepActive { + color: var(--text-primary); + border-bottom-color: var(--amber); +} + +.stepDone { + color: var(--text-primary); +} + +.stepBody { + min-height: 320px; +} + +.footer { + display: flex; + justify-content: space-between; +} +``` + +Note: `.promoteBanner` is removed — the wizard will switch to DS `` in Task 12. + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/pages/Alerts/RuleEditor/wizard.module.css +git commit -m "$(cat <<'EOF' +refactor(alerts/ui): replace undefined CSS vars in wizard.module.css + +Replace undefined tokens (--muted, --fg, --accent, --border, +--amber-bg) with DS tokens (--text-muted, --text-primary, --amber, +--border-subtle, --space-sm|md). Drop .promoteBanner — replaced by +DS Alert in follow-up commit. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 12: Wizard banners → DS `Alert`; step body → section card + +**Files:** +- Modify: `ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx` + +- [ ] **Step 1: Replace the JSX return of `RuleEditorWizard`** + +Open `ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx`. At the top of the file, update imports: + +```tsx +import { useEffect, useState } from 'react'; +import { useNavigate, useParams, useSearchParams } from 'react-router'; +import { Alert, 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, 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 sectionStyles from '../../../styles/section-card.module.css'; +import css from './wizard.module.css'; +``` + +Then replace the `return (...)` block at the bottom of the component with: + +```tsx + return ( +
+
+ {isEdit ? `Edit rule: ${form.name}` : 'New alert rule'} +
+ + {promoteFrom && ( + + Promoting from {promoteFrom} — review and adjust, then save. + + )} + + {warnings.length > 0 && ( + +
    + {warnings.map((w) => ( +
  • + {w.field}: {w.message} +
  • + ))} +
+
+ )} + + + +
{body}
+ +
+ + {idx < WIZARD_STEPS.length - 1 ? ( + + ) : ( + + )} +
+
+ ); +``` + +- [ ] **Step 2: Typecheck** + +```bash +cd ui && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "RuleEditorWizard\.tsx" +``` +Expected: no output. + +- [ ] **Step 3: Unit tests still green** + +```bash +cd ui && npm run test -- --run +``` +Expected: no regressions. + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx +git commit -m "$(cat <<'EOF' +refactor(alerts/ui): wizard banners → DS Alert, step body → section card + +Promote banner and prefill warnings now render as DS Alert components +(info / warning variants). Step body wraps in sectionStyles.section +for card affordance matching other forms in the app. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## E2E test adaptation + +### Task 13: Update `alerting.spec.ts` for ConfirmDialog + +**Files:** +- Modify: `ui/src/test/e2e/alerting.spec.ts` + +Context: two tests currently rely on `page.once('dialog', (d) => d.accept())` which captures native `confirm()` dialogs. Tasks 8 and 9 replaced those with DS `ConfirmDialog`. Update the selectors. + +- [ ] **Step 1: Patch the rule delete assertion** + +Open `ui/src/test/e2e/alerting.spec.ts`. Find the "create + delete a rule via the wizard" test. Replace: + +```ts + // 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); +``` + +With: + +```ts + // Cleanup: open ConfirmDialog via row Delete button, confirm in dialog. + await page + .getByRole('row', { name: new RegExp(ruleName) }) + .getByRole('button', { name: /^delete$/i }) + .click(); + const confirmDelete = page.getByRole('dialog'); + await expect(confirmDelete.getByText(/delete alert rule/i)).toBeVisible(); + await confirmDelete.getByRole('button', { name: /^delete$/i }).click(); + await expect(main.getByRole('link', { name: ruleName })).toHaveCount(0); +``` + +- [ ] **Step 2: Patch the silence end-early assertion** + +In the same file, find the "silence create + end-early" test. Replace: + +```ts + 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); +``` + +With: + +```ts + await page + .getByRole('row', { name: new RegExp(unique) }) + .getByRole('button', { name: /^end$/i }) + .click(); + const confirmEnd = page.getByRole('dialog'); + await expect(confirmEnd.getByText(/end silence/i)).toBeVisible(); + await confirmEnd.getByRole('button', { name: /end silence/i }).click(); + await expect(page.getByText(unique)).toHaveCount(0); +``` + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/test/e2e/alerting.spec.ts +git commit -m "$(cat <<'EOF' +test(alerts/e2e): adapt smoke suite to DS ConfirmDialog + +The Rules list Delete and Silences End-early flows now use DS +ConfirmDialog instead of native confirm(). Update selectors to +target the dialog's role=dialog + confirm button instead of +listening for the native `dialog` event. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Verification + +### Task 14: Full verification + +No code changes — run the gate checks in order and fix any failure before moving on. + +- [ ] **Step 1: Full TypeScript compile** + +```bash +cd ui && npx tsc --noEmit -p tsconfig.app.json +``` +Expected: exits 0. + +- [ ] **Step 2: UI unit tests** + +```bash +cd ui && npm run test -- --run +``` +Expected: all pass, count ≥ baseline + 9 (3 severity-utils + 6 time-utils). + +- [ ] **Step 3: Production build** + +```bash +cd ui && npm run build +``` +Expected: exits 0 (no dead imports, no TS errors). + +- [ ] **Step 4: Grep sanity — no undefined tokens remain in the alerts tree** + +```bash +grep -rn "var(--bg)\|var(--fg)\|var(--muted)\|var(--accent)\|var(--amber-bg" ui/src/pages/Alerts/ +``` +Expected: no matches. `var(--amber)` IS valid (DS token) — only flag if you see `--amber-bg` used on a legacy surface (the token itself is fine, but we removed all usages in this plan). + +- [ ] **Step 5: Grep sanity — no raw `
` in alerts** + +```bash +grep -rn "&targetEnv=other`) renders as info `Alert`. Delete a rule → `ConfirmDialog` opens; confirm → row disappears. +6. **Silences** — create form grid uses FormField labels/hints; table renders; `End` → `ConfirmDialog`. + +- [ ] **Step 9: Run Playwright e2e** + +```bash +cd ui && npm run test:e2e +``` + +If `test:e2e` script doesn't exist, run: + +```bash +cd ui && npx playwright test --config=playwright.config.js +``` + +Expected: all 4 tests pass. If the Inbox smoke now asserts `Mark all read` but a regression made the button label differ, update the assertion or the UI — do not suppress the test. + +- [ ] **Step 10: GitNexus scope check** + +Before the final wrap-up commit (there may or may not be one): + +```bash +# Using the MCP tool, run: +# gitnexus_detect_changes({scope: "compare", base_ref: "main"}) +``` + +Expected: affected files match the plan — everything is under `ui/src/pages/Alerts/`, `ui/src/test/e2e/alerting.spec.ts`, or new helper files. No unexpected changes in `cameleer-server-core`, `cameleer-server-app/src/main/java`, `cameleer-server-app/src/main/resources`, or `ui/src/api/`. + +If anything outside these scopes shows up — stop and investigate. + +--- + +## Self-review + +**Spec coverage check:** + +| Spec item | Task | +|-----------|------| +| Tokens in `alerts-page.module.css` → DS tokens | Task 4 | +| Tokens in `wizard.module.css` → DS tokens | Task 11 | +| Inbox → DataTable + EmptyState + bulk toolbar | Task 5 | +| All alerts → DataTable + SegmentedTabs filter | Task 6 (spec said ButtonGroup; adopted SegmentedTabs because `ButtonGroup` is multi-select — documented in header) | +| History → DataTable + DateRangePicker | Task 7 | +| Rules list → DataTable + Dropdown + ConfirmDialog | Task 8 | +| Silences → DataTable + FormField + ConfirmDialog | Task 9 | +| AlertRow delete | Task 10 | +| Wizard banners → DS Alert | Task 12 | +| Step body wraps in section-card | Task 12 | +| `severityToAccent` helper + test | Task 1 | +| `formatRelativeTime` helper + test | Task 2 | +| Shared `renderAlertExpanded` | Task 3 | +| E2E adaptation for ConfirmDialog | Task 13 | +| Manual light/dark smoke | Task 14 Step 8 | + +**Placeholder scan:** No "TBD"/"TODO"/"fill in"/"handle edge cases". Every task has full code blocks and exact commands. + +**Type consistency:** Severity enum (`CRITICAL | WARNING | INFO`) is consistent across Tasks 1, 3, 5, 6, 7. `rowAccent` return type matches DS (`error | warning | undefined`). `AlertDto` fields used in Task 3 have a safety-check fallback step.