Task-by-task TDD plan implementing the design spec. Splits the work into 14 tasks: helper utilities (TDD), shared renderer, CSS token migration, per-page rewrites (Inbox/All/History/Rules/Silences), wizard banner migration, AlertRow deletion, E2E adaptation for ConfirmDialog, and full verification pass. Each task produces an atomic commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1907 lines
57 KiB
Markdown
1907 lines
57 KiB
Markdown
# 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<string>` (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<AlertDto['severity']>;
|
|
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) <noreply@anthropic.com>
|
|
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) <noreply@anthropic.com>
|
|
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 (
|
|
<div className={css.expanded}>
|
|
{alert.message && (
|
|
<div className={css.expandedField}>
|
|
<span className={css.expandedLabel}>Message</span>
|
|
<p className={css.expandedValue}>{alert.message}</p>
|
|
</div>
|
|
)}
|
|
<div className={css.expandedGrid}>
|
|
<div className={css.expandedField}>
|
|
<span className={css.expandedLabel}>Fired at</span>
|
|
<span className={css.expandedValue}>{alert.firedAt ?? '—'}</span>
|
|
</div>
|
|
{alert.resolvedAt && (
|
|
<div className={css.expandedField}>
|
|
<span className={css.expandedLabel}>Resolved at</span>
|
|
<span className={css.expandedValue}>{alert.resolvedAt}</span>
|
|
</div>
|
|
)}
|
|
{alert.ackedAt && (
|
|
<div className={css.expandedField}>
|
|
<span className={css.expandedLabel}>Acknowledged at</span>
|
|
<span className={css.expandedValue}>{alert.ackedAt}</span>
|
|
</div>
|
|
)}
|
|
<div className={css.expandedField}>
|
|
<span className={css.expandedLabel}>Rule</span>
|
|
<span className={css.expandedValue}>{alert.ruleName ?? alert.ruleId ?? '—'}</span>
|
|
</div>
|
|
{alert.appSlug && (
|
|
<div className={css.expandedField}>
|
|
<span className={css.expandedLabel}>App</span>
|
|
<span className={css.expandedValue}>{alert.appSlug}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
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) <noreply@anthropic.com>
|
|
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) <noreply@anthropic.com>
|
|
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<Set<string>>(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<AlertDto>[] = [
|
|
{
|
|
key: 'select', header: '', width: '40px',
|
|
render: (_, row) => (
|
|
<input
|
|
type="checkbox"
|
|
checked={selected.has(row.id)}
|
|
onChange={() => toggleSelected(row.id)}
|
|
aria-label={`Select ${row.title ?? row.id}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: 'severity', header: 'Severity', width: '110px',
|
|
render: (_, row) =>
|
|
row.severity ? <SeverityBadge severity={row.severity} /> : null,
|
|
},
|
|
{
|
|
key: 'state', header: 'State', width: '140px',
|
|
render: (_, row) =>
|
|
row.state ? <AlertStateChip state={row.state} silenced={row.silenced} /> : null,
|
|
},
|
|
{
|
|
key: 'title', header: 'Title',
|
|
render: (_, row) => {
|
|
const unread = row.state === 'FIRING';
|
|
return (
|
|
<div className={`${css.titleCell} ${unread ? css.titleCellUnread : ''}`}>
|
|
<Link to={`/alerts/inbox/${row.id}`} onClick={() => markRead.mutate(row.id)}>
|
|
{row.title ?? '(untitled)'}
|
|
</Link>
|
|
{row.message && <span className={css.titlePreview}>{row.message}</span>}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: 'age', header: 'Age', width: '100px', sortable: true,
|
|
render: (_, row) =>
|
|
row.firedAt ? (
|
|
<span title={row.firedAt} style={{ fontVariantNumeric: 'tabular-nums' }}>
|
|
{formatRelativeTime(row.firedAt)}
|
|
</span>
|
|
) : '—',
|
|
},
|
|
{
|
|
key: 'ack', header: '', width: '70px',
|
|
render: (_, row) =>
|
|
row.state === 'FIRING' ? (
|
|
<Button size="sm" variant="secondary" onClick={() => onAck(row.id, row.title ?? undefined)}>
|
|
Ack
|
|
</Button>
|
|
) : null,
|
|
},
|
|
];
|
|
|
|
if (isLoading) return <PageLoader />;
|
|
if (error) return <div className={css.page}>Failed to load alerts: {String(error)}</div>;
|
|
|
|
const selectedIds = Array.from(selected);
|
|
|
|
return (
|
|
<div className={css.page}>
|
|
<div className={css.toolbar}>
|
|
<SectionHeader>Inbox</SectionHeader>
|
|
</div>
|
|
|
|
<div className={css.bulkBar}>
|
|
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>
|
|
{selectedIds.length > 0
|
|
? `${selectedIds.length} selected`
|
|
: `${unreadIds.length} unread`}
|
|
</span>
|
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: 'var(--space-sm)' }}>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => onBulkRead(selectedIds)}
|
|
disabled={selectedIds.length === 0 || bulkRead.isPending}
|
|
>
|
|
Mark selected read
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => onBulkRead(unreadIds)}
|
|
disabled={unreadIds.length === 0 || bulkRead.isPending}
|
|
>
|
|
Mark all read
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{rows.length === 0 ? (
|
|
<EmptyState
|
|
icon={<Inbox size={32} />}
|
|
title="All clear"
|
|
description="No open alerts for you in this environment."
|
|
/>
|
|
) : (
|
|
<div className={tableStyles.tableSection}>
|
|
<DataTable<AlertDto>
|
|
columns={columns}
|
|
data={rows}
|
|
sortable
|
|
flush
|
|
rowAccent={(row) => row.severity ? severityToAccent(row.severity) : undefined}
|
|
expandedContent={renderAlertExpanded}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **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) <noreply@anthropic.com>
|
|
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<AlertDto['state']>;
|
|
|
|
const STATE_FILTERS: Record<string, { label: string; values: AlertState[] }> = {
|
|
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<string>('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<AlertDto>[] = [
|
|
{
|
|
key: 'severity', header: 'Severity', width: '110px',
|
|
render: (_, row) => row.severity ? <SeverityBadge severity={row.severity} /> : null,
|
|
},
|
|
{
|
|
key: 'state', header: 'State', width: '140px',
|
|
render: (_, row) => row.state ? <AlertStateChip state={row.state} silenced={row.silenced} /> : null,
|
|
},
|
|
{
|
|
key: 'title', header: 'Title',
|
|
render: (_, row) => (
|
|
<div className={css.titleCell}>
|
|
<Link to={`/alerts/inbox/${row.id}`} onClick={() => markRead.mutate(row.id)}>
|
|
{row.title ?? '(untitled)'}
|
|
</Link>
|
|
{row.message && <span className={css.titlePreview}>{row.message}</span>}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'firedAt', header: 'Fired at', width: '140px', sortable: true,
|
|
render: (_, row) =>
|
|
row.firedAt ? (
|
|
<span title={row.firedAt} style={{ fontVariantNumeric: 'tabular-nums' }}>
|
|
{formatRelativeTime(row.firedAt)}
|
|
</span>
|
|
) : '—',
|
|
},
|
|
];
|
|
|
|
if (isLoading) return <PageLoader />;
|
|
if (error) return <div className={css.page}>Failed to load alerts: {String(error)}</div>;
|
|
|
|
return (
|
|
<div className={css.page}>
|
|
<div className={css.toolbar}>
|
|
<SectionHeader>All alerts</SectionHeader>
|
|
</div>
|
|
|
|
<div className={css.filterBar}>
|
|
<SegmentedTabs
|
|
tabs={Object.entries(STATE_FILTERS).map(([value, f]) => ({ value, label: f.label }))}
|
|
active={filterKey}
|
|
onChange={setFilterKey}
|
|
/>
|
|
</div>
|
|
|
|
{rows.length === 0 ? (
|
|
<EmptyState
|
|
icon={<Bell size={32} />}
|
|
title="No alerts match this filter"
|
|
description={`Try switching to a different state or widening your criteria.`}
|
|
/>
|
|
) : (
|
|
<div className={tableStyles.tableSection}>
|
|
<DataTable<AlertDto>
|
|
columns={columns}
|
|
data={rows}
|
|
sortable
|
|
flush
|
|
rowAccent={(row) => row.severity ? severityToAccent(row.severity) : undefined}
|
|
expandedContent={renderAlertExpanded}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **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) <noreply@anthropic.com>
|
|
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<AlertDto>[] = [
|
|
{
|
|
key: 'severity', header: 'Severity', width: '110px',
|
|
render: (_, row) => row.severity ? <SeverityBadge severity={row.severity} /> : null,
|
|
},
|
|
{
|
|
key: 'title', header: 'Title',
|
|
render: (_, row) => (
|
|
<div className={css.titleCell}>
|
|
<Link to={`/alerts/inbox/${row.id}`}>{row.title ?? '(untitled)'}</Link>
|
|
{row.message && <span className={css.titlePreview}>{row.message}</span>}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'firedAt', header: 'Fired at', width: '140px', sortable: true,
|
|
render: (_, row) =>
|
|
row.firedAt ? (
|
|
<span title={row.firedAt} style={{ fontVariantNumeric: 'tabular-nums' }}>
|
|
{formatRelativeTime(row.firedAt)}
|
|
</span>
|
|
) : '—',
|
|
},
|
|
{
|
|
key: 'resolvedAt', header: 'Resolved at', width: '140px', sortable: true,
|
|
render: (_, row) =>
|
|
row.resolvedAt ? (
|
|
<span title={row.resolvedAt} style={{ fontVariantNumeric: 'tabular-nums' }}>
|
|
{formatRelativeTime(row.resolvedAt)}
|
|
</span>
|
|
) : '—',
|
|
},
|
|
{
|
|
key: 'duration', header: 'Duration', width: '90px',
|
|
render: (_, row) => formatDuration(row.firedAt, row.resolvedAt),
|
|
},
|
|
];
|
|
|
|
if (isLoading) return <PageLoader />;
|
|
if (error) return <div className={css.page}>Failed to load history: {String(error)}</div>;
|
|
|
|
return (
|
|
<div className={css.page}>
|
|
<div className={css.toolbar}>
|
|
<SectionHeader>History</SectionHeader>
|
|
</div>
|
|
|
|
<div className={css.filterBar}>
|
|
<DateRangePicker value={dateRange} onChange={setDateRange} />
|
|
</div>
|
|
|
|
{filtered.length === 0 ? (
|
|
<EmptyState
|
|
icon={<History size={32} />}
|
|
title="No resolved alerts"
|
|
description="Nothing in the selected date range. Try widening it."
|
|
/>
|
|
) : (
|
|
<div className={tableStyles.tableSection}>
|
|
<DataTable<AlertDto>
|
|
columns={columns}
|
|
data={filtered}
|
|
sortable
|
|
flush
|
|
rowAccent={(row) => row.severity ? severityToAccent(row.severity) : undefined}
|
|
expandedContent={renderAlertExpanded}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
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) <noreply@anthropic.com>
|
|
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<AlertRuleResponse | null>(null);
|
|
|
|
if (isLoading) return <PageLoader />;
|
|
if (error) return <div className={css.page}>Failed to load rules: {String(error)}</div>;
|
|
|
|
const rows = rules ?? [];
|
|
const otherEnvs = (envs ?? []).filter((e) => e.slug !== env);
|
|
|
|
const onToggle = async (r: AlertRuleResponse) => {
|
|
try {
|
|
await setEnabled.mutateAsync({ id: r.id, enabled: !r.enabled });
|
|
toast({ title: r.enabled ? 'Disabled' : 'Enabled', description: r.name, variant: 'success' });
|
|
} catch (e) {
|
|
toast({ title: 'Toggle failed', description: String(e), variant: 'error' });
|
|
}
|
|
};
|
|
|
|
const 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<AlertRuleResponse>[] = [
|
|
{
|
|
key: 'name', header: 'Name',
|
|
render: (_, r) => <Link to={`/alerts/rules/${r.id}`}>{r.name}</Link>,
|
|
},
|
|
{
|
|
key: 'conditionKind', header: 'Kind', width: '160px',
|
|
render: (_, r) => <Badge label={r.conditionKind} color="auto" variant="outlined" />,
|
|
},
|
|
{
|
|
key: 'severity', header: 'Severity', width: '110px',
|
|
render: (_, r) => <SeverityBadge severity={r.severity} />,
|
|
},
|
|
{
|
|
key: 'enabled', header: 'Enabled', width: '90px',
|
|
render: (_, r) => (
|
|
<Toggle
|
|
checked={r.enabled}
|
|
onChange={() => onToggle(r)}
|
|
disabled={setEnabled.isPending}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: 'targets', header: 'Targets', width: '90px',
|
|
render: (_, r) => String(r.targets.length),
|
|
},
|
|
{
|
|
key: 'actions', header: '', width: '220px',
|
|
render: (_, r) => (
|
|
<div style={{ display: 'flex', gap: 'var(--space-sm)', justifyContent: 'flex-end' }}>
|
|
{otherEnvs.length > 0 && (
|
|
<Dropdown
|
|
trigger={<Button variant="ghost" size="sm">Promote to ▾</Button>}
|
|
items={otherEnvs.map((e) => ({
|
|
label: e.slug,
|
|
onClick: () => onPromote(r, e.slug),
|
|
}))}
|
|
/>
|
|
)}
|
|
<Button variant="ghost" size="sm" onClick={() => setPendingDelete(r)} disabled={deleteRule.isPending}>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className={css.page}>
|
|
<div className={css.toolbar}>
|
|
<SectionHeader
|
|
action={
|
|
<Link to="/alerts/rules/new">
|
|
<Button variant="primary">New rule</Button>
|
|
</Link>
|
|
}
|
|
>
|
|
Alert rules
|
|
</SectionHeader>
|
|
</div>
|
|
|
|
{rows.length === 0 ? (
|
|
<EmptyState
|
|
icon={<FilePlus size={32} />}
|
|
title="No alert rules"
|
|
description="Create one to start evaluating alerts for this environment."
|
|
action={
|
|
<Link to="/alerts/rules/new">
|
|
<Button variant="primary">Create rule</Button>
|
|
</Link>
|
|
}
|
|
/>
|
|
) : (
|
|
<div className={tableStyles.tableSection}>
|
|
<DataTable<AlertRuleResponse>
|
|
columns={columns}
|
|
data={rows}
|
|
flush
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
open={!!pendingDelete}
|
|
onClose={() => 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}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **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 <table> with DataTable, raw <select> promote control with
|
|
DS Dropdown, and native confirm() delete with ConfirmDialog. Adds DS
|
|
EmptyState with CTA for the no-rules case. Uses SectionHeader's
|
|
action slot instead of ad-hoc flex wrapper.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Rewrite `SilencesPage.tsx`
|
|
|
|
**Files:**
|
|
- Modify: `ui/src/pages/Alerts/SilencesPage.tsx`
|
|
|
|
- [ ] **Step 1: Replace entire file**
|
|
|
|
Overwrite `ui/src/pages/Alerts/SilencesPage.tsx`:
|
|
|
|
```tsx
|
|
import { useState } from 'react';
|
|
import { BellOff } from 'lucide-react';
|
|
import {
|
|
Button, FormField, Input, SectionHeader, useToast, DataTable,
|
|
EmptyState, ConfirmDialog, MonoText,
|
|
} from '@cameleer/design-system';
|
|
import type { Column } 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';
|
|
import tableStyles from '../../styles/table-section.module.css';
|
|
import css from './alerts-page.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);
|
|
const [pendingEnd, setPendingEnd] = useState<AlertSilenceResponse | null>(null);
|
|
|
|
if (isLoading) return <PageLoader />;
|
|
if (error) return <div className={css.page}>Failed to load silences: {String(error)}</div>;
|
|
|
|
const rows = data ?? [];
|
|
|
|
const onCreate = async () => {
|
|
const now = new Date();
|
|
const endsAt = new Date(now.getTime() + hours * 3600_000);
|
|
const matcher: Record<string, string> = {};
|
|
if (matcherRuleId) matcher.ruleId = matcherRuleId;
|
|
if (matcherAppSlug) matcher.appSlug = matcherAppSlug;
|
|
if (Object.keys(matcher).length === 0) {
|
|
toast({ title: 'Silence needs at least one matcher field', variant: 'error' });
|
|
return;
|
|
}
|
|
try {
|
|
await create.mutateAsync({
|
|
matcher,
|
|
reason: reason || undefined,
|
|
startsAt: now.toISOString(),
|
|
endsAt: endsAt.toISOString(),
|
|
});
|
|
setReason('');
|
|
setMatcherRuleId('');
|
|
setMatcherAppSlug('');
|
|
setHours(1);
|
|
toast({ title: 'Silence created', variant: 'success' });
|
|
} catch (e) {
|
|
toast({ title: 'Create failed', description: String(e), variant: 'error' });
|
|
}
|
|
};
|
|
|
|
const confirmEnd = async () => {
|
|
if (!pendingEnd) return;
|
|
try {
|
|
await remove.mutateAsync(pendingEnd.id!);
|
|
toast({ title: 'Silence removed', variant: 'success' });
|
|
} catch (e) {
|
|
toast({ title: 'Remove failed', description: String(e), variant: 'error' });
|
|
} finally {
|
|
setPendingEnd(null);
|
|
}
|
|
};
|
|
|
|
const columns: Column<AlertSilenceResponse>[] = [
|
|
{
|
|
key: 'matcher', header: 'Matcher',
|
|
render: (_, s) => <MonoText size="xs">{JSON.stringify(s.matcher)}</MonoText>,
|
|
},
|
|
{ key: 'reason', header: 'Reason', render: (_, s) => s.reason ?? '—' },
|
|
{ key: 'startsAt', header: 'Starts', width: '200px' },
|
|
{ key: 'endsAt', header: 'Ends', width: '200px' },
|
|
{
|
|
key: 'actions', header: '', width: '90px',
|
|
render: (_, s) => (
|
|
<Button variant="ghost" size="sm" onClick={() => setPendingEnd(s)}>
|
|
End
|
|
</Button>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className={css.page}>
|
|
<div className={css.toolbar}>
|
|
<SectionHeader>Alert silences</SectionHeader>
|
|
</div>
|
|
|
|
<section className={sectionStyles.section}>
|
|
<div
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(4, minmax(0, 1fr)) auto',
|
|
gap: 'var(--space-sm)',
|
|
alignItems: 'end',
|
|
}}
|
|
>
|
|
<FormField label="Rule ID" hint="Exact rule id (optional)">
|
|
<Input value={matcherRuleId} onChange={(e) => setMatcherRuleId(e.target.value)} />
|
|
</FormField>
|
|
<FormField label="App slug" hint="App slug (optional)">
|
|
<Input value={matcherAppSlug} onChange={(e) => setMatcherAppSlug(e.target.value)} />
|
|
</FormField>
|
|
<FormField label="Duration" hint="Hours">
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
value={hours}
|
|
onChange={(e) => setHours(Number(e.target.value))}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Reason" hint="Context for operators">
|
|
<Input
|
|
value={reason}
|
|
onChange={(e) => setReason(e.target.value)}
|
|
placeholder="Maintenance window"
|
|
/>
|
|
</FormField>
|
|
<Button variant="primary" size="sm" onClick={onCreate} disabled={create.isPending}>
|
|
Create silence
|
|
</Button>
|
|
</div>
|
|
</section>
|
|
|
|
{rows.length === 0 ? (
|
|
<EmptyState
|
|
icon={<BellOff size={32} />}
|
|
title="No silences"
|
|
description="Nothing is currently silenced in this environment."
|
|
/>
|
|
) : (
|
|
<div className={tableStyles.tableSection}>
|
|
<DataTable<AlertSilenceResponse>
|
|
columns={columns}
|
|
data={rows.map((s) => ({ ...s, id: s.id ?? '' }))}
|
|
flush
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
open={!!pendingEnd}
|
|
onClose={() => 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}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **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 <table> 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) <noreply@anthropic.com>
|
|
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) <noreply@anthropic.com>
|
|
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 `<Alert>` 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) <noreply@anthropic.com>
|
|
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 (
|
|
<div className={css.wizard}>
|
|
<div className={css.header}>
|
|
<SectionHeader>{isEdit ? `Edit rule: ${form.name}` : 'New alert rule'}</SectionHeader>
|
|
</div>
|
|
|
|
{promoteFrom && (
|
|
<Alert variant="info" title="Promoting a rule">
|
|
Promoting from <code>{promoteFrom}</code> — review and adjust, then save.
|
|
</Alert>
|
|
)}
|
|
|
|
{warnings.length > 0 && (
|
|
<Alert variant="warning" title="Review before saving">
|
|
<ul style={{ margin: '4px 0 0 16px', padding: 0 }}>
|
|
{warnings.map((w) => (
|
|
<li key={w.field}>
|
|
<code>{w.field}</code>: {w.message}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</Alert>
|
|
)}
|
|
|
|
<nav className={css.steps}>
|
|
{WIZARD_STEPS.map((s, i) => (
|
|
<button
|
|
key={s}
|
|
type="button"
|
|
className={`${css.step} ${step === s ? css.stepActive : ''} ${i < idx ? css.stepDone : ''}`}
|
|
onClick={() => setStep(s)}
|
|
>
|
|
{STEP_LABELS[s]}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
<section className={`${sectionStyles.section} ${css.stepBody}`}>{body}</section>
|
|
|
|
<div className={css.footer}>
|
|
<Button variant="secondary" onClick={onBack} disabled={idx === 0}>
|
|
Back
|
|
</Button>
|
|
{idx < WIZARD_STEPS.length - 1 ? (
|
|
<Button variant="primary" onClick={onNext}>
|
|
Next
|
|
</Button>
|
|
) : (
|
|
<Button variant="primary" onClick={onSave} disabled={create.isPending || update.isPending}>
|
|
{isEdit ? 'Save changes' : 'Create rule'}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 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) <noreply@anthropic.com>
|
|
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) <noreply@anthropic.com>
|
|
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 `<table>` in alerts**
|
|
|
|
```bash
|
|
grep -rn "<table" ui/src/pages/Alerts/
|
|
```
|
|
Expected: no matches (all replaced by DataTable).
|
|
|
|
- [ ] **Step 6: Grep sanity — no `confirm(` in alerts**
|
|
|
|
```bash
|
|
grep -rn "confirm(" ui/src/pages/Alerts/
|
|
```
|
|
Expected: no matches.
|
|
|
|
- [ ] **Step 7: Backend smoke (no changes expected to break it)**
|
|
|
|
```bash
|
|
mvn -pl cameleer-server-app -am -DskipTests package -q
|
|
```
|
|
Expected: exits 0.
|
|
|
|
- [ ] **Step 8: Start backend + frontend, visually smoke all five pages**
|
|
|
|
In one terminal:
|
|
|
|
```bash
|
|
java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar
|
|
```
|
|
|
|
Wait for `Started` log line. In another terminal:
|
|
|
|
```bash
|
|
cd ui && npm run dev
|
|
```
|
|
|
|
Open http://localhost:5173 and walk through:
|
|
|
|
1. Log in (admin / admin) and pick `default` env.
|
|
2. Sidebar → Alerts → **Inbox** — empty state if no alerts; otherwise DataTable with rows. Click a row → expands. Toggle light/dark via theme switch — cards should keep DS shadows, no bleed-through borders.
|
|
3. **All** — `SegmentedTabs` filter switches cleanly; rows update.
|
|
4. **History** — `DateRangePicker` renders; empty state shows sane message.
|
|
5. **Rules** — list renders as table; `New rule` button goes to wizard; wizard promote banner (navigate to `/alerts/rules/new?promoteFrom=default&ruleId=<any>&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.
|