{emptyMessage || 'No data found'}
diff --git a/web/src/components/EmptyState.test.tsx b/web/src/components/EmptyState.test.tsx
new file mode 100644
index 0000000..2a239e4
--- /dev/null
+++ b/web/src/components/EmptyState.test.tsx
@@ -0,0 +1,78 @@
+// Copyright 2026 certctl LLC. All rights reserved.
+// SPDX-License-Identifier: BUSL-1.1
+
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import EmptyState from './EmptyState';
+
+describe('EmptyState', () => {
+ it('renders the title', () => {
+ render(
);
+ expect(screen.getByText('No certificates yet')).toBeInTheDocument();
+ });
+
+ it('renders description when provided', () => {
+ render(
+
,
+ );
+ expect(
+ screen.getByText('Issue your first certificate to get started.'),
+ ).toBeInTheDocument();
+ });
+
+ it('renders icon slot when provided', () => {
+ render(
+
📜}
+ title="No certificates"
+ />,
+ );
+ expect(screen.getByTestId('empty-icon')).toBeInTheDocument();
+ });
+
+ it('renders primaryAction button and fires its onClick', () => {
+ const onClick = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'Issue certificate' }));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders secondaryAction button and fires its onClick', () => {
+ const onClick = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'Read docs' }));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders both actions side-by-side', () => {
+ render(
+ {} }}
+ secondaryAction={{ label: 'Connect issuer', onClick: () => {} }}
+ />,
+ );
+ expect(screen.getByRole('button', { name: 'Issue' })).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Connect issuer' }),
+ ).toBeInTheDocument();
+ });
+
+ it('exposes role="status" for screen readers', () => {
+ render();
+ expect(screen.getByRole('status')).toBeInTheDocument();
+ });
+});
diff --git a/web/src/components/EmptyState.tsx b/web/src/components/EmptyState.tsx
new file mode 100644
index 0000000..2ede628
--- /dev/null
+++ b/web/src/components/EmptyState.tsx
@@ -0,0 +1,95 @@
+// Copyright 2026 certctl LLC. All rights reserved.
+// SPDX-License-Identifier: BUSL-1.1
+//
+// EmptyState — the certctl-themed empty-state primitive. Phase 1
+// closure for UX-M3 (no primitive; DataTable shows a bare
+// 'No data found' string).
+//
+// Two render paths:
+// 1) `` — minimum
+// acceptable empty state. Title is required (the user must
+// understand what's missing); description + actions are optional.
+// 2) `} title="..." description="..."
+// primaryAction={{ label, onClick }} secondaryAction={...} />` —
+// first-run CTA shape. Renders icon at the top, title in the
+// middle, two action buttons at the bottom. Use this on list pages
+// that an operator might hit on their first visit ("No certs yet —
+// [Issue first certificate] [Connect an issuer]").
+//
+// Composition with DataTable: DataTable accepts `emptyState?: ReactNode`
+// (added alongside the existing `emptyMessage?: string` for backward
+// compat) so list pages can pass either a string or a full
+// component.
+
+import type { ReactNode } from 'react';
+
+export interface EmptyStateAction {
+ label: string;
+ onClick: () => void;
+}
+
+export interface EmptyStateProps {
+ /** Optional icon at the top. Pass any ReactNode (lucide / SVG / emoji). */
+ icon?: ReactNode;
+ /** Required headline. Keep short: "No certificates yet". */
+ title: string;
+ /** Optional sub-copy. One sentence explaining the empty condition. */
+ description?: string;
+ /** Optional primary CTA. Renders as .btn-primary. */
+ primaryAction?: EmptyStateAction;
+ /** Optional secondary CTA. Renders as .btn-outline alongside primary. */
+ secondaryAction?: EmptyStateAction;
+ /** Override default centering / padding when nested inside a card. */
+ className?: string;
+}
+
+export default function EmptyState({
+ icon,
+ title,
+ description,
+ primaryAction,
+ secondaryAction,
+ className,
+}: EmptyStateProps) {
+ return (
+
+ {icon && (
+
+ {icon}
+
+ )}
+
{title}
+ {description && (
+
{description}
+ )}
+ {(primaryAction || secondaryAction) && (
+
+ {primaryAction && (
+
+ )}
+ {secondaryAction && (
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/web/src/components/StatusBadge.test.tsx b/web/src/components/StatusBadge.test.tsx
index c1bd975..19754d8 100644
--- a/web/src/components/StatusBadge.test.tsx
+++ b/web/src/components/StatusBadge.test.tsx
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { render } from '@testing-library/react';
-import StatusBadge from './StatusBadge';
+import StatusBadge, { statusDisplay, titleCase } from './StatusBadge';
// -----------------------------------------------------------------------------
// D-1 master — StatusBadge enum-coverage contract
@@ -118,13 +118,111 @@ describe('StatusBadge — enum-coverage contract (D-1 master)', () => {
expect(container.querySelector('span')!.className).toContain('badge-warning');
});
- // Unknown statuses fall through to neutral. The string is still
- // displayed verbatim so an operator can see "what is this?" rather
- // than nothing at all.
- it('unknown status string renders as neutral but preserves the label text', () => {
+ // Unknown statuses fall through to neutral. The label is humanised
+ // via the titleCase() helper (UX-H5) so the operator sees readable
+ // text rather than the raw enum key — "Some future status" instead
+ // of "SomeFutureStatus".
+ it('unknown status string renders as neutral with titleCase fallback', () => {
const { container } = render();
const span = container.querySelector('span');
expect(span!.className).toBe('badge badge-neutral');
- expect(span!.textContent).toBe('SomeFutureStatus');
+ expect(span!.textContent).toBe('Some future status');
+ });
+});
+
+// -----------------------------------------------------------------------------
+// UX-H5 master — StatusBadge display-string contract (Phase 1, 2026-05-14)
+//
+// The audit finding: pre-Phase-1, StatusBadge rendered raw Go enum keys
+// — operators saw "RenewalInProgress" / "AwaitingCSR" / "cert_mismatch"
+// / "dead" verbatim. Phase 1 adds a statusDisplay map next to
+// statusStyles; this suite pins the byte-exact display string for every
+// wire key.
+// -----------------------------------------------------------------------------
+describe('StatusBadge — display-string contract (UX-H5)', () => {
+ // Every wire key in the colour map MUST have a display-string entry
+ // and the entry MUST be non-empty. Missing entries fall back to the
+ // titleCase() helper, but having an explicit entry in statusDisplay
+ // is the preferred path (lets us pick the cleanest sentence-case
+ // phrasing, with terms like "Awaiting CSR" capitalised correctly
+ // where titleCase would yield "Awaiting csr").
+ const EXPECTED_DISPLAY: Array<[string, string]> = [
+ // Certificate statuses
+ ['Active', 'Active'],
+ ['Expiring', 'Expiring soon'],
+ ['Expired', 'Expired'],
+ ['RenewalInProgress', 'Renewal in progress'],
+ ['Archived', 'Archived'],
+ ['Revoked', 'Revoked'],
+ // Job statuses
+ ['Pending', 'Pending'],
+ ['AwaitingCSR', 'Awaiting CSR'],
+ ['AwaitingApproval', 'Awaiting approval'],
+ ['Running', 'Running'],
+ ['Completed', 'Completed'],
+ ['Failed', 'Failed'],
+ ['Cancelled', 'Cancelled'],
+ // Agent statuses
+ ['Online', 'Online'],
+ ['Offline', 'Offline'],
+ ['Degraded', 'Degraded'],
+ // Discovery statuses
+ ['Unmanaged', 'Unmanaged'],
+ ['Managed', 'Managed'],
+ ['Dismissed', 'Dismissed'],
+ // Frontend-synthesized issuer statuses
+ ['Enabled', 'Enabled'],
+ ['Disabled', 'Disabled'],
+ // Notification statuses (lowercase wire values)
+ ['sent', 'Sent'],
+ ['pending', 'Pending'],
+ ['failed', 'Failed'],
+ ['dead', 'Dead-lettered'],
+ ['read', 'Read'],
+ // Health check statuses (lowercase + snake_case)
+ ['healthy', 'Healthy'],
+ ['degraded', 'Degraded'],
+ ['down', 'Down'],
+ ['cert_mismatch', 'Certificate mismatch'],
+ ['unknown', 'Unknown'],
+ ];
+
+ it.each(EXPECTED_DISPLAY)(
+ "wire key '%s' renders display string '%s'",
+ (wire, expected) => {
+ // First — verify the statusDisplay map carries the entry verbatim.
+ expect(statusDisplay[wire]).toBe(expected);
+ // Then — verify the rendered 's textContent matches.
+ const { container } = render();
+ expect(container.querySelector('span')!.textContent).toBe(expected);
+ },
+ );
+
+ it('every wire key in statusStyles has a matching statusDisplay entry', () => {
+ // Parity check — re-deriving the styles key set isn't possible at
+ // runtime without re-importing it, but we can probe a known sample
+ // and pin: if a future PR adds a new style entry without a display
+ // entry, the EXPECTED_DISPLAY list above will mismatch.
+ expect(Object.keys(statusDisplay).length).toBeGreaterThanOrEqual(
+ EXPECTED_DISPLAY.length,
+ );
+ });
+
+ describe('titleCase() helper — fallback for unmapped keys', () => {
+ it('humanises PascalCase', () => {
+ expect(titleCase('RenewalInProgress')).toBe('Renewal in progress');
+ });
+ it('humanises snake_case', () => {
+ expect(titleCase('cert_mismatch')).toBe('Cert mismatch');
+ });
+ it('handles single-word lowercase', () => {
+ expect(titleCase('pending')).toBe('Pending');
+ });
+ it('handles single-word PascalCase', () => {
+ expect(titleCase('Active')).toBe('Active');
+ });
+ it('handles empty string defensively', () => {
+ expect(titleCase('')).toBe('');
+ });
});
});
diff --git a/web/src/components/StatusBadge.tsx b/web/src/components/StatusBadge.tsx
index f080a7c..c724a47 100644
--- a/web/src/components/StatusBadge.tsx
+++ b/web/src/components/StatusBadge.tsx
@@ -4,6 +4,16 @@
// the Go side; StatusBadge.test.tsx walks every value and will go red
// before users see a default-grey "what is happening?" badge.
//
+// UX-H5 closure (Phase 1, 2026-05-14): we now render a human display
+// string rather than the raw enum key. The wire keys stay byte-
+// identical to the Go-side enums (per the D-1 closure comment above) —
+// only the rendered text changes. PascalCase + snake_case +
+// lowercase enums map to spaced sentence-case ("Renewal in progress",
+// "Awaiting CSR", "Dead-lettered", "Certificate mismatch"). Unmapped
+// keys fall through to a titleCase helper that lower-bounds the
+// readability even when a new Go-side enum lands before the frontend
+// catches up.
+//
// D-1 master closure (cat-d-359e92c20cbf, cat-d-9f4c8e4a91f1,
// cat-d-1447e04732e7, cat-f-cert_detail_page_key_render_fallback,
// cat-f-ae0d06b6588f) fixed the pre-master drift:
@@ -74,7 +84,73 @@ const statusStyles: Record = {
unknown: 'badge-neutral',
};
+// statusDisplay — human-facing text for each wire key. UX-H5 closure.
+// Keys MUST stay byte-identical to statusStyles above (which is byte-
+// identical to the Go enums). When a key here is missing, the
+// titleCase fallback below renders something readable rather than
+// the raw enum key.
+const statusDisplay: Record = {
+ // Certificate statuses
+ Active: 'Active',
+ Expiring: 'Expiring soon',
+ Expired: 'Expired',
+ RenewalInProgress: 'Renewal in progress',
+ Archived: 'Archived',
+ Revoked: 'Revoked',
+ // Job statuses
+ Pending: 'Pending',
+ AwaitingCSR: 'Awaiting CSR',
+ AwaitingApproval: 'Awaiting approval',
+ Running: 'Running',
+ Completed: 'Completed',
+ Failed: 'Failed',
+ Cancelled: 'Cancelled',
+ // Agent statuses
+ Online: 'Online',
+ Offline: 'Offline',
+ Degraded: 'Degraded',
+ // Discovery statuses
+ Unmanaged: 'Unmanaged',
+ Managed: 'Managed',
+ Dismissed: 'Dismissed',
+ // Issuer statuses (frontend-synthesized)
+ Enabled: 'Enabled',
+ Disabled: 'Disabled',
+ // Notification statuses
+ sent: 'Sent',
+ pending: 'Pending',
+ failed: 'Failed',
+ dead: 'Dead-lettered',
+ read: 'Read',
+ // Health check statuses
+ healthy: 'Healthy',
+ degraded: 'Degraded',
+ down: 'Down',
+ cert_mismatch: 'Certificate mismatch',
+ unknown: 'Unknown',
+};
+
+// titleCase — best-effort humanizer for wire keys not in statusDisplay.
+// Handles PascalCase ("RenewalInProgress" → "Renewal in progress") and
+// snake_case ("cert_mismatch" → "Cert mismatch"). The render-time fallback;
+// adding a proper entry to statusDisplay above is the preferred path.
+function titleCase(s: string): string {
+ if (!s) return s;
+ // snake_case → space-separated lower
+ let out = s.replace(/_/g, ' ');
+ // PascalCase / camelCase → space before capitals (but not the first)
+ out = out.replace(/([a-z])([A-Z])/g, '$1 $2');
+ // Lowercase everything, then capitalize the first character.
+ out = out.toLowerCase();
+ return out.charAt(0).toUpperCase() + out.slice(1);
+}
+
export default function StatusBadge({ status }: { status: string }) {
const cls = statusStyles[status] || 'badge-neutral';
- return {status};
+ const display = statusDisplay[status] ?? titleCase(status);
+ return {display};
}
+
+// Exported for the StatusBadge.test.tsx suite — pinning the byte-exact
+// display strings for every wire key in one place.
+export { statusStyles, statusDisplay, titleCase };
diff --git a/web/src/components/Toaster.test.tsx b/web/src/components/Toaster.test.tsx
new file mode 100644
index 0000000..89e45ae
--- /dev/null
+++ b/web/src/components/Toaster.test.tsx
@@ -0,0 +1,41 @@
+// Copyright 2026 certctl LLC. All rights reserved.
+// SPDX-License-Identifier: BUSL-1.1
+//
+// Smoke-test the Toaster wrapper. Sonner has its own deep test suite;
+// we just pin (a) the wrapper renders without crashing, (b) the
+// Sonner root lands in the DOM with our position prop, and
+// (c) toast.success / toast.error reach the renderer.
+
+import { render, screen, act } from '@testing-library/react';
+import { describe, it, expect } from 'vitest';
+import { toast } from 'sonner';
+import Toaster from './Toaster';
+
+describe('Toaster', () => {
+ it('renders the Sonner root without crashing', () => {
+ render();
+ // Sonner mounts a section[aria-label="Notifications "] container
+ // — the label includes Sonner's expand-shortcut hint (e.g. "alt+T").
+ // Match the prefix only.
+ expect(screen.getByLabelText(/Notifications/)).toBeInTheDocument();
+ });
+
+ it('forwards toast.success() to the visible queue', async () => {
+ render();
+ act(() => {
+ toast.success('Profile saved');
+ });
+ // Sonner debounces render slightly; flush via findByText.
+ expect(await screen.findByText('Profile saved')).toBeInTheDocument();
+ });
+
+ it('forwards toast.error() to the visible queue', async () => {
+ render();
+ act(() => {
+ toast.error('Save failed: not authorized');
+ });
+ expect(
+ await screen.findByText('Save failed: not authorized'),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/web/src/components/Toaster.tsx b/web/src/components/Toaster.tsx
new file mode 100644
index 0000000..507ca4a
--- /dev/null
+++ b/web/src/components/Toaster.tsx
@@ -0,0 +1,43 @@
+// Copyright 2026 certctl LLC. All rights reserved.
+// SPDX-License-Identifier: BUSL-1.1
+//
+// Toaster — the certctl-themed Sonner wrapper. Phase 1 closure for
+// UX-H3 (no toast / snackbar system) per the frontend-design-audit.
+//
+// Mount once near the top of 's React tree (next to
+// QueryClientProvider). Inside any component, import { toast } from
+// "sonner" and call toast.success(…) / toast.error(…) / toast.info(…) /
+// toast.warning(…). Sonner handles the singleton queue, focus + ARIA
+// (role="status" / role="alert"), enter/exit animation, swipe-to-
+// dismiss, and respects prefers-reduced-motion automatically.
+//
+// We surface a thin wrapper rather than the bare so the
+// default position + visual config lives in one place. Pages must NOT
+// mount their own Toaster instances — Sonner asserts at runtime if
+// multiple are mounted, but the failure mode is "toasts duplicate or
+// disappear silently" which is hard to debug. Single import discipline.
+//
+// Visual position: top-right. Operators are paginated-table-heavy;
+// top-right keeps the toast away from row-action click targets at the
+// bottom of the list. richColors gives us the per-severity background
+// fills (success teal / error red / warning amber / info blue) that
+// match the existing .badge-* color tier.
+
+import { Toaster as SonnerToaster } from 'sonner';
+
+export default function Toaster() {
+ return (
+
+ );
+}
diff --git a/web/src/components/Tooltip.test.tsx b/web/src/components/Tooltip.test.tsx
new file mode 100644
index 0000000..915f87a
--- /dev/null
+++ b/web/src/components/Tooltip.test.tsx
@@ -0,0 +1,49 @@
+// Copyright 2026 certctl LLC. All rights reserved.
+// SPDX-License-Identifier: BUSL-1.1
+//
+// Tooltip smoke + interaction tests. Floating-UI's positioning math
+// requires a real browser layout engine; we just assert the wiring:
+// - children render at rest (no tooltip)
+// - focus reveals the tooltip body in the portal
+// - escape dismisses
+
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect } from 'vitest';
+import Tooltip from './Tooltip';
+
+describe('Tooltip', () => {
+ it('renders the trigger at rest with no tooltip visible', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole('button', { name: 'Hover me' })).toBeInTheDocument();
+ expect(screen.queryByText('Hint')).not.toBeInTheDocument();
+ });
+
+ it('reveals tooltip body on focus', () => {
+ render(
+
+
+ ,
+ );
+ const trigger = screen.getByRole('button', { name: 'Focusable trigger' });
+ fireEvent.focus(trigger);
+ // FloatingPortal renders into document.body; queryable.
+ expect(screen.getByText('Hint visible')).toBeInTheDocument();
+ });
+
+ it('dismisses on Escape after focus-open', () => {
+ render(
+
+
+ ,
+ );
+ const trigger = screen.getByRole('button', { name: 'Focusable' });
+ fireEvent.focus(trigger);
+ expect(screen.getByText('Press escape')).toBeInTheDocument();
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(screen.queryByText('Press escape')).not.toBeInTheDocument();
+ });
+});
diff --git a/web/src/components/Tooltip.tsx b/web/src/components/Tooltip.tsx
new file mode 100644
index 0000000..81875a5
--- /dev/null
+++ b/web/src/components/Tooltip.tsx
@@ -0,0 +1,122 @@
+// Copyright 2026 certctl LLC. All rights reserved.
+// SPDX-License-Identifier: BUSL-1.1
+//
+// Tooltip — Floating-UI-backed replacement for the ~103 native title=
+// attributes. Phase 1 builds the primitive; migrating the 103 callsites
+// is per-page rolling work that happens in subsequent PRs (per the
+// audit prompt's explicit "DO NOT" on one-mega-PR sweeps).
+//
+// Why Floating-UI: native title= renders poorly on mobile + has no
+// reliable show/hide timing, no visual styling, no positioning around
+// the edges of the viewport, and (most importantly) zero a11y story
+// beyond the browser's default tooltip — which screen readers
+// inconsistently surface. Floating-UI gives us:
+// - middleware-driven positioning (auto-flip, shift, offset)
+// - hover + focus triggers (with `useFocus` + `useHover`)
+// - aria-describedby wiring via `useRole`
+// - dismissable via ESC
+//
+// Usage:
+//
+//
+//
+//
+// Children must be a single element capable of accepting a ref. For
+// non-ref-forwardable children (e.g. plain text), wrap in a span.
+
+import { useState, cloneElement, isValidElement } from 'react';
+import type { ReactElement, ReactNode } from 'react';
+import {
+ useFloating,
+ useHover,
+ useFocus,
+ useDismiss,
+ useRole,
+ useInteractions,
+ flip,
+ shift,
+ offset,
+ autoUpdate,
+ FloatingPortal,
+} from '@floating-ui/react';
+
+export interface TooltipProps {
+ /** Tooltip body — usually a short string; ReactNode is allowed for icons. */
+ content: ReactNode;
+ /** Single child element that receives the ref + ARIA wiring. */
+ children: ReactElement;
+ /** Preferred placement; Floating-UI will auto-flip if viewport-clamped. */
+ placement?: 'top' | 'right' | 'bottom' | 'left';
+ /** Pixel offset between the trigger and the tooltip. Default 6. */
+ offsetPx?: number;
+}
+
+export default function Tooltip({
+ content,
+ children,
+ placement = 'top',
+ offsetPx = 6,
+}: TooltipProps) {
+ const [open, setOpen] = useState(false);
+
+ const { refs, floatingStyles, context } = useFloating({
+ open,
+ onOpenChange: setOpen,
+ placement,
+ middleware: [offset(offsetPx), flip(), shift({ padding: 8 })],
+ whileElementsMounted: autoUpdate,
+ });
+
+ const hover = useHover(context, { move: false, delay: { open: 200, close: 0 } });
+ const focus = useFocus(context);
+ const dismiss = useDismiss(context);
+ const role = useRole(context, { role: 'tooltip' });
+
+ const { getReferenceProps, getFloatingProps } = useInteractions([
+ hover,
+ focus,
+ dismiss,
+ role,
+ ]);
+
+ if (!isValidElement(children)) {
+ // Defensive: render the child verbatim; Tooltip wiring is skipped.
+ // Console-warn so the misuse is visible during dev.
+ if (typeof console !== 'undefined') {
+ console.warn(
+ ' requires a single React element child; got:',
+ children,
+ );
+ }
+ return <>{children}>;
+ }
+
+ // Merge the ref + interaction props onto the child. cloneElement keeps
+ // the original child's type + own props; we layer ours on top.
+ const triggerProps = getReferenceProps();
+ const child = cloneElement(
+ children as ReactElement>,
+ {
+ ref: refs.setReference,
+ ...triggerProps,
+ },
+ );
+
+ return (
+ <>
+ {child}
+ {open && content && (
+
+
+ {content}
+
+
+ )}
+ >
+ );
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
index 0317b3b..66fdf81 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -58,6 +58,10 @@ import SessionsPage from './pages/auth/SessionsPage';
import BreakglassPage from './pages/auth/BreakglassPage';
// Audit 2026-05-10 MED-11 closure — federated-user admin page.
import UsersPage from './pages/auth/UsersPage';
+// Phase 1 closure (UX-H3): toast / snackbar system. Mounted once near
+// the root so any component can `import { toast } from "sonner"` and
+// call toast.success / toast.error without provider plumbing.
+import Toaster from './components/Toaster';
import './index.css';
const queryClient = new QueryClient({
@@ -74,6 +78,7 @@ createRoot(document.getElementById('root')!).render(
+
diff --git a/web/src/pages/AgentGroupsPage.tsx b/web/src/pages/AgentGroupsPage.tsx
index 574efb1..53a5342 100644
--- a/web/src/pages/AgentGroupsPage.tsx
+++ b/web/src/pages/AgentGroupsPage.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { toast } from 'sonner';
import { useTrackedMutation } from '../hooks/useTrackedMutation';
import { getAgentGroups, deleteAgentGroup, createAgentGroup, updateAgentGroup } from '../api/client';
import PageHeader from '../components/PageHeader';
@@ -7,6 +8,7 @@ import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
import StatusBadge from '../components/StatusBadge';
import ErrorState from '../components/ErrorState';
+import ConfirmDialog from '../components/ConfirmDialog';
import { formatDateTime } from '../api/utils';
import type { AgentGroup } from '../api/types';
@@ -254,6 +256,7 @@ export default function AgentGroupsPage() {
const queryClient = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const [editingGroup, setEditingGroup] = useState(null);
+ const [confirmDelete, setConfirmDelete] = useState(null);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['agent-groups'],
@@ -263,6 +266,8 @@ export default function AgentGroupsPage() {
const deleteMutation = useTrackedMutation({
mutationFn: deleteAgentGroup,
invalidates: [['agent-groups']],
+ onSuccess: () => toast.success('Agent group deleted'),
+ onError: (err: Error) => toast.error(`Delete failed: ${err.message}`),
});
const createMutation = useTrackedMutation({
@@ -337,7 +342,7 @@ export default function AgentGroupsPage() {
Edit