Files
cameleer-server/docs/superpowers/plans/2026-04-21-alerts-design-system-alignment.md
hsiegeln 52a08a8769 docs(alerts): Implementation plan — design-system alignment for /alerts pages
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>
2026-04-21 09:49:47 +02:00

57 KiB

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 Alerts; 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
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
cd ui && npm install

Expected: exits 0. @cameleer/design-system@0.1.56 in node_modules.

  • Step 3: Run UI unit tests baseline
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)
mvn -pl cameleer-server-app -am compile -q

Expected: exits 0.

  • Step 5: Inspect DS tokens
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:

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
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:

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
cd ui && npm run test -- --run src/pages/Alerts/severity-utils.test.ts

Expected: 3 tests pass.

  • Step 5: Commit
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:

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
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:

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
cd ui && npm run test -- --run src/pages/Alerts/time-utils.test.ts

Expected: 6 tests pass.

  • Step 5: Commit
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:

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
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
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:

.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
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
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:

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
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
cd ui && npm run test -- --run

Expected: no regressions; same or higher count than Task 0 Step 3 baseline.

  • Step 4: Commit
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:

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
cd ui && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "AllAlertsPage\.tsx"

Expected: no output.

  • Step 3: Commit
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:

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
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
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:

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
cd ui && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "RulesListPage\.tsx"

Expected: no output.

  • Step 3: Commit
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:

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
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
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

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
git rm ui/src/pages/Alerts/AlertRow.tsx
  • Step 3: Typecheck
cd ui && npx tsc --noEmit -p tsconfig.app.json

Expected: exits 0.

  • Step 4: Commit
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:

.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
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:

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:

  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
cd ui && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "RuleEditorWizard\.tsx"

Expected: no output.

  • Step 3: Unit tests still green
cd ui && npm run test -- --run

Expected: no regressions.

  • Step 4: Commit
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:

    // 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:

    // 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:

    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:

    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
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
cd ui && npx tsc --noEmit -p tsconfig.app.json

Expected: exits 0.

  • Step 2: UI unit tests
cd ui && npm run test -- --run

Expected: all pass, count ≥ baseline + 9 (3 severity-utils + 6 time-utils).

  • Step 3: Production build
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
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
grep -rn "<table" ui/src/pages/Alerts/

Expected: no matches (all replaced by DataTable).

  • Step 6: Grep sanity — no confirm( in alerts
grep -rn "confirm(" ui/src/pages/Alerts/

Expected: no matches.

  • Step 7: Backend smoke (no changes expected to break it)
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:

java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar

Wait for Started log line. In another terminal:

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. AllSegmentedTabs filter switches cleanly; rows update.
  4. HistoryDateRangePicker 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; EndConfirmDialog.
  • Step 9: Run Playwright e2e
cd ui && npm run test:e2e

If test:e2e script doesn't exist, run:

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):

# 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.