feat(frontend): Phase 4 Loading + Perceived Performance — close UX-M1 + FE-M5 + PERF-M1 + P-H3 + partial FE-M3 / P-M2

Closes the Phase 4 batch from cowork/frontend-design-audit.html: skeleton
primitive, route-level lazy splitting + vendor manualChunks, mega-page
split (OnboardingWizard), targeted memoization for dashboard charts,
useTransition for filter-toolbar.

═════════════════════════ AUDIT VERIFICATION ═════════════════════════
Confirmed facts from the live repo before implementing (not the audit's
stamped numbers — those drifted):

  • Pre-Phase-4 index-*.js = 1,121,868 B raw / 288,238 B gz
    (audit said 980 KB / 247 KB — drifted UP since the audit was written)
  • React.lazy sites = 1 (CommandPaletteHost from Phase 3); zero route-
    level lazy boundaries before this commit
  • vite.config.ts had NO rollupOptions.output.manualChunks
  • Mega-page LOCs: OnboardingWizard 1043 / CertificateDetailPage 977 /
    SCEPAdminPage 806 / CertificatesPage 812 / ESTAdminPage 646
    (audit said 1033 / 936 / 806 / 751 / 646 — all grew due to Phase 1-3
    additions; still mega)
  • Memoization tally: React.memo 0, useMemo 22, useCallback 5,
    useTransition 0, useDeferredValue 0
  • DashboardPage useQuery sites = 9 (audit said 10 — overcount)
  • OnboardingWizard step structure = 4 step fns (issuer / agent /
    certificate / complete) + StepIndicator + WizardFooter +
    CodeBlock + 2 inline create modals. The audit's "6-way split"
    suggestion = 6 files post-split (shell + indicator/shell helpers
    + 4 step files), which is what this commit ships.

═════════════════════════════ CLOSURES ═══════════════════════════════

UX-M1 — Skeleton primitive (web/src/components/Skeleton.tsx, +6 tests)
  • Four variants: page / table / card / stat
  • Each uses Tailwind animate-pulse on layout-shaped divs so eventual
    content lands without CLS
  • role="status" + aria-busy="true" + aria-label for SR users
  • DataTable.tsx now uses Skeleton variant="table" with columns prop
    instead of the centered "Loading..." spinner — every DataTable
    consumer gets layout-shape-preserving loading without code changes.
    The skeleton sizes the table to the actual column count + adds a
    selectable-column slot when relevant.

FE-M5 + SCALE-H1 — route-level code split + vendor manualChunks
  • main.tsx: every page route except DashboardPage (landing route, kept
    eager) is now React.lazy() + wrapped in <Suspense fallback={
    <Skeleton variant="page" />}> via lazyRoute() helper. 35 lazy
    routes total.
  • OnboardingWizard is also lazy-imported inside DashboardPage —
    keeps its 29 KB step-form code off the dashboard hot path for every
    operator who already dismissed the first-run wizard.
  • vite.config.ts: rollupOptions.output.manualChunks splits
    react+react-dom (132 KB), react-router-dom (24 KB),
    @tanstack/react-query (28 KB), recharts (383 KB!), and lucide-react
    (16 KB) into named vendor chunks. Vite 8 rolldown requires the
    function-shape manualChunks (id) => string; not the Vite-5 object
    shape — confirmed against the actual build error before writing
    the function.

  Bundle profile (raw / gz):
    pre-Phase-4   single index-*.js = 1,121,868 / 288,238
    post-Phase-4  index-*.js        =    91,978 /  25,867   (-92% raw)
                  vendor-react      =   132,821 /  43,113
                  vendor-router     =    23,835 /   8,763
                  vendor-query      =    28,029 /   8,693
                  vendor-icons      =    15,663 /   6,149
                  vendor-recharts   =   382,953 / 110,251   (Dashboard-only)
                  per-route chunks  =    1.4-26 KB raw each

  Non-Dashboard cold load: vendor-react + vendor-router + vendor-query
  + vendor-icons + index + per-route chunk ≈ 95 KB gz first-load.
  Dashboard cold load adds vendor-recharts (110 KB gz) on demand.

  Audit target was <100 KB gz first-load for non-Dashboard routes — hit.

FE-M3 + P-M2 (partial) — OnboardingWizard mega-page split
  • 1043 LOC monolith → src/pages/OnboardingWizard.tsx (100 LOC shell) +
    src/pages/onboarding/{types.ts, StepShell.tsx, IssuerStep.tsx,
    AgentStep.tsx, CertificateStep.tsx, CompleteStep.tsx} (6 files,
    largest = CertificateStep at 504 LOC for the certificate form +
    two inline create-team/create-owner modals it owns).
  • Behavior preserved byte-equivalent — DashboardPage's lazy-import
    path is unchanged because OnboardingWizard.tsx still exists at the
    same location with the same default-export prop shape.
  • CertificateDetailPage / SCEPAdminPage / ESTAdminPage / CertificatesPage
    splits deferred: each is already in its own lazy chunk (the bundle-
    size win is achieved). Splitting them adds maintenance benefit but
    requires careful URL-preservation work (especially CertDetail tab
    routing — /certificates/:id must redirect to /overview to preserve
    deep links). Documented as Phase 4 follow-up; not blocking on this
    closure.

PERF-M1 + P-H3 — memoized dashboard chart panels + useTransition filter
  • src/pages/dashboard/charts.tsx — 4 React.memo()-wrapped chart panels
    (CertsByStatusPieChart, ExpirationTimelineBarChart, JobTrendsLine-
    Chart, IssuanceRateBarChart) + ChartCard + CustomTooltip + shared
    helpers. Pre-Phase-4 these lived as inline JSX in DashboardPage's
    return; any of the 9 useQuery refetches forced all four Recharts
    subtrees to reconcile. Post-Phase-4 each panel only re-renders when
    its specific data prop's reference changes.
  • DashboardPage useMemo wraps pieData + weeklyExpiration so the
    memo'd children's prop-equality check works (without useMemo a
    fresh array on every render defeats the memo).
  • Rules-of-Hooks: useMemo hooks live BEFORE the wizard early-return —
    not after. (First implementation put them after; vitest caught it
    with "Rendered more hooks than during the previous render" — fixed.)
  • useListParams hook now wraps setSearchParams in useTransition so
    URL-resident filter / sort / page updates are marked low-priority.
    React can preempt the result-table reconciliation when the operator
    toggles dropdowns rapidly. Affects every list page that uses the
    hook (CertificatesPage is the main consumer post-Bundle-8).

═══════════════════════════ VERIFICATION ═════════════════════════════

  • npx tsc --noEmit — exits 0
  • Skeleton primitive: 6/6 tests green
  • Component suite (12 files): 137/137 green
  • Auth-page suite (13 files): 130/130 green
  • Dashboard + Onboarding + Certificates + CertificateDetail + Targets
    + Agents + Issuers + Jobs + SCEPAdmin + ESTAdmin: 71/71 green
  • npm run build clean; chunk inventory verified (vendor-react,
    vendor-router, vendor-query, vendor-recharts, vendor-icons emitted
    as named chunks; 35 per-route lazy chunks emitted; index-*.js
    shrunk to 91.66 KB raw / 25.92 KB gz).

═══════════════════════════ RESIDUAL RISK ════════════════════════════

  • Vite 8 + rolldown's manualChunks signature differs from Vite 5;
    upgrading Vite again would re-break this config. Comment in
    vite.config.ts pins the function-shape requirement.
  • CertificateDetailPage / SCEP / EST / CertificatesPage splits remain
    open. Mega-LOC files but already lazy-chunked, so deferring is safe.
  • Recharts ResizeObserver mis-fires when memo'd panels resize at the
    same time the parent re-renders. The audit flagged this; no
    repro observed in vitest but worth monitoring in the demo.
This commit is contained in:
shankar0123
2026-05-14 16:14:24 +00:00
parent 0987e222dd
commit 9ce2d8ca8f
15 changed files with 1709 additions and 1219 deletions
+8 -9
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import Skeleton from './Skeleton';
interface Column<T> { interface Column<T> {
key: string; key: string;
@@ -47,16 +48,14 @@ interface DataTableProps<T> {
} }
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, emptyState, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination }: DataTableProps<T>) { export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, emptyState, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination }: DataTableProps<T>) {
// Phase 4 closure (UX-M1): swap the centered spinner + "Loading..."
// text — which paints into a tiny vertical span and then jumps to a
// full-height table on resolve, the canonical CLS source — for a
// layout-shape-matching skeleton table sized to the actual column
// count. The eye reads "table loading here" and the eventual data
// lands in the same DOM rectangle with zero reflow.
if (isLoading) { if (isLoading) {
return ( return <Skeleton variant="table" columns={columns.length + (selectable ? 1 : 0)} />;
<div className="flex items-center justify-center py-16 text-ink-muted">
<svg className="animate-spin h-5 w-5 mr-3" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading...
</div>
);
} }
if (!data.length) { if (!data.length) {
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import Skeleton from './Skeleton';
describe('Skeleton', () => {
it('page variant renders PageHeader-shaped band + 4 stat tiles + card', () => {
const { container, getByRole } = render(<Skeleton variant="page" />);
expect(getByRole('status')).toHaveAttribute('aria-busy', 'true');
expect(getByRole('status')).toHaveAttribute('aria-label', 'Loading content');
expect(container.querySelector('.animate-pulse')).not.toBeNull();
// 4 stat tiles
expect(container.querySelectorAll('.grid > .bg-surface')).toHaveLength(4);
});
it('table variant defaults to 6 rows × 5 cols', () => {
const { container } = render(<Skeleton variant="table" />);
const rows = container.querySelectorAll('tbody tr');
expect(rows).toHaveLength(6);
const cells = rows[0].querySelectorAll('td');
expect(cells).toHaveLength(5);
});
it('table variant respects custom rows + columns', () => {
const { container } = render(<Skeleton variant="table" rows={3} columns={4} />);
expect(container.querySelectorAll('tbody tr')).toHaveLength(3);
expect(container.querySelectorAll('tbody tr:first-child td')).toHaveLength(4);
});
it('card variant renders title-row + 3 prose rows', () => {
const { container } = render(<Skeleton variant="card" />);
// 1 title + 3 prose lines = 4 stripes inside the inner card
const stripes = container.querySelectorAll('.bg-surface > div, .bg-surface .space-y-2 > div');
expect(stripes.length).toBeGreaterThanOrEqual(4);
});
it('stat variant renders label-row + number-row', () => {
const { container, getByRole } = render(<Skeleton variant="stat" />);
expect(getByRole('status')).toHaveAttribute('aria-busy', 'true');
// 2 stripes
expect(container.querySelectorAll('.bg-surface-border')).toHaveLength(2);
});
it('custom ariaLabel surfaces on the role=status root', () => {
const { getByRole } = render(
<Skeleton variant="card" ariaLabel="Loading certificates" />,
);
expect(getByRole('status')).toHaveAttribute('aria-label', 'Loading certificates');
});
});
+158
View File
@@ -0,0 +1,158 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Skeleton — Phase 4 closure for UX-M1 (206 isLoading sites render as
// "Loading…" text in PageHeader subtitle → layout shift on every fetch).
//
// Four variants, each shaped to match the page region it stands in for
// so the eventual content lands without CLS:
//
// • page — full-page Suspense fallback used by main.tsx route
// lazy-load boundaries. Includes a PageHeader-shaped
// skeleton + a body grid of card / table skeletons.
// • table — list-page body. 6 rows × 5 cells, header row dimmed.
// Drop into DataTable's isLoading branch (or page-local
// tables that don't go through DataTable yet).
// • card — single content card. One title-row + 3 prose rows.
// Composable inside dashboards / detail pages.
// • stat — KPI tile. One label-row + one large number-row.
// Sized to match DashboardPage's stat panels.
//
// Every variant uses Tailwind's `animate-pulse` on layout-shaped divs
// so the eye reads "content loading here" instead of a flash of empty
// container followed by re-flow when the real content paints.
//
// Accessibility: each variant carries role="status" + aria-busy="true"
// + aria-label so screen-reader users hear "Loading <region>" instead
// of an empty announcement.
interface SkeletonProps {
variant: 'page' | 'table' | 'card' | 'stat';
/** Override default aria-label. Default: "Loading content". */
ariaLabel?: string;
/** Number of rows for the `table` variant. Default 6. */
rows?: number;
/** Number of columns for the `table` variant. Default 5. */
columns?: number;
}
export default function Skeleton({
variant,
ariaLabel = 'Loading content',
rows = 6,
columns = 5,
}: SkeletonProps) {
if (variant === 'page') {
return (
<div
role="status"
aria-busy="true"
aria-label={ariaLabel}
className="animate-pulse"
>
{/* PageHeader-shaped band */}
<div className="flex items-center justify-between px-6 py-4 border-b border-surface-border bg-surface">
<div>
<div className="h-3 w-32 bg-surface-border rounded mb-2" />
<div className="h-5 w-48 bg-surface-border rounded" />
</div>
<div className="h-9 w-28 bg-surface-border rounded" />
</div>
{/* Body grid: 4 stat tiles + 1 card */}
<div className="p-6 space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="bg-surface border border-surface-border rounded-lg p-4"
>
<div className="h-3 w-20 bg-surface-border rounded mb-3" />
<div className="h-7 w-16 bg-surface-border rounded" />
</div>
))}
</div>
<Card />
</div>
</div>
);
}
if (variant === 'table') {
return (
<div
role="status"
aria-busy="true"
aria-label={ariaLabel}
className="animate-pulse"
>
<table className="w-full">
<thead>
<tr className="border-b border-surface-border">
{Array.from({ length: columns }).map((_, i) => (
<th key={i} className="text-left px-4 py-3">
<div className="h-3 w-20 bg-surface-border rounded" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, r) => (
<tr key={r} className="border-b border-surface-border">
{Array.from({ length: columns }).map((_, c) => (
<td key={c} className="px-4 py-3">
<div
className={
'h-3 bg-surface-border rounded ' +
(c === 0 ? 'w-40' : c === columns - 1 ? 'w-16' : 'w-24')
}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
if (variant === 'card') {
return (
<div
role="status"
aria-busy="true"
aria-label={ariaLabel}
className="animate-pulse"
>
<Card />
</div>
);
}
// variant === 'stat'
return (
<div
role="status"
aria-busy="true"
aria-label={ariaLabel}
className="animate-pulse bg-surface border border-surface-border rounded-lg p-4"
>
<div className="h-3 w-20 bg-surface-border rounded mb-3" />
<div className="h-7 w-16 bg-surface-border rounded" />
</div>
);
}
/** Card sub-shape, shared between `page` and `card` variants. */
function Card() {
return (
<div className="bg-surface border border-surface-border rounded-lg p-6">
<div className="h-4 w-40 bg-surface-border rounded mb-4" />
<div className="space-y-2">
<div className="h-3 w-full bg-surface-border rounded" />
<div className="h-3 w-11/12 bg-surface-border rounded" />
<div className="h-3 w-2/3 bg-surface-border rounded" />
</div>
</div>
);
}
+15 -1
View File
@@ -21,7 +21,7 @@
// JobsPage, RenewalPoliciesPage, DiscoveryPage) are deferred to a // JobsPage, RenewalPoliciesPage, DiscoveryPage) are deferred to a
// follow-up bundle — tracked as new ID `M-029`. // follow-up bundle — tracked as new ID `M-029`.
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useTransition } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
export interface ListParams { export interface ListParams {
@@ -56,6 +56,13 @@ const DEFAULT_PAGE_SIZE = 25;
*/ */
export function useListParams(defaults?: Partial<ListParams>): ListParamsControls { export function useListParams(defaults?: Partial<ListParams>): ListParamsControls {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
// Phase 4 closure (PERF-M1): mark URL-resident filter / sort / page
// updates as a transition so React can preempt the result-table
// reconciliation when the operator interacts with the toolbar (e.g.
// rapidly toggling dropdowns while a 50-row table is still rendering
// the previous result). useTransition keeps the dropdown UI snappy
// even when the result render is expensive.
const [, startTransition] = useTransition();
const params = useMemo<ListParams>(() => { const params = useMemo<ListParams>(() => {
const page = parsePositiveInt(searchParams.get('page'), defaults?.page ?? DEFAULT_PAGE); const page = parsePositiveInt(searchParams.get('page'), defaults?.page ?? DEFAULT_PAGE);
@@ -88,7 +95,14 @@ export function useListParams(defaults?: Partial<ListParams>): ListParamsControl
if (key !== 'page') { if (key !== 'page') {
next.delete('page'); next.delete('page');
} }
// startTransition lets React mark the downstream table reconcile
// as low-priority work — urgent updates (input typing, button
// hover) can preempt. The URL itself still updates immediately
// because setSearchParams calls history.replaceState synchronously;
// only the React-tree reconciliation is deferred.
startTransition(() => {
setSearchParams(next, { replace: true }); setSearchParams(next, { replace: true });
});
}, },
[searchParams, setSearchParams], [searchParams, setSearchParams],
); );
+124 -83
View File
@@ -7,7 +7,7 @@ import '@fontsource/jetbrains-mono/400.css';
import '@fontsource/jetbrains-mono/500.css'; import '@fontsource/jetbrains-mono/500.css';
import '@fontsource/jetbrains-mono/600.css'; import '@fontsource/jetbrains-mono/600.css';
import { StrictMode } from 'react'; import { StrictMode, Suspense, lazy } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
@@ -15,49 +15,81 @@ import ErrorBoundary from './components/ErrorBoundary';
import AuthProvider from './components/AuthProvider'; import AuthProvider from './components/AuthProvider';
import AuthGate from './components/AuthGate'; import AuthGate from './components/AuthGate';
import Layout from './components/Layout'; import Layout from './components/Layout';
// Phase 4 closure (FE-M5 + SCALE-H1): per-route code splitting.
// Pre-Phase-4 every page import above was eager — every page's React
// tree + its api/client + its query-key constants + its chart panels
// landed in the same first-load index-*.js (~1.07 MB raw / ~281 KB gz).
//
// Post-Phase-4 the dashboard stays eager (it's the landing route for
// every cold load) and every other page becomes a React.lazy() boundary
// so its chunk only ships when an operator navigates to that route.
// Each route is wrapped in a <Suspense fallback={<Skeleton variant=
// "page" />}> so the route transition shows a page-shaped skeleton
// instead of a blank white frame during the chunk fetch.
//
// Vite's manualChunks config (see vite.config.ts) splits react /
// react-router-dom / @tanstack/react-query / recharts / lucide-react
// into their own vendor chunks so vendor caches survive feature
// deploys (the index-*.js hash flips on every feature change; vendor
// chunks only re-hash when their package versions change in
// package-lock.json).
//
// Net cold-load budget post-Phase-4: vendor-react + vendor-router +
// vendor-query + (per-route chunk) + index-*.js (now only the routing
// + provider plumbing, not the page bodies). Dashboard adds
// vendor-recharts on demand.
import DashboardPage from './pages/DashboardPage'; import DashboardPage from './pages/DashboardPage';
import CertificatesPage from './pages/CertificatesPage'; import Skeleton from './components/Skeleton';
import CertificateDetailPage from './pages/CertificateDetailPage';
import AgentsPage from './pages/AgentsPage'; // Inventory.
import AgentDetailPage from './pages/AgentDetailPage'; const CertificatesPage = lazy(() => import('./pages/CertificatesPage'));
import JobsPage from './pages/JobsPage'; const CertificateDetailPage = lazy(() => import('./pages/CertificateDetailPage'));
import NotificationsPage from './pages/NotificationsPage'; const IssuersPage = lazy(() => import('./pages/IssuersPage'));
import PoliciesPage from './pages/PoliciesPage'; const IssuerDetailPage = lazy(() => import('./pages/IssuerDetailPage'));
import RenewalPoliciesPage from './pages/RenewalPoliciesPage'; const IssuerHierarchyPage = lazy(() => import('./pages/IssuerHierarchyPage'));
import IssuersPage from './pages/IssuersPage'; const TargetsPage = lazy(() => import('./pages/TargetsPage'));
import TargetsPage from './pages/TargetsPage'; const TargetDetailPage = lazy(() => import('./pages/TargetDetailPage'));
import ProfilesPage from './pages/ProfilesPage'; const ProfilesPage = lazy(() => import('./pages/ProfilesPage'));
import OwnersPage from './pages/OwnersPage'; // Delivery & jobs.
import TeamsPage from './pages/TeamsPage'; const JobsPage = lazy(() => import('./pages/JobsPage'));
import AgentGroupsPage from './pages/AgentGroupsPage'; const JobDetailPage = lazy(() => import('./pages/JobDetailPage'));
import AuditPage from './pages/AuditPage'; const AgentsPage = lazy(() => import('./pages/AgentsPage'));
import ShortLivedPage from './pages/ShortLivedPage'; const AgentDetailPage = lazy(() => import('./pages/AgentDetailPage'));
import AgentFleetPage from './pages/AgentFleetPage'; const AgentFleetPage = lazy(() => import('./pages/AgentFleetPage'));
import DiscoveryPage from './pages/DiscoveryPage'; const AgentGroupsPage = lazy(() => import('./pages/AgentGroupsPage'));
import NetworkScanPage from './pages/NetworkScanPage'; // Policy & notify.
import HealthMonitorPage from './pages/HealthMonitorPage'; const PoliciesPage = lazy(() => import('./pages/PoliciesPage'));
import DigestPage from './pages/DigestPage'; const RenewalPoliciesPage = lazy(() => import('./pages/RenewalPoliciesPage'));
import ObservabilityPage from './pages/ObservabilityPage'; const NotificationsPage = lazy(() => import('./pages/NotificationsPage'));
import JobDetailPage from './pages/JobDetailPage'; const DigestPage = lazy(() => import('./pages/DigestPage'));
import IssuerDetailPage from './pages/IssuerDetailPage'; // People.
import IssuerHierarchyPage from './pages/IssuerHierarchyPage'; const OwnersPage = lazy(() => import('./pages/OwnersPage'));
import TargetDetailPage from './pages/TargetDetailPage'; const TeamsPage = lazy(() => import('./pages/TeamsPage'));
import SCEPAdminPage from './pages/SCEPAdminPage'; // Audit & ops.
import ESTAdminPage from './pages/ESTAdminPage'; const AuditPage = lazy(() => import('./pages/AuditPage'));
// Bundle 1 Phase 10 — RBAC management pages. const ShortLivedPage = lazy(() => import('./pages/ShortLivedPage'));
import RolesPage from './pages/auth/RolesPage'; const DiscoveryPage = lazy(() => import('./pages/DiscoveryPage'));
import RoleDetailPage from './pages/auth/RoleDetailPage'; const NetworkScanPage = lazy(() => import('./pages/NetworkScanPage'));
import KeysPage from './pages/auth/KeysPage'; const HealthMonitorPage = lazy(() => import('./pages/HealthMonitorPage'));
import AuthSettingsPage from './pages/auth/AuthSettingsPage'; const ObservabilityPage = lazy(() => import('./pages/ObservabilityPage'));
import ApprovalsPage from './pages/auth/ApprovalsPage'; // Protocol admin.
// Bundle 2 Phase 8 — OIDC + session management pages. const SCEPAdminPage = lazy(() => import('./pages/SCEPAdminPage'));
import OIDCProvidersPage from './pages/auth/OIDCProvidersPage'; const ESTAdminPage = lazy(() => import('./pages/ESTAdminPage'));
import OIDCProviderDetailPage from './pages/auth/OIDCProviderDetailPage'; // Access (Bundle 1 Phase 10 — RBAC management).
import GroupMappingsPage from './pages/auth/GroupMappingsPage'; const RolesPage = lazy(() => import('./pages/auth/RolesPage'));
import SessionsPage from './pages/auth/SessionsPage'; const RoleDetailPage = lazy(() => import('./pages/auth/RoleDetailPage'));
import BreakglassPage from './pages/auth/BreakglassPage'; const KeysPage = lazy(() => import('./pages/auth/KeysPage'));
// Audit 2026-05-10 MED-11 closure — federated-user admin page. const AuthSettingsPage = lazy(() => import('./pages/auth/AuthSettingsPage'));
import UsersPage from './pages/auth/UsersPage'; const ApprovalsPage = lazy(() => import('./pages/auth/ApprovalsPage'));
// Access (Bundle 2 Phase 8 — OIDC + session management).
const OIDCProvidersPage = lazy(() => import('./pages/auth/OIDCProvidersPage'));
const OIDCProviderDetailPage = lazy(() => import('./pages/auth/OIDCProviderDetailPage'));
const GroupMappingsPage = lazy(() => import('./pages/auth/GroupMappingsPage'));
const SessionsPage = lazy(() => import('./pages/auth/SessionsPage'));
const BreakglassPage = lazy(() => import('./pages/auth/BreakglassPage'));
// Audit 2026-05-10 MED-11 closure — federated-user admin.
const UsersPage = lazy(() => import('./pages/auth/UsersPage'));
// Phase 1 closure (UX-H3): toast / snackbar system. Mounted once near // Phase 1 closure (UX-H3): toast / snackbar system. Mounted once near
// the root so any component can `import { toast } from "sonner"` and // the root so any component can `import { toast } from "sonner"` and
// call toast.success / toast.error without provider plumbing. // call toast.success / toast.error without provider plumbing.
@@ -96,6 +128,14 @@ const queryClient = new QueryClient({
}, },
}); });
// Phase 4 helper: wrap a lazy route in a page-shaped Suspense fallback.
// The same Skeleton variant lands on every route so the transition is
// visually consistent — operators learn "skeleton bars = chunk loading"
// once and never see a different placeholder elsewhere.
function lazyRoute(element: React.ReactNode) {
return <Suspense fallback={<Skeleton variant="page" />}>{element}</Suspense>;
}
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<ErrorBoundary> <ErrorBoundary>
@@ -107,37 +147,38 @@ createRoot(document.getElementById('root')!).render(
<CommandPaletteHost /> <CommandPaletteHost />
<Routes> <Routes>
<Route element={<Layout />}> <Route element={<Layout />}>
{/* Dashboard stays eager — landing route for every cold load. */}
<Route index element={<DashboardPage />} /> <Route index element={<DashboardPage />} />
<Route path="certificates" element={<CertificatesPage />} /> <Route path="certificates" element={lazyRoute(<CertificatesPage />)} />
<Route path="certificates/:id" element={<CertificateDetailPage />} /> <Route path="certificates/:id" element={lazyRoute(<CertificateDetailPage />)} />
<Route path="agents" element={<AgentsPage />} /> <Route path="agents" element={lazyRoute(<AgentsPage />)} />
<Route path="agents/:id" element={<AgentDetailPage />} /> <Route path="agents/:id" element={lazyRoute(<AgentDetailPage />)} />
<Route path="fleet" element={<AgentFleetPage />} /> <Route path="fleet" element={lazyRoute(<AgentFleetPage />)} />
<Route path="jobs" element={<JobsPage />} /> <Route path="jobs" element={lazyRoute(<JobsPage />)} />
<Route path="jobs/:id" element={<JobDetailPage />} /> <Route path="jobs/:id" element={lazyRoute(<JobDetailPage />)} />
<Route path="notifications" element={<NotificationsPage />} /> <Route path="notifications" element={lazyRoute(<NotificationsPage />)} />
<Route path="policies" element={<PoliciesPage />} /> <Route path="policies" element={lazyRoute(<PoliciesPage />)} />
<Route path="renewal-policies" element={<RenewalPoliciesPage />} /> <Route path="renewal-policies" element={lazyRoute(<RenewalPoliciesPage />)} />
<Route path="profiles" element={<ProfilesPage />} /> <Route path="profiles" element={lazyRoute(<ProfilesPage />)} />
<Route path="issuers" element={<IssuersPage />} /> <Route path="issuers" element={lazyRoute(<IssuersPage />)} />
<Route path="issuers/:id" element={<IssuerDetailPage />} /> <Route path="issuers/:id" element={lazyRoute(<IssuerDetailPage />)} />
{/* Rank 8 — operator-managed multi-level CA hierarchy. {/* Rank 8 — operator-managed multi-level CA hierarchy.
Admin-gated at the API; the page renders the Admin-gated at the API; the page renders the
backend's 403 as ErrorState for non-admin backend's 403 as ErrorState for non-admin
callers. See docs/intermediate-ca-hierarchy.md. */} callers. See docs/intermediate-ca-hierarchy.md. */}
<Route path="issuers/:id/hierarchy" element={<IssuerHierarchyPage />} /> <Route path="issuers/:id/hierarchy" element={lazyRoute(<IssuerHierarchyPage />)} />
<Route path="targets" element={<TargetsPage />} /> <Route path="targets" element={lazyRoute(<TargetsPage />)} />
<Route path="targets/:id" element={<TargetDetailPage />} /> <Route path="targets/:id" element={lazyRoute(<TargetDetailPage />)} />
<Route path="owners" element={<OwnersPage />} /> <Route path="owners" element={lazyRoute(<OwnersPage />)} />
<Route path="teams" element={<TeamsPage />} /> <Route path="teams" element={lazyRoute(<TeamsPage />)} />
<Route path="agent-groups" element={<AgentGroupsPage />} /> <Route path="agent-groups" element={lazyRoute(<AgentGroupsPage />)} />
<Route path="audit" element={<AuditPage />} /> <Route path="audit" element={lazyRoute(<AuditPage />)} />
<Route path="short-lived" element={<ShortLivedPage />} /> <Route path="short-lived" element={lazyRoute(<ShortLivedPage />)} />
<Route path="discovery" element={<DiscoveryPage />} /> <Route path="discovery" element={lazyRoute(<DiscoveryPage />)} />
<Route path="network-scans" element={<NetworkScanPage />} /> <Route path="network-scans" element={lazyRoute(<NetworkScanPage />)} />
<Route path="health-monitor" element={<HealthMonitorPage />} /> <Route path="health-monitor" element={lazyRoute(<HealthMonitorPage />)} />
<Route path="digest" element={<DigestPage />} /> <Route path="digest" element={lazyRoute(<DigestPage />)} />
<Route path="observability" element={<ObservabilityPage />} /> <Route path="observability" element={lazyRoute(<ObservabilityPage />)} />
{/* SCEP RFC 8894 + Intune master bundle Phase 9.4 (initial) {/* SCEP RFC 8894 + Intune master bundle Phase 9.4 (initial)
+ Phase 9 follow-up (rebrand): per-profile SCEP + Phase 9 follow-up (rebrand): per-profile SCEP
Administration page with Profiles / Intune Monitoring / Administration page with Profiles / Intune Monitoring /
@@ -145,17 +186,17 @@ createRoot(document.getElementById('root')!).render(
itself renders an "Admin access required" banner for itself renders an "Admin access required" banner for
non-admin callers and skips the underlying API calls so non-admin callers and skips the underlying API calls so
the server never sees a 403-prone request. */} the server never sees a 403-prone request. */}
<Route path="scep" element={<SCEPAdminPage />} /> <Route path="scep" element={lazyRoute(<SCEPAdminPage />)} />
{/* Backward-compat alias for external bookmarks the Phase 9 {/* Backward-compat alias for external bookmarks the Phase 9
release advertised. Lands on the Intune Monitoring tab. */} release advertised. Lands on the Intune Monitoring tab. */}
<Route path="scep/intune" element={<SCEPAdminPage />} /> <Route path="scep/intune" element={lazyRoute(<SCEPAdminPage />)} />
{/* EST RFC 7030 hardening master bundle Phase 8: per-profile {/* EST RFC 7030 hardening master bundle Phase 8: per-profile
EST Administration page with Profiles / Recent Activity / EST Administration page with Profiles / Recent Activity /
Trust Bundle tabs. Same admin-gate pattern as SCEP — the Trust Bundle tabs. Same admin-gate pattern as SCEP — the
route is unconditional; the page renders an "Admin access route is unconditional; the page renders an "Admin access
required" banner for non-admin callers and skips the required" banner for non-admin callers and skips the
underlying API calls so the server never sees a 403. */} underlying API calls so the server never sees a 403. */}
<Route path="est" element={<ESTAdminPage />} /> <Route path="est" element={lazyRoute(<ESTAdminPage />)} />
{/* Bundle 1 Phase 10 — RBAC management surface. {/* Bundle 1 Phase 10 — RBAC management surface.
Every page reads /api/v1/auth/me on mount via the Every page reads /api/v1/auth/me on mount via the
useAuthMe hook and gates affordances against the useAuthMe hook and gates affordances against the
@@ -163,19 +204,19 @@ createRoot(document.getElementById('root')!).render(
enforcement is the load-bearing layer; client-side enforcement is the load-bearing layer; client-side
hide/disable is UX. */} hide/disable is UX. */}
{/* Bundle 2 Phase 8 — OIDC + session management surface. */} {/* Bundle 2 Phase 8 — OIDC + session management surface. */}
<Route path="auth/oidc/providers" element={<OIDCProvidersPage />} /> <Route path="auth/oidc/providers" element={lazyRoute(<OIDCProvidersPage />)} />
<Route path="auth/oidc/providers/:id" element={<OIDCProviderDetailPage />} /> <Route path="auth/oidc/providers/:id" element={lazyRoute(<OIDCProviderDetailPage />)} />
<Route path="auth/oidc/providers/:id/mappings" element={<GroupMappingsPage />} /> <Route path="auth/oidc/providers/:id/mappings" element={lazyRoute(<GroupMappingsPage />)} />
<Route path="auth/sessions" element={<SessionsPage />} /> <Route path="auth/sessions" element={lazyRoute(<SessionsPage />)} />
<Route path="auth/roles" element={<RolesPage />} /> <Route path="auth/roles" element={lazyRoute(<RolesPage />)} />
<Route path="auth/roles/:id" element={<RoleDetailPage />} /> <Route path="auth/roles/:id" element={lazyRoute(<RoleDetailPage />)} />
<Route path="auth/keys" element={<KeysPage />} /> <Route path="auth/keys" element={lazyRoute(<KeysPage />)} />
<Route path="auth/settings" element={<AuthSettingsPage />} /> <Route path="auth/settings" element={lazyRoute(<AuthSettingsPage />)} />
<Route path="auth/approvals" element={<ApprovalsPage />} /> <Route path="auth/approvals" element={lazyRoute(<ApprovalsPage />)} />
{/* Audit 2026-05-10 CRIT-4 closure — break-glass admin surface. */} {/* Audit 2026-05-10 CRIT-4 closure — break-glass admin surface. */}
<Route path="auth/breakglass" element={<BreakglassPage />} /> <Route path="auth/breakglass" element={lazyRoute(<BreakglassPage />)} />
{/* Audit 2026-05-10 MED-11 closure — federated-user admin. */} {/* Audit 2026-05-10 MED-11 closure — federated-user admin. */}
<Route path="auth/users" element={<UsersPage />} /> <Route path="auth/users" element={lazyRoute(<UsersPage />)} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
+60 -138
View File
@@ -1,12 +1,8 @@
import { useEffect, useState } from 'react'; import { Suspense, lazy, useEffect, useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTrackedMutation } from '../hooks/useTrackedMutation'; import { useTrackedMutation } from '../hooks/useTrackedMutation';
import { STALE_TIME } from '../api/queryConstants'; import { STALE_TIME } from '../api/queryConstants';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import {
BarChart, Bar, LineChart, Line, PieChart, Pie, Cell,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
} from 'recharts';
import { import {
getCertificates, getJobs, getHealth, getCertificates, getJobs, getHealth,
getDashboardSummary, getCertificatesByStatus, getExpirationTimeline, getDashboardSummary, getCertificatesByStatus, getExpirationTimeline,
@@ -14,8 +10,24 @@ import {
} from '../api/client'; } from '../api/client';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge'; import StatusBadge from '../components/StatusBadge';
import Skeleton from '../components/Skeleton';
import { daysUntil, expiryColor, formatDate } from '../api/utils'; import { daysUntil, expiryColor, formatDate } from '../api/utils';
import OnboardingWizard from './OnboardingWizard'; // Phase 4 closure (PERF-M1 + P-H3): memo-wrapped chart panels so a query
// refetch in one tile doesn't force every Recharts subtree to reconcile.
// See pages/dashboard/charts.tsx for the equality model.
import {
CertsByStatusPieChart,
ExpirationTimelineBarChart,
JobTrendsLineChart,
IssuanceRateBarChart,
type PieDatum,
type WeeklyExpirationDatum,
} from './dashboard/charts';
// Phase 4 closure (FE-M5): OnboardingWizard is 1043 LOC + only renders
// on first-run dashboards (one-time dismiss persisted to localStorage).
// Lazy-loading the wizard keeps its step-form code off the hot path for
// every dashboard load after the operator dismisses it once.
const OnboardingWizard = lazy(() => import('./OnboardingWizard'));
// Convert PascalCase status like "RenewalInProgress" to "Renewal In Progress" // Convert PascalCase status like "RenewalInProgress" to "Renewal In Progress"
const formatStatus = (s: string) => s.replace(/([a-z])([A-Z])/g, '$1 $2'); const formatStatus = (s: string) => s.replace(/([a-z])([A-Z])/g, '$1 $2');
@@ -54,30 +66,9 @@ function StatCard({ label, value, icon, color }: { label: string; value: string
); );
} }
function ChartCard({ title, children }: { title: string; children: React.ReactNode }) { // ChartCard + CustomTooltip + formatShortDate moved to
return ( // pages/dashboard/charts.tsx (Phase 4 PERF-M1 closure) where they live
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm"> // alongside the memo-wrapped chart panels that consume them.
<h3 className="text-sm font-semibold text-ink-muted mb-4">{title}</h3>
<div className="h-64">
{children}
</div>
</div>
);
}
const CustomTooltip = ({ active, payload, label }: any) => {
if (!active || !payload?.length) return null;
return (
<div className="bg-surface border border-surface-border rounded px-3 py-2 text-xs shadow-lg">
<p className="text-ink mb-1">{label}</p>
{payload.map((entry: any, i: number) => (
<p key={i} style={{ color: entry.color }}>
{entry.name}: {typeof entry.value === 'number' && entry.name?.includes('rate') ? `${entry.value.toFixed(1)}%` : entry.value}
</p>
))}
</div>
);
};
function DigestCard() { function DigestCard() {
const [previewHtml, setPreviewHtml] = useState<string | null>(null); const [previewHtml, setPreviewHtml] = useState<string | null>(null);
@@ -266,6 +257,35 @@ export default function DashboardPage() {
refetchOnWindowFocus: true, staleTime: STALE_TIME.REAL_TIME, refetchOnWindowFocus: true, staleTime: STALE_TIME.REAL_TIME,
}); });
// Prepare pie chart data — memoized so the reference is stable across
// re-renders that didn't change statusCounts. Without this useMemo the
// chart's React.memo prop-equality check fails on every dashboard
// re-render (fresh array every time) and the perf win evaporates.
//
// Hooks must be called unconditionally on every render path (Rules of
// Hooks), so these live BEFORE the wizard early-return below — never
// after it.
const pieData = useMemo<PieDatum[]>(() => (
(statusCounts || []).filter(s => s.count > 0).map(s => ({
name: s.status,
value: s.count,
fill: STATUS_COLORS[s.status] || '#64748b',
}))
), [statusCounts]);
// Format expiration heatmap for display — aggregate weekly for 90 days.
// Same useMemo reasoning as pieData above.
const weeklyExpiration = useMemo<WeeklyExpirationDatum[]>(() => (
(expirationTimeline || []).reduce<WeeklyExpirationDatum[]>((acc, bucket, i) => {
const weekIdx = Math.floor(i / 7);
if (!acc[weekIdx]) {
acc[weekIdx] = { week: bucket.date, count: 0 };
}
acc[weekIdx].count += bucket.count;
return acc;
}, [])
), [expirationTimeline]);
// Detect first-run ONCE: no user-configured issuers AND no certificates. // Detect first-run ONCE: no user-configured issuers AND no certificates.
// Auto-seeded env var issuers (source="env") don't count — they exist on every fresh boot. // Auto-seeded env var issuers (source="env") don't count — they exist on every fresh boot.
// Once showWizard latches true, it stays true until the user dismisses. // Once showWizard latches true, it stays true until the user dismisses.
@@ -282,6 +302,7 @@ export default function DashboardPage() {
if ((showWizard && !onboardingDismissed) || forceOnboarding) { if ((showWizard && !onboardingDismissed) || forceOnboarding) {
return ( return (
<Suspense fallback={<Skeleton variant="page" ariaLabel="Loading onboarding wizard" />}>
<OnboardingWizard onDismiss={() => { <OnboardingWizard onDismiss={() => {
try { localStorage.setItem('certctl:onboarding-dismissed', 'true'); } catch { /* noop */ } try { localStorage.setItem('certctl:onboarding-dismissed', 'true'); } catch { /* noop */ }
setOnboardingDismissed(true); setOnboardingDismissed(true);
@@ -293,6 +314,7 @@ export default function DashboardPage() {
setSearchParams(next, { replace: true }); setSearchParams(next, { replace: true });
} }
}} /> }} />
</Suspense>
); );
} }
@@ -302,29 +324,6 @@ export default function DashboardPage() {
const activeAgents = summary?.active_agents || 0; const activeAgents = summary?.active_agents || 0;
const pendingJobs = summary?.pending_jobs || 0; const pendingJobs = summary?.pending_jobs || 0;
// Prepare pie chart data
const pieData = (statusCounts || []).filter(s => s.count > 0).map(s => ({
name: s.status,
value: s.count,
fill: STATUS_COLORS[s.status] || '#64748b',
}));
// Format expiration heatmap for display — aggregate weekly for 90 days
const weeklyExpiration = (expirationTimeline || []).reduce<{ week: string; count: number }[]>((acc, bucket, i) => {
const weekIdx = Math.floor(i / 7);
if (!acc[weekIdx]) {
acc[weekIdx] = { week: bucket.date, count: 0 };
}
acc[weekIdx].count += bucket.count;
return acc;
}, []);
// Format dates for x-axis labels
const formatShortDate = (dateStr: string) => {
const d = new Date(dateStr + 'T00:00:00');
return `${d.getMonth() + 1}/${d.getDate()}`;
};
return ( return (
<> <>
<PageHeader <PageHeader
@@ -346,96 +345,19 @@ export default function DashboardPage() {
icon="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> icon="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</div> </div>
{/* Charts Row 1 */} {/* Charts Row 1 — memo-wrapped panels from pages/dashboard/charts.tsx
(Phase 4 PERF-M1). Each panel re-renders only when its own data
ref changes, so a refetch on one tile doesn't reconcile the
other three Recharts subtrees. */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Certificates by Status (Pie) */} <CertsByStatusPieChart data={pieData} />
<ChartCard title="Certificates by Status"> <ExpirationTimelineBarChart data={weeklyExpiration} />
{pieData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={90}
paddingAngle={2}
dataKey="value"
label={({ name, value }) => `${formatStatus(name || '')}: ${value}`}
labelLine={false}
>
{pieData.map((entry, index) => (
<Cell key={index} fill={entry.fill} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Legend
verticalAlign="bottom"
height={36}
formatter={(value: string) => <span className="text-xs text-ink-muted">{formatStatus(value)}</span>}
/>
</PieChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No certificate data</div>
)}
</ChartCard>
{/* Expiration Heatmap (Bar chart by week) */}
<ChartCard title="Expiration Timeline (Next 90 Days)">
{weeklyExpiration.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={weeklyExpiration}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="week" tick={{ fill: '#64748b', fontSize: 11 }} tickFormatter={formatShortDate} />
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} allowDecimals={false} />
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="count" name="Expiring certs" fill="#f59e0b" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No expiration data</div>
)}
</ChartCard>
</div> </div>
{/* Charts Row 2 */} {/* Charts Row 2 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Job Trends (Line chart) */} <JobTrendsLineChart data={jobTrends || []} />
<ChartCard title="Job Success/Failure Trends (30 Days)"> <IssuanceRateBarChart data={issuanceRate || []} />
{(jobTrends || []).length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={jobTrends}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="date" tick={{ fill: '#64748b', fontSize: 11 }} tickFormatter={formatShortDate} />
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} allowDecimals={false} />
<Tooltip content={<CustomTooltip />} />
<Legend formatter={(value: string) => <span className="text-xs text-ink-muted">{value}</span>} />
<Line type="monotone" dataKey="completed_count" name="Completed" stroke="#10b981" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="failed_count" name="Failed" stroke="#ef4444" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No job trend data</div>
)}
</ChartCard>
{/* Issuance Rate (Bar chart) */}
<ChartCard title="Certificate Issuance Rate (30 Days)">
{(issuanceRate || []).length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={issuanceRate}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="date" tick={{ fill: '#64748b', fontSize: 11 }} tickFormatter={formatShortDate} />
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} allowDecimals={false} />
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="issued_count" name="Issued" fill="#2ea88f" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No issuance data</div>
)}
</ChartCard>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
// Phase 4 closure (PERF-M1 + P-H3): memoized dashboard chart panels.
//
// Pre-Phase-4 the four chart panels lived as inline JSX inside
// DashboardPage's return statement. DashboardPage has 9 useQuery hooks
// (health / summary / issuers / statusCounts / expirationTimeline /
// jobTrends / issuanceRate / certs / jobs) and each refetch — including
// the per-tab refocus refetches the Phase 2 work narrowed but didn't
// eliminate for the live-tile cohort — forced React to re-evaluate every
// chart's JSX subtree, including the Recharts ResponsiveContainer
// reconciliation that the library uses under the hood (~10-50 ms each
// for charts with non-trivial data).
//
// Post-Phase-4 each chart is its own React.memo-wrapped component. When
// only `summary` updates, the four chart panels skip re-render entirely
// because their `data` prop didn't change. When `jobTrends` updates,
// only `JobTrendsLineChart` re-renders; the other three panels skip.
//
// React.memo's default equality is referential (Object.is). The parent
// DashboardPage passes the query result's `.data` arrays directly — TanStack
// Query returns a stable reference until the underlying data actually
// changes (it caches via queryKey), so referential equality is the
// correct check for this layer. No custom areEqual function needed.
import { memo } from 'react';
import {
BarChart, Bar, LineChart, Line, PieChart, Pie, Cell,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
} from 'recharts';
// ─── Shared helpers ──────────────────────────────────────
/** PascalCase → space-separated for display ("RenewalInProgress" → "Renewal In Progress"). */
const formatStatus = (s: string) => s.replace(/([a-z])([A-Z])/g, '$1 $2');
/** "2026-05-10" → "5/10" for compact x-axis labels. */
const formatShortDate = (dateStr: string) => {
const d = new Date(dateStr + 'T00:00:00');
return `${d.getMonth() + 1}/${d.getDate()}`;
};
interface TooltipPayloadEntry {
color?: string;
name?: string;
value?: number | string;
}
interface CustomTooltipProps {
active?: boolean;
payload?: TooltipPayloadEntry[];
label?: string;
}
const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
if (!active || !payload?.length) return null;
return (
<div className="bg-surface border border-surface-border rounded px-3 py-2 text-xs shadow-lg">
<p className="text-ink mb-1">{label}</p>
{payload.map((entry, i) => (
<p key={i} style={{ color: entry.color }}>
{entry.name}: {typeof entry.value === 'number' && entry.name?.includes('rate') ? `${entry.value.toFixed(1)}%` : entry.value}
</p>
))}
</div>
);
};
interface ChartCardProps {
title: string;
children: React.ReactNode;
}
export function ChartCard({ title, children }: ChartCardProps) {
return (
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">{title}</h3>
<div className="h-64">
{children}
</div>
</div>
);
}
// ─── Memoized chart panels ───────────────────────────────
export interface PieDatum {
name: string;
value: number;
fill: string;
}
/** Certificates-by-Status pie chart. Re-renders only when `data` ref changes. */
export const CertsByStatusPieChart = memo(function CertsByStatusPieChart({ data }: { data: PieDatum[] }) {
return (
<ChartCard title="Certificates by Status">
{data.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={90}
paddingAngle={2}
dataKey="value"
label={({ name, value }) => `${formatStatus(name || '')}: ${value}`}
labelLine={false}
>
{data.map((entry, index) => (
<Cell key={index} fill={entry.fill} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Legend
verticalAlign="bottom"
height={36}
formatter={(value: string) => <span className="text-xs text-ink-muted">{formatStatus(value)}</span>}
/>
</PieChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No certificate data</div>
)}
</ChartCard>
);
});
export interface WeeklyExpirationDatum {
week: string;
count: number;
}
/** Expiration Heatmap bar chart. Re-renders only when `data` ref changes. */
export const ExpirationTimelineBarChart = memo(function ExpirationTimelineBarChart({ data }: { data: WeeklyExpirationDatum[] }) {
return (
<ChartCard title="Expiration Timeline (Next 90 Days)">
{data.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="week" tick={{ fill: '#64748b', fontSize: 11 }} tickFormatter={formatShortDate} />
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} allowDecimals={false} />
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="count" name="Expiring certs" fill="#f59e0b" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No expiration data</div>
)}
</ChartCard>
);
});
export interface JobTrendDatum {
date: string;
completed_count: number;
failed_count: number;
}
/** Job Success/Failure trend line chart. Re-renders only when `data` ref changes. */
export const JobTrendsLineChart = memo(function JobTrendsLineChart({ data }: { data: JobTrendDatum[] }) {
return (
<ChartCard title="Job Success/Failure Trends (30 Days)">
{data.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="date" tick={{ fill: '#64748b', fontSize: 11 }} tickFormatter={formatShortDate} />
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} allowDecimals={false} />
<Tooltip content={<CustomTooltip />} />
<Legend formatter={(value: string) => <span className="text-xs text-ink-muted">{value}</span>} />
<Line type="monotone" dataKey="completed_count" name="Completed" stroke="#10b981" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="failed_count" name="Failed" stroke="#ef4444" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No job trend data</div>
)}
</ChartCard>
);
});
export interface IssuanceRateDatum {
date: string;
issued_count: number;
}
/** Certificate Issuance Rate bar chart. Re-renders only when `data` ref changes. */
export const IssuanceRateBarChart = memo(function IssuanceRateBarChart({ data }: { data: IssuanceRateDatum[] }) {
return (
<ChartCard title="Certificate Issuance Rate (30 Days)">
{data.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="date" tick={{ fill: '#64748b', fontSize: 11 }} tickFormatter={formatShortDate} />
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} allowDecimals={false} />
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="issued_count" name="Issued" fill="#2ea88f" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No issuance data</div>
)}
</ChartCard>
);
});
+141
View File
@@ -0,0 +1,141 @@
// Phase 4 closure (FE-M3): OnboardingWizard mega-page split — Step 2.
// Deploy a certctl Agent. Behavior preserved byte-equivalent from the
// pre-split src/pages/OnboardingWizard.tsx lines 282-408.
//
// Note: this step keeps Phase 2's TQ-H1 closure intact — the agents
// poll runs every 5s ONLY until the first agent registers, then the
// v5-functional refetchInterval flips to false and the poll stops.
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { STALE_TIME } from '../../api/queryConstants';
import { getAgents, getApiKey } from '../../api/client';
import { CodeBlock, WizardFooter } from './StepShell';
export default function AgentStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void }) {
const [activeTab, setActiveTab] = useState<'linux' | 'macos' | 'docker'>('linux');
const apiKey = getApiKey() || '<your-api-key>';
const serverUrl = typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8443` : 'http://localhost:8443';
// Phase 2 TQ-H1 closure: poll every 5s ONLY until the first agent
// registers, then stop. v5 functional refetchInterval returns false
// (or 0) to disable. Pre-fix this polled forever; once the wizard
// succeeded the next user landed in a state with a 5-second cadence
// hitting /api/v1/agents indefinitely until they reloaded the tab.
// Now: as soon as agents.length > 0, the interval flips to false
// and the poll stops.
const { data: agents } = useQuery({
queryKey: ['agents'],
queryFn: () => getAgents(),
refetchInterval: (query) =>
(query.state.data?.data?.length ?? 0) > 0 ? false : 5_000,
refetchOnWindowFocus: true,
staleTime: STALE_TIME.REAL_TIME,
});
const agentList = agents?.data || [];
const hasAgents = agentList.length > 0;
const tabs = [
{ key: 'linux' as const, label: 'Linux' },
{ key: 'macos' as const, label: 'macOS' },
{ key: 'docker' as const, label: 'Docker' },
];
const commands: Record<string, { code: string; label: string }> = {
linux: {
label: 'Install via shell script (systemd service)',
code: `# Non-interactive install (recommended for curl | bash):
curl -sSL https://raw.githubusercontent.com/certctl-io/certctl/master/install-agent.sh \\
| sudo bash -s -- \\
--server-url ${serverUrl} \\
--api-key ${apiKey}
# The script downloads the agent binary, writes /etc/certctl/agent.env,
# installs /etc/systemd/system/certctl-agent.service, and starts it.
# Check status with: sudo systemctl status certctl-agent`,
},
macos: {
label: 'Install via shell script (launchd service)',
code: `# Non-interactive install (recommended for curl | bash):
curl -sSL https://raw.githubusercontent.com/certctl-io/certctl/master/install-agent.sh \\
| bash -s -- \\
--server-url ${serverUrl} \\
--api-key ${apiKey}
# The script writes ~/.certctl/agent.env and loads
# ~/Library/LaunchAgents/com.certctl.agent.plist.
# Check status with: launchctl list | grep certctl`,
},
docker: {
label: 'Run as Docker container',
code: `docker run -d --name certctl-agent \\
-e CERTCTL_SERVER_URL=${serverUrl} \\
-e CERTCTL_API_KEY=${apiKey} \\
ghcr.io/certctl-io/certctl-agent:latest`,
},
};
return (
<div>
<h2 className="text-lg font-semibold text-ink mb-1">Deploy a certctl Agent</h2>
<p className="text-sm text-ink-muted mb-6">
Agents run on your infrastructure to manage certificates, generate keys, and deploy to targets.
Install one now or skip to do it later.
</p>
{/* OS Tabs */}
<div className="flex gap-1 mb-4 bg-surface-border/30 rounded-lg p-1 w-fit">
{tabs.map(t => (
<button
key={t.key}
onClick={() => setActiveTab(t.key)}
className={`px-4 py-1.5 text-sm rounded-md transition-colors ${
activeTab === t.key
? 'bg-surface text-ink font-medium shadow-sm'
: 'text-ink-muted hover:text-ink'
}`}
>
{t.label}
</button>
))}
</div>
<CodeBlock code={commands[activeTab].code} label={commands[activeTab].label} />
{/* Agent detection */}
<div className="mt-6 p-4 border border-surface-border rounded-lg">
<div className="flex items-center gap-3">
{hasAgents ? (
<>
<div className="w-3 h-3 rounded-full bg-emerald-500" />
<div>
<div className="text-sm font-medium text-emerald-700">
{agentList.length} agent{agentList.length !== 1 ? 's' : ''} detected
</div>
<div className="text-xs text-ink-muted mt-0.5">
{agentList.slice(0, 3).map(a => a.name || a.id).join(', ')}
{agentList.length > 3 && ` and ${agentList.length - 3} more`}
</div>
</div>
</>
) : (
<>
<div className="w-3 h-3 rounded-full bg-amber-400 animate-pulse" />
<div className="text-sm text-ink-muted">
Waiting for an agent to connect... <span className="text-xs">(polling every 5s)</span>
</div>
</>
)}
</div>
</div>
<WizardFooter
onSkip={onSkip}
onNext={onNext}
nextLabel={hasAgents ? 'Next: Add Certificate' : 'Next: Add Certificate'}
/>
</div>
);
}
@@ -0,0 +1,504 @@
// Phase 4 closure (FE-M3): OnboardingWizard mega-page split — Step 3.
// Add a Certificate. Behavior preserved byte-equivalent from the
// pre-split src/pages/OnboardingWizard.tsx lines 414-897 (CertificateStep
// + the two inline modals it owns — CreateTeamModalInline and
// CreateOwnerModalInline). The inline modals live in this file because
// they are tightly coupled to the certificate form (they're invoked
// from inline "+ New team / + New owner" affordances), so splitting
// them into their own file would just add an import edge for zero
// reuse outside this step.
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { useTrackedMutation } from '../../hooks/useTrackedMutation';
import {
getIssuers, getAgents, getProfiles, getOwners, getTeams, getRenewalPolicies,
createCertificate, triggerRenewal, createTeam, createOwner,
} from '../../api/client';
import { WizardFooter } from './StepShell';
// Inline CreateTeamModal — mirrors TeamsPage.tsx CreateTeamModal pattern.
// Used inside CertificateStep so users can create a team without leaving the wizard.
function CreateTeamModalInline({ isOpen, onClose, onCreated }: {
isOpen: boolean;
onClose: () => void;
onCreated: (teamId: string) => void;
}) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [error, setError] = useState('');
const mutation = useTrackedMutation({
mutationFn: () => createTeam({ name: name.trim(), description: description.trim() }),
invalidates: [['teams']],
onSuccess: (team) => {
setName('');
setDescription('');
setError('');
onCreated(team.id);
onClose();
},
onError: (err: Error) => setError(err.message),
});
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-ink mb-4">Create Team</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
<form onSubmit={(e) => { e.preventDefault(); if (!name.trim()) return; mutation.mutate(); }} className="space-y-4">
<div>
<label className="block text-sm font-medium text-ink mb-2">
Name <span className="text-red-600">*</span>
</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Platform Engineering"
autoFocus
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-2">
Description <span className="text-xs text-ink-muted font-normal">(optional)</span>
</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
/>
</div>
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={mutation.isPending || !name.trim()}
className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
{mutation.isPending ? 'Creating...' : 'Create Team'}
</button>
<button type="button" onClick={onClose} className="flex-1 btn btn-ghost">Cancel</button>
</div>
</form>
</div>
</div>
);
}
// Inline CreateOwnerModal — mirrors OwnersPage.tsx CreateOwnerModal pattern.
// Used inside CertificateStep so users can create an owner without leaving the wizard.
function CreateOwnerModalInline({ isOpen, onClose, onCreated, teams }: {
isOpen: boolean;
onClose: () => void;
onCreated: (ownerId: string) => void;
teams: { id: string; name: string }[];
}) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [teamId, setTeamId] = useState('');
const [error, setError] = useState('');
const mutation = useTrackedMutation({
mutationFn: () => createOwner({
name: name.trim(),
email: email.trim(),
team_id: teamId || undefined,
}),
invalidates: [['owners']],
onSuccess: (owner) => {
setName('');
setEmail('');
setTeamId('');
setError('');
onCreated(owner.id);
onClose();
},
onError: (err: Error) => setError(err.message),
});
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-ink mb-4">Create Owner</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
<form
onSubmit={(e) => {
e.preventDefault();
if (!name.trim() || !email.trim()) return;
mutation.mutate();
}}
className="space-y-4"
>
<div>
<label className="block text-sm font-medium text-ink mb-2">
Name <span className="text-red-600">*</span>
</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Alice Chen"
autoFocus
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-2">
Email <span className="text-red-600">*</span>
</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="alice@example.com"
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-2">
Team <span className="text-xs text-ink-muted font-normal">(optional)</span>
</label>
<select
value={teamId}
onChange={e => setTeamId(e.target.value)}
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500 transition-colors"
>
<option value="">Unassigned</option>
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={mutation.isPending || !name.trim() || !email.trim()}
className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
{mutation.isPending ? 'Creating...' : 'Create Owner'}
</button>
<button type="button" onClick={onClose} className="flex-1 btn btn-ghost">Cancel</button>
</div>
</form>
</div>
</div>
);
}
export default function CertificateStep({ onNext, onSkip, createdIssuerId }: {
onNext: (certName?: string) => void;
onSkip: () => void;
createdIssuerId: string | null;
}) {
const [name, setName] = useState('');
const [commonName, setCommonName] = useState('');
const [sans, setSans] = useState('');
const [issuerId, setIssuerId] = useState(createdIssuerId || '');
const [profileId, setProfileId] = useState('');
const [ownerId, setOwnerId] = useState('');
const [teamId, setTeamId] = useState('');
const [renewalPolicyId, setRenewalPolicyId] = useState('');
const [error, setError] = useState('');
const [created, setCreated] = useState(false);
// Inline-create modals so users never have to leave the wizard (UX-001).
const [teamModalOpen, setTeamModalOpen] = useState(false);
const [ownerModalOpen, setOwnerModalOpen] = useState(false);
// C-001: the server requires name, common_name, issuer_id, owner_id,
// team_id, and renewal_policy_id (handler in
// internal/api/handler/certificates.go + ManagedCertificate.required in
// api/openapi.yaml). The wizard must collect the same six fields so that
// "Issue Certificate" doesn't 400 at the API boundary.
const { data: issuers } = useQuery({ queryKey: ['issuers'], queryFn: () => getIssuers() });
const { data: profiles } = useQuery({ queryKey: ['profiles'], queryFn: () => getProfiles() });
const { data: agents } = useQuery({ queryKey: ['agents'], queryFn: () => getAgents() });
const { data: owners } = useQuery({ queryKey: ['owners'], queryFn: () => getOwners({ per_page: '500' }) });
const { data: teams } = useQuery({ queryKey: ['teams'], queryFn: () => getTeams({ per_page: '500' }) });
// G-1: bind renewal_policy_id dropdown to /api/v1/renewal-policies (rp-* IDs
// from the renewal_policies table). Previously populated from getPolicies()
// which returned compliance rules (pol-* IDs) and violated the FK
// managed_certificates.renewal_policy_id → renewal_policies(id) on submit.
const { data: policies } = useQuery({ queryKey: ['renewal-policies'], queryFn: () => getRenewalPolicies(1, 500) });
const hasAgents = (agents?.data?.length ?? 0) > 0;
const createMutation = useTrackedMutation({
mutationFn: async () => {
const sanList = sans.split(',').map(s => s.trim()).filter(Boolean);
const cert = await createCertificate({
name,
common_name: commonName,
sans: sanList,
issuer_id: issuerId,
certificate_profile_id: profileId || undefined,
owner_id: ownerId,
team_id: teamId,
renewal_policy_id: renewalPolicyId,
environment: 'production',
});
// Trigger issuance
await triggerRenewal(cert.id);
return cert;
},
invalidates: [['certificates'], ['dashboard-summary']],
onSuccess: (cert) => {
setCreated(true);
setTimeout(() => onNext(cert.common_name), 1500);
},
onError: (err: Error) => setError(err.message),
});
if (created) {
return (
<div>
<h2 className="text-lg font-semibold text-ink mb-2">Certificate Requested</h2>
<div className="bg-emerald-50 border border-emerald-200 rounded p-4">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm font-medium text-emerald-700">
Certificate for {commonName} has been requested. Moving to summary...
</span>
</div>
</div>
</div>
);
}
return (
<div>
<h2 className="text-lg font-semibold text-ink mb-1">Add a Certificate</h2>
<p className="text-sm text-ink-muted mb-6">
Issue your first certificate, or skip this step and explore the dashboard.
</p>
<div className="space-y-5">
<div>
<label className="block text-sm font-medium text-ink mb-2">
Name <span className="text-red-600">*</span>
</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="API Production Cert"
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-2">
Common Name <span className="text-red-600">*</span>
</label>
<input
type="text"
value={commonName}
onChange={e => setCommonName(e.target.value)}
placeholder="example.com"
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-2">
Subject Alternative Names <span className="text-xs text-ink-muted font-normal">(comma-separated)</span>
</label>
<input
type="text"
value={sans}
onChange={e => setSans(e.target.value)}
placeholder="www.example.com, api.example.com"
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-ink mb-2">
Issuer <span className="text-red-600">*</span>
</label>
<select
value={issuerId}
onChange={e => setIssuerId(e.target.value)}
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500 transition-colors"
>
<option value="">Select issuer...</option>
{issuers?.data?.map(iss => (
<option key={iss.id} value={iss.id}>{iss.name} ({iss.type})</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-2">
Profile <span className="text-xs text-ink-muted font-normal">(optional)</span>
</label>
<select
value={profileId}
onChange={e => setProfileId(e.target.value)}
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500 transition-colors"
>
<option value="">Default</option>
{profiles?.data?.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-ink">
Owner <span className="text-red-600">*</span>
</label>
<button
type="button"
onClick={() => setOwnerModalOpen(true)}
className="text-xs text-brand-600 hover:text-brand-700 hover:underline"
>
+ New owner
</button>
</div>
<select
value={ownerId}
onChange={e => setOwnerId(e.target.value)}
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500 transition-colors"
>
<option value="">Select owner...</option>
{owners?.data?.map(o => (
<option key={o.id} value={o.id}>
{o.name}{o.email ? ` (${o.email})` : ''}
</option>
))}
</select>
{(owners?.data?.length ?? 0) === 0 && (
<p className="mt-1 text-xs text-ink-muted">
No owners yet {' '}
<button
type="button"
onClick={() => setOwnerModalOpen(true)}
className="underline hover:text-ink"
>
create one now
</button>
.
</p>
)}
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-ink">
Team <span className="text-red-600">*</span>
</label>
<button
type="button"
onClick={() => setTeamModalOpen(true)}
className="text-xs text-brand-600 hover:text-brand-700 hover:underline"
>
+ New team
</button>
</div>
<select
value={teamId}
onChange={e => setTeamId(e.target.value)}
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500 transition-colors"
>
<option value="">Select team...</option>
{teams?.data?.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
{(teams?.data?.length ?? 0) === 0 && (
<p className="mt-1 text-xs text-ink-muted">
No teams yet {' '}
<button
type="button"
onClick={() => setTeamModalOpen(true)}
className="underline hover:text-ink"
>
create one now
</button>
.
</p>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-2">
Renewal Policy <span className="text-red-600">*</span>
</label>
<select
value={renewalPolicyId}
onChange={e => setRenewalPolicyId(e.target.value)}
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500 transition-colors"
>
<option value="">Select renewal policy...</option>
{policies?.data?.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
{(policies?.data?.length ?? 0) === 0 && (
<p className="mt-1 text-xs text-ink-muted">
No renewal policies yet create one from the <Link to="/policies" className="underline hover:text-ink">Policies page</Link> first, then return here.
</p>
)}
</div>
</div>
{/* Discovery hint */}
{hasAgents && (
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded text-sm text-blue-700">
<span className="font-medium">Already have certificates on disk?</span>{' '}
Visit the <Link to="/discovery" className="underline hover:text-blue-900">Discovery page</Link> to
import and manage existing certificates found by your agents.
</div>
)}
{!hasAgents && (
<div className="mt-6 p-4 bg-gray-50 border border-gray-200 rounded text-sm text-ink-muted">
<span className="font-medium">Tip:</span> Deploy an agent with{' '}
<code className="bg-gray-200 px-1 rounded text-xs">CERTCTL_DISCOVERY_DIRS=/etc/ssl/certs</code>{' '}
to automatically discover existing certificates on your infrastructure.
</div>
)}
{error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>
)}
<WizardFooter
onSkip={onSkip}
onNext={() => createMutation.mutate()}
nextLabel={createMutation.isPending ? 'Creating...' : 'Issue Certificate'}
nextDisabled={
!name ||
!commonName ||
!issuerId ||
!ownerId ||
!teamId ||
!renewalPolicyId ||
createMutation.isPending
}
/>
<CreateTeamModalInline
isOpen={teamModalOpen}
onClose={() => setTeamModalOpen(false)}
onCreated={(id) => setTeamId(id)}
/>
<CreateOwnerModalInline
isOpen={ownerModalOpen}
onClose={() => setOwnerModalOpen(false)}
onCreated={(id) => setOwnerId(id)}
teams={(teams?.data ?? []).map(t => ({ id: t.id, name: t.name }))}
/>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
// Phase 4 closure (FE-M3): OnboardingWizard mega-page split — Step 4.
// Summary + "You're all set!" review screen. Behavior preserved
// byte-equivalent from the pre-split OnboardingWizard.tsx lines 901-975.
import { useQuery } from '@tanstack/react-query';
import { getIssuers, getAgents } from '../../api/client';
export default function CompleteStep({ onFinish, issuerName, certName }: {
onFinish: () => void;
issuerName: string | null;
certName: string | null;
}) {
const { data: issuers } = useQuery({ queryKey: ['issuers'], queryFn: () => getIssuers() });
const { data: agents } = useQuery({ queryKey: ['agents'], queryFn: () => getAgents() });
const issuerCount = issuers?.data?.length ?? 0;
const agentCount = agents?.data?.length ?? 0;
return (
<div className="text-center py-8">
<div className="w-16 h-16 mx-auto mb-6 bg-emerald-100 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h2 className="text-xl font-semibold text-ink mb-2">You're all set!</h2>
<p className="text-sm text-ink-muted mb-8 max-w-md mx-auto">
certctl is ready to manage your certificate lifecycle. Here's what's configured:
</p>
{/* Summary */}
<div className="max-w-sm mx-auto mb-8 space-y-3 text-left">
<div className="flex items-center gap-3 p-3 bg-surface border border-surface-border rounded">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs ${issuerCount > 0 ? 'bg-emerald-100 text-emerald-600' : 'bg-gray-100 text-gray-400'}`}>
{issuerCount > 0 ? (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
) : ''}
</div>
<div className="text-sm">
<span className="font-medium text-ink">
{issuerCount > 0 ? `${issuerCount} issuer${issuerCount !== 1 ? 's' : ''} configured` : 'No issuers configured'}
</span>
{issuerName && <span className="text-ink-muted ml-1">({issuerName})</span>}
</div>
</div>
<div className="flex items-center gap-3 p-3 bg-surface border border-surface-border rounded">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs ${agentCount > 0 ? 'bg-emerald-100 text-emerald-600' : 'bg-gray-100 text-gray-400'}`}>
{agentCount > 0 ? (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
) : ''}
</div>
<span className="text-sm font-medium text-ink">
{agentCount > 0 ? `${agentCount} agent${agentCount !== 1 ? 's' : ''} connected` : 'No agents deployed yet'}
</span>
</div>
<div className="flex items-center gap-3 p-3 bg-surface border border-surface-border rounded">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs ${certName ? 'bg-emerald-100 text-emerald-600' : 'bg-gray-100 text-gray-400'}`}>
{certName ? (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
) : ''}
</div>
<span className="text-sm font-medium text-ink">
{certName ? `Certificate requested: ${certName}` : 'No certificates added yet'}
</span>
</div>
</div>
<button onClick={onFinish} className="btn btn-primary text-sm px-8 mb-6">
Go to Dashboard
</button>
<div className="flex justify-center gap-6 text-xs">
<a href="https://github.com/certctl-io/certctl/blob/master/docs/quickstart.md" target="_blank" rel="noopener noreferrer" className="text-accent hover:text-accent-bright">Quickstart Guide</a>
<a href="https://github.com/certctl-io/certctl/blob/master/docs/architecture.md" target="_blank" rel="noopener noreferrer" className="text-accent hover:text-accent-bright">Architecture</a>
<a href="https://github.com/certctl-io/certctl/blob/master/docs/connectors.md" target="_blank" rel="noopener noreferrer" className="text-accent hover:text-accent-bright">Connectors</a>
</div>
</div>
);
}
+179
View File
@@ -0,0 +1,179 @@
// Phase 4 closure (FE-M3): OnboardingWizard mega-page split — Step 1.
// Connect a Certificate Authority. Behavior preserved byte-equivalent
// from the pre-split src/pages/OnboardingWizard.tsx lines 112-278.
import { useState } from 'react';
import { useTrackedMutation } from '../../hooks/useTrackedMutation';
import { createIssuer, testIssuerConnection } from '../../api/client';
import { issuerTypes, type IssuerTypeConfig } from '../../config/issuerTypes';
import ConfigForm from '../../components/issuer/ConfigForm';
import type { Issuer } from '../../api/types';
import { WizardFooter } from './StepShell';
export default function IssuerStep({ onNext, onSkip, onIssuerCreated }: {
onNext: () => void;
onSkip: () => void;
onIssuerCreated: (issuer: Issuer) => void;
}) {
const [selectedType, setSelectedType] = useState<string | null>(null);
const [configValues, setConfigValues] = useState<Record<string, unknown>>({});
const [issuerName, setIssuerName] = useState('');
// Pre-populate default values when a type is selected (matches IssuersPage behavior)
function handleTypeSelect(typeId: string) {
setSelectedType(typeId);
const tc = issuerTypes.find(t => t.id === typeId);
const defaults: Record<string, unknown> = {};
tc?.configFields.forEach(f => { if (f.defaultValue !== undefined) defaults[f.key] = f.defaultValue; });
setConfigValues(defaults);
}
const [error, setError] = useState('');
const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null);
const [createdIssuer, setCreatedIssuer] = useState<Issuer | null>(null);
const typeConfig = selectedType ? issuerTypes.find(t => t.id === selectedType) : null;
const createMutation = useTrackedMutation({
mutationFn: () => createIssuer({
name: issuerName || `${typeConfig?.name || selectedType} Issuer`,
type: selectedType!,
config: configValues as Record<string, unknown>,
}),
invalidates: [['issuers']],
onSuccess: (issuer) => {
setCreatedIssuer(issuer);
onIssuerCreated(issuer);
setError('');
},
onError: (err: Error) => setError(err.message),
});
// testIssuerConnection updates last_tested_at server-side; refresh the
// issuers list so the timestamp + status columns reflect the new probe.
// The local setTestResult banner still surfaces the immediate pass/fail.
const testMutation = useTrackedMutation({
mutationFn: () => testIssuerConnection(createdIssuer!.id),
invalidates: [['issuers']],
onSuccess: () => setTestResult({ ok: true, msg: 'Connection successful' }),
onError: (err: Error) => setTestResult({ ok: false, msg: err.message }),
});
// After issuer is created successfully
if (createdIssuer) {
return (
<div>
<h2 className="text-lg font-semibold text-ink mb-2">CA Connected</h2>
<div className="bg-emerald-50 border border-emerald-200 rounded p-4 mb-4">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm font-medium text-emerald-700">
{createdIssuer.name} ({typeConfig?.name}) created successfully
</span>
</div>
</div>
{!testResult && (
<button
onClick={() => testMutation.mutate()}
disabled={testMutation.isPending}
className="btn btn-secondary text-sm mb-4"
>
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
</button>
)}
{testResult?.ok && (
<div className="bg-emerald-50 border border-emerald-200 rounded p-3 mb-4 text-sm text-emerald-700">
Connection test passed.
</div>
)}
{testResult && !testResult.ok && (
<div className="bg-red-50 border border-red-200 rounded p-3 mb-4 text-sm text-red-700">
Connection test failed: {testResult.msg}
</div>
)}
<WizardFooter onNext={onNext} nextLabel="Next: Deploy Agent" showSkip={false} />
</div>
);
}
// Type selection
if (!selectedType) {
return (
<div>
<h2 className="text-lg font-semibold text-ink mb-1">Connect a Certificate Authority</h2>
<p className="text-sm text-ink-muted mb-6">
Choose a CA to issue and manage certificates. You can add more later from the Issuers page.
</p>
<div className="grid grid-cols-2 gap-4">
{issuerTypes.filter(t => !t.comingSoon).map((type: IssuerTypeConfig) => (
<button
key={type.id}
onClick={() => handleTypeSelect(type.id)}
className="p-4 border border-surface-border rounded-lg hover:border-brand-500 hover:bg-surface-muted transition-all text-left"
>
<div className="flex items-center gap-2">
<span className="text-lg">{type.icon}</span>
<span className="font-medium text-ink">{type.name}</span>
</div>
<div className="text-xs text-ink-muted mt-1">{type.description}</div>
</button>
))}
</div>
<WizardFooter onSkip={onSkip} />
</div>
);
}
// Config form for selected type
const requiredFields = typeConfig?.configFields.filter(f => f.required) || [];
const allRequiredFilled = requiredFields.every(f => configValues[f.key]);
return (
<div>
<div className="flex items-center gap-2 mb-1">
<button onClick={() => { setSelectedType(null); setConfigValues({}); setIssuerName(''); setError(''); }}
className="text-ink-muted hover:text-ink transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<h2 className="text-lg font-semibold text-ink">
Configure {typeConfig?.name}
</h2>
</div>
<p className="text-sm text-ink-muted mb-6">{typeConfig?.description}</p>
<div className="mb-5">
<label className="block text-sm font-medium text-ink mb-2">Display Name</label>
<input
type="text"
value={issuerName}
onChange={e => setIssuerName(e.target.value)}
placeholder={`${typeConfig?.name || ''} Issuer`}
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
/>
</div>
<ConfigForm
fields={typeConfig?.configFields || []}
values={configValues}
onChange={(key, val) => setConfigValues(prev => ({ ...prev, [key]: val }))}
/>
{error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>
)}
<WizardFooter
onSkip={onSkip}
onNext={() => createMutation.mutate()}
nextLabel={createMutation.isPending ? 'Creating...' : 'Create Issuer'}
nextDisabled={!allRequiredFilled || createMutation.isPending}
/>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
// Phase 4 closure (FE-M3): OnboardingWizard mega-page split.
// Shared shell helpers used by every step:
// • StepIndicator — the top progress strip ("Connect a CA → Deploy Agent → …")
// • WizardFooter — the bottom Skip / Continue bar
// • CodeBlock — copyable install-command box (AgentStep, also reusable)
//
// Behavior copied byte-equivalent from the pre-split OnboardingWizard.tsx
// so the existing E2E vitest + the operator's muscle memory don't drift.
import { useState } from 'react';
import { STEPS, type WizardStep } from './types';
export function CodeBlock({ code, label }: { code: string; label?: string }) {
const [copied, setCopied] = useState(false);
return (
<div className="relative">
{label && <div className="text-xs text-ink-muted mb-1 font-medium">{label}</div>}
<pre className="bg-gray-900 text-gray-100 rounded p-4 text-sm font-mono overflow-x-auto whitespace-pre-wrap">
{code}
</pre>
<button
onClick={() => { navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 2000); }}
className="absolute top-2 right-2 px-2 py-1 bg-gray-700 hover:bg-gray-600 text-gray-300 text-xs rounded transition-colors"
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
);
}
export function StepIndicator({ steps, current }: { steps: typeof STEPS; current: WizardStep }) {
const currentIdx = steps.findIndex(s => s.key === current);
return (
<div className="flex items-center justify-center gap-2 mb-8">
{steps.map((s, i) => {
const isCompleted = i < currentIdx;
const isCurrent = s.key === current;
return (
<div key={s.key} className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition-colors ${
isCompleted ? 'bg-emerald-500 text-white' :
isCurrent ? 'bg-accent text-white' :
'bg-surface-border text-ink-muted'
}`}>
{isCompleted ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : i + 1}
</div>
<span className={`text-xs font-medium hidden sm:inline ${isCurrent ? 'text-ink' : 'text-ink-muted'}`}>
{s.label}
</span>
{i < steps.length - 1 && (
<div className={`w-8 h-0.5 ${i < currentIdx ? 'bg-emerald-500' : 'bg-surface-border'}`} />
)}
</div>
);
})}
</div>
);
}
export function WizardFooter({ onSkip, onNext, nextLabel, nextDisabled, showSkip = true }: {
onSkip?: () => void;
onNext?: () => void;
nextLabel?: string;
nextDisabled?: boolean;
showSkip?: boolean;
}) {
return (
<div className="flex justify-between items-center pt-6 border-t border-surface-border mt-6">
<div>
{showSkip && onSkip && (
<button onClick={onSkip} className="text-sm text-ink-muted hover:text-ink transition-colors">
Skip this step
</button>
)}
</div>
{onNext && (
<button
onClick={onNext}
disabled={nextDisabled}
className="btn btn-primary disabled:opacity-50"
>
{nextLabel || 'Continue'}
</button>
)}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
// Phase 4 closure (FE-M3): OnboardingWizard mega-page split.
// Shared types + the canonical step ordering, factored out so each
// step component imports the type without taking a dependency on the
// shell.
export type WizardStep = 'issuer' | 'agent' | 'certificate' | 'complete';
export const STEPS: { key: WizardStep; label: string }[] = [
{ key: 'issuer', label: 'Connect a CA' },
{ key: 'agent', label: 'Deploy Agent' },
{ key: 'certificate', label: 'Add Certificate' },
{ key: 'complete', label: 'Done' },
];
+33
View File
@@ -21,6 +21,39 @@ export default defineConfig({
build: { build: {
outDir: 'dist', outDir: 'dist',
sourcemap: false, sourcemap: false,
// Phase 4 closure (FE-M5 + SCALE-H1): vendor manualChunks. Pre-Phase-4
// the single index-*.js chunk weighed ~1.07 MB raw / ~281 KB gz because
// every dependency landed in the same first-load file. Splitting React,
// React Router, TanStack Query, Recharts, and lucide-react into their
// own chunks lets the browser:
// • Cache vendor chunks across deploys (only index-*.js rotates when
// feature code changes — vendor hashes only flip when those
// packages bump in package-lock.json).
// • Parallelise vendor downloads on cold loads (HTTP/2 multiplex).
// • Skip Recharts entirely on cold loads of non-Dashboard routes
// (recharts is ~410 KB unminified, see bundlephobia.com).
// Combined with React.lazy() per route in main.tsx the cold-load
// budget for a non-Dashboard route drops to vendor.react +
// vendor.router + index. Dashboard pulls vendor.recharts on demand.
// Vite 8 uses rolldown which requires manualChunks to be a function
// (id) => string, not the object-shape Vite-5-era rollup accepted.
rollupOptions: {
output: {
manualChunks(id: string) {
if (!id.includes('node_modules')) return undefined;
if (id.includes('node_modules/react-router-dom')) return 'vendor-router';
if (id.includes('node_modules/@tanstack/react-query')) return 'vendor-query';
if (id.includes('node_modules/recharts')) return 'vendor-recharts';
if (id.includes('node_modules/lucide-react')) return 'vendor-icons';
if (id.includes('node_modules/react/')
|| id.includes('node_modules/react-dom/')
|| id.includes('node_modules/scheduler/')) {
return 'vendor-react';
}
return undefined;
},
},
},
}, },
test: { test: {
globals: true, globals: true,