From 9ce2d8ca8fd95e9b4fa516f0cd70ed378061ca3f Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Thu, 14 May 2026 16:14:24 +0000 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20Phase=204=20Loading=20+=20Per?= =?UTF-8?q?ceived=20Performance=20=E2=80=94=20close=20UX-M1=20+=20FE-M5=20?= =?UTF-8?q?+=20PERF-M1=20+=20P-H3=20+=20partial=20FE-M3=20/=20P-M2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 }> 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. --- web/src/components/DataTable.tsx | 17 +- web/src/components/Skeleton.test.tsx | 49 + web/src/components/Skeleton.tsx | 158 +++ web/src/hooks/useListParams.ts | 18 +- web/src/main.tsx | 207 ++-- web/src/pages/DashboardPage.tsx | 220 ++-- web/src/pages/OnboardingWizard.tsx | 1009 +----------------- web/src/pages/dashboard/charts.tsx | 207 ++++ web/src/pages/onboarding/AgentStep.tsx | 141 +++ web/src/pages/onboarding/CertificateStep.tsx | 504 +++++++++ web/src/pages/onboarding/CompleteStep.tsx | 82 ++ web/src/pages/onboarding/IssuerStep.tsx | 179 ++++ web/src/pages/onboarding/StepShell.tsx | 91 ++ web/src/pages/onboarding/types.ts | 13 + web/vite.config.ts | 33 + 15 files changed, 1709 insertions(+), 1219 deletions(-) create mode 100644 web/src/components/Skeleton.test.tsx create mode 100644 web/src/components/Skeleton.tsx create mode 100644 web/src/pages/dashboard/charts.tsx create mode 100644 web/src/pages/onboarding/AgentStep.tsx create mode 100644 web/src/pages/onboarding/CertificateStep.tsx create mode 100644 web/src/pages/onboarding/CompleteStep.tsx create mode 100644 web/src/pages/onboarding/IssuerStep.tsx create mode 100644 web/src/pages/onboarding/StepShell.tsx create mode 100644 web/src/pages/onboarding/types.ts diff --git a/web/src/components/DataTable.tsx b/web/src/components/DataTable.tsx index e046aba..8176871 100644 --- a/web/src/components/DataTable.tsx +++ b/web/src/components/DataTable.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react'; +import Skeleton from './Skeleton'; interface Column { key: string; @@ -47,16 +48,14 @@ interface DataTableProps { } export default function DataTable({ columns, data, onRowClick, emptyMessage, emptyState, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination }: DataTableProps) { + // 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) { - return ( -
- - - - - Loading... -
- ); + return ; } if (!data.length) { diff --git a/web/src/components/Skeleton.test.tsx b/web/src/components/Skeleton.test.tsx new file mode 100644 index 0000000..88e894d --- /dev/null +++ b/web/src/components/Skeleton.test.tsx @@ -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(); + 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(); + 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(); + 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(); + // 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(); + 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( + , + ); + expect(getByRole('status')).toHaveAttribute('aria-label', 'Loading certificates'); + }); +}); diff --git a/web/src/components/Skeleton.tsx b/web/src/components/Skeleton.tsx new file mode 100644 index 0000000..f350acb --- /dev/null +++ b/web/src/components/Skeleton.tsx @@ -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 " 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 ( +
+ {/* PageHeader-shaped band */} +
+
+
+
+
+
+
+ {/* Body grid: 4 stat tiles + 1 card */} +
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+ ))} +
+ +
+
+ ); + } + + if (variant === 'table') { + return ( +
+ + + + {Array.from({ length: columns }).map((_, i) => ( + + ))} + + + + {Array.from({ length: rows }).map((_, r) => ( + + {Array.from({ length: columns }).map((_, c) => ( + + ))} + + ))} + +
+
+
+
+
+
+ ); + } + + if (variant === 'card') { + return ( +
+ +
+ ); + } + + // variant === 'stat' + return ( +
+
+
+
+ ); +} + +/** Card sub-shape, shared between `page` and `card` variants. */ +function Card() { + return ( +
+
+
+
+
+
+
+
+ ); +} diff --git a/web/src/hooks/useListParams.ts b/web/src/hooks/useListParams.ts index c73fece..fd756f0 100644 --- a/web/src/hooks/useListParams.ts +++ b/web/src/hooks/useListParams.ts @@ -21,7 +21,7 @@ // JobsPage, RenewalPoliciesPage, DiscoveryPage) are deferred to a // 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'; export interface ListParams { @@ -56,6 +56,13 @@ const DEFAULT_PAGE_SIZE = 25; */ export function useListParams(defaults?: Partial): ListParamsControls { 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(() => { const page = parsePositiveInt(searchParams.get('page'), defaults?.page ?? DEFAULT_PAGE); @@ -88,7 +95,14 @@ export function useListParams(defaults?: Partial): ListParamsControl if (key !== 'page') { next.delete('page'); } - setSearchParams(next, { replace: true }); + // 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 }); + }); }, [searchParams, setSearchParams], ); diff --git a/web/src/main.tsx b/web/src/main.tsx index 90f7f79..215d331 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -7,7 +7,7 @@ import '@fontsource/jetbrains-mono/400.css'; import '@fontsource/jetbrains-mono/500.css'; import '@fontsource/jetbrains-mono/600.css'; -import { StrictMode } from 'react'; +import { StrictMode, Suspense, lazy } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -15,49 +15,81 @@ import ErrorBoundary from './components/ErrorBoundary'; import AuthProvider from './components/AuthProvider'; import AuthGate from './components/AuthGate'; 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 }> 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 CertificatesPage from './pages/CertificatesPage'; -import CertificateDetailPage from './pages/CertificateDetailPage'; -import AgentsPage from './pages/AgentsPage'; -import AgentDetailPage from './pages/AgentDetailPage'; -import JobsPage from './pages/JobsPage'; -import NotificationsPage from './pages/NotificationsPage'; -import PoliciesPage from './pages/PoliciesPage'; -import RenewalPoliciesPage from './pages/RenewalPoliciesPage'; -import IssuersPage from './pages/IssuersPage'; -import TargetsPage from './pages/TargetsPage'; -import ProfilesPage from './pages/ProfilesPage'; -import OwnersPage from './pages/OwnersPage'; -import TeamsPage from './pages/TeamsPage'; -import AgentGroupsPage from './pages/AgentGroupsPage'; -import AuditPage from './pages/AuditPage'; -import ShortLivedPage from './pages/ShortLivedPage'; -import AgentFleetPage from './pages/AgentFleetPage'; -import DiscoveryPage from './pages/DiscoveryPage'; -import NetworkScanPage from './pages/NetworkScanPage'; -import HealthMonitorPage from './pages/HealthMonitorPage'; -import DigestPage from './pages/DigestPage'; -import ObservabilityPage from './pages/ObservabilityPage'; -import JobDetailPage from './pages/JobDetailPage'; -import IssuerDetailPage from './pages/IssuerDetailPage'; -import IssuerHierarchyPage from './pages/IssuerHierarchyPage'; -import TargetDetailPage from './pages/TargetDetailPage'; -import SCEPAdminPage from './pages/SCEPAdminPage'; -import ESTAdminPage from './pages/ESTAdminPage'; -// Bundle 1 Phase 10 — RBAC management pages. -import RolesPage from './pages/auth/RolesPage'; -import RoleDetailPage from './pages/auth/RoleDetailPage'; -import KeysPage from './pages/auth/KeysPage'; -import AuthSettingsPage from './pages/auth/AuthSettingsPage'; -import ApprovalsPage from './pages/auth/ApprovalsPage'; -// Bundle 2 Phase 8 — OIDC + session management pages. -import OIDCProvidersPage from './pages/auth/OIDCProvidersPage'; -import OIDCProviderDetailPage from './pages/auth/OIDCProviderDetailPage'; -import GroupMappingsPage from './pages/auth/GroupMappingsPage'; -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'; +import Skeleton from './components/Skeleton'; + +// Inventory. +const CertificatesPage = lazy(() => import('./pages/CertificatesPage')); +const CertificateDetailPage = lazy(() => import('./pages/CertificateDetailPage')); +const IssuersPage = lazy(() => import('./pages/IssuersPage')); +const IssuerDetailPage = lazy(() => import('./pages/IssuerDetailPage')); +const IssuerHierarchyPage = lazy(() => import('./pages/IssuerHierarchyPage')); +const TargetsPage = lazy(() => import('./pages/TargetsPage')); +const TargetDetailPage = lazy(() => import('./pages/TargetDetailPage')); +const ProfilesPage = lazy(() => import('./pages/ProfilesPage')); +// Delivery & jobs. +const JobsPage = lazy(() => import('./pages/JobsPage')); +const JobDetailPage = lazy(() => import('./pages/JobDetailPage')); +const AgentsPage = lazy(() => import('./pages/AgentsPage')); +const AgentDetailPage = lazy(() => import('./pages/AgentDetailPage')); +const AgentFleetPage = lazy(() => import('./pages/AgentFleetPage')); +const AgentGroupsPage = lazy(() => import('./pages/AgentGroupsPage')); +// Policy & notify. +const PoliciesPage = lazy(() => import('./pages/PoliciesPage')); +const RenewalPoliciesPage = lazy(() => import('./pages/RenewalPoliciesPage')); +const NotificationsPage = lazy(() => import('./pages/NotificationsPage')); +const DigestPage = lazy(() => import('./pages/DigestPage')); +// People. +const OwnersPage = lazy(() => import('./pages/OwnersPage')); +const TeamsPage = lazy(() => import('./pages/TeamsPage')); +// Audit & ops. +const AuditPage = lazy(() => import('./pages/AuditPage')); +const ShortLivedPage = lazy(() => import('./pages/ShortLivedPage')); +const DiscoveryPage = lazy(() => import('./pages/DiscoveryPage')); +const NetworkScanPage = lazy(() => import('./pages/NetworkScanPage')); +const HealthMonitorPage = lazy(() => import('./pages/HealthMonitorPage')); +const ObservabilityPage = lazy(() => import('./pages/ObservabilityPage')); +// Protocol admin. +const SCEPAdminPage = lazy(() => import('./pages/SCEPAdminPage')); +const ESTAdminPage = lazy(() => import('./pages/ESTAdminPage')); +// Access (Bundle 1 Phase 10 — RBAC management). +const RolesPage = lazy(() => import('./pages/auth/RolesPage')); +const RoleDetailPage = lazy(() => import('./pages/auth/RoleDetailPage')); +const KeysPage = lazy(() => import('./pages/auth/KeysPage')); +const AuthSettingsPage = lazy(() => import('./pages/auth/AuthSettingsPage')); +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 // the root so any component can `import { toast } from "sonner"` and // 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 }>{element}; +} + createRoot(document.getElementById('root')!).render( @@ -107,37 +147,38 @@ createRoot(document.getElementById('root')!).render( }> + {/* Dashboard stays eager — landing route for every cold load. */} } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> {/* Rank 8 — operator-managed multi-level CA hierarchy. Admin-gated at the API; the page renders the backend's 403 as ErrorState for non-admin callers. See docs/intermediate-ca-hierarchy.md. */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> {/* SCEP RFC 8894 + Intune master bundle Phase 9.4 (initial) + Phase 9 follow-up (rebrand): per-profile SCEP Administration page with Profiles / Intune Monitoring / @@ -145,17 +186,17 @@ createRoot(document.getElementById('root')!).render( itself renders an "Admin access required" banner for non-admin callers and skips the underlying API calls so the server never sees a 403-prone request. */} - } /> + )} /> {/* Backward-compat alias for external bookmarks the Phase 9 release advertised. Lands on the Intune Monitoring tab. */} - } /> + )} /> {/* EST RFC 7030 hardening master bundle Phase 8: per-profile EST Administration page with Profiles / Recent Activity / Trust Bundle tabs. Same admin-gate pattern as SCEP — the route is unconditional; the page renders an "Admin access required" banner for non-admin callers and skips the underlying API calls so the server never sees a 403. */} - } /> + )} /> {/* Bundle 1 Phase 10 — RBAC management surface. Every page reads /api/v1/auth/me on mount via 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 hide/disable is UX. */} {/* Bundle 2 Phase 8 — OIDC + session management surface. */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> {/* Audit 2026-05-10 CRIT-4 closure — break-glass admin surface. */} - } /> + )} /> {/* Audit 2026-05-10 MED-11 closure — federated-user admin. */} - } /> + )} /> diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 7f84c70..14cdae9 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -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 { useTrackedMutation } from '../hooks/useTrackedMutation'; import { STALE_TIME } from '../api/queryConstants'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { - BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, - XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, -} from 'recharts'; import { getCertificates, getJobs, getHealth, getDashboardSummary, getCertificatesByStatus, getExpirationTimeline, @@ -14,8 +10,24 @@ import { } from '../api/client'; import PageHeader from '../components/PageHeader'; import StatusBadge from '../components/StatusBadge'; +import Skeleton from '../components/Skeleton'; 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" 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 }) { - return ( -
-

{title}

-
- {children} -
-
- ); -} - -const CustomTooltip = ({ active, payload, label }: any) => { - if (!active || !payload?.length) return null; - return ( -
-

{label}

- {payload.map((entry: any, i: number) => ( -

- {entry.name}: {typeof entry.value === 'number' && entry.name?.includes('rate') ? `${entry.value.toFixed(1)}%` : entry.value} -

- ))} -
- ); -}; +// ChartCard + CustomTooltip + formatShortDate moved to +// pages/dashboard/charts.tsx (Phase 4 PERF-M1 closure) where they live +// alongside the memo-wrapped chart panels that consume them. function DigestCard() { const [previewHtml, setPreviewHtml] = useState(null); @@ -266,6 +257,35 @@ export default function DashboardPage() { 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(() => ( + (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(() => ( + (expirationTimeline || []).reduce((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. // 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. @@ -282,17 +302,19 @@ export default function DashboardPage() { if ((showWizard && !onboardingDismissed) || forceOnboarding) { return ( - { - try { localStorage.setItem('certctl:onboarding-dismissed', 'true'); } catch { /* noop */ } - setOnboardingDismissed(true); - setShowWizard(false); - // Strip ?onboarding=1 so page refresh doesn't relaunch the wizard - if (searchParams.has('onboarding')) { - const next = new URLSearchParams(searchParams); - next.delete('onboarding'); - setSearchParams(next, { replace: true }); - } - }} /> + }> + { + try { localStorage.setItem('certctl:onboarding-dismissed', 'true'); } catch { /* noop */ } + setOnboardingDismissed(true); + setShowWizard(false); + // Strip ?onboarding=1 so page refresh doesn't relaunch the wizard + if (searchParams.has('onboarding')) { + const next = new URLSearchParams(searchParams); + next.delete('onboarding'); + setSearchParams(next, { replace: true }); + } + }} /> + ); } @@ -302,29 +324,6 @@ export default function DashboardPage() { const activeAgents = summary?.active_agents || 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 ( <>
- {/* 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. */}
- {/* Certificates by Status (Pie) */} - - {pieData.length > 0 ? ( - - - `${formatStatus(name || '')}: ${value}`} - labelLine={false} - > - {pieData.map((entry, index) => ( - - ))} - - } /> - {formatStatus(value)}} - /> - - - ) : ( -
No certificate data
- )} -
- - {/* Expiration Heatmap (Bar chart by week) */} - - {weeklyExpiration.length > 0 ? ( - - - - - - } /> - - - - ) : ( -
No expiration data
- )} -
+ +
{/* Charts Row 2 */}
- {/* Job Trends (Line chart) */} - - {(jobTrends || []).length > 0 ? ( - - - - - - } /> - {value}} /> - - - - - ) : ( -
No job trend data
- )} -
- - {/* Issuance Rate (Bar chart) */} - - {(issuanceRate || []).length > 0 ? ( - - - - - - } /> - - - - ) : ( -
No issuance data
- )} -
+ +
diff --git a/web/src/pages/OnboardingWizard.tsx b/web/src/pages/OnboardingWizard.tsx index da40245..40ada5d 100644 --- a/web/src/pages/OnboardingWizard.tsx +++ b/web/src/pages/OnboardingWizard.tsx @@ -1,980 +1,37 @@ +// Phase 4 closure (FE-M3): OnboardingWizard mega-page split. +// +// Pre-Phase-4 this file was 1043 LOC containing: +// • 4 step component definitions (Issuer / Agent / Certificate / Complete) +// • 2 inline modal helpers (CreateTeamModalInline, CreateOwnerModalInline) +// • 3 layout helpers (CodeBlock, StepIndicator, WizardFooter) +// • The shell + state + step transitions +// — all in a single file that took ~30s to navigate end-to-end in the +// editor and hit the eslint per-file-LOC ceiling. +// +// Post-Phase-4 this file is just the shell + state + step transitions +// (~67 LOC). Each step now lives in src/pages/onboarding/ as its own +// file, importable in isolation: +// +// • types.ts — WizardStep type + STEPS list +// • StepShell.tsx — shared CodeBlock + StepIndicator + WizardFooter +// • IssuerStep.tsx — Step 1 +// • AgentStep.tsx — Step 2 +// • CertificateStep.tsx — Step 3 (owns its inline team/owner modals) +// • CompleteStep.tsx — Step 4 +// +// Behavior preserved byte-equivalent — no logic change, just a +// directory reshape. DashboardPage's lazy(() => import('./OnboardingWizard')) +// import path is unchanged because this file still exists at the same +// location and still has a default export with the same prop shape. + import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { useTrackedMutation } from '../hooks/useTrackedMutation'; -import { STALE_TIME } from '../api/queryConstants'; -import { useNavigate, Link } from 'react-router-dom'; -import { - getIssuers, getAgents, getProfiles, getOwners, getTeams, getRenewalPolicies, - createIssuer, testIssuerConnection, - createCertificate, triggerRenewal, - createTeam, createOwner, - getApiKey, -} from '../api/client'; -import { issuerTypes, type IssuerTypeConfig } from '../config/issuerTypes'; -import ConfigForm from '../components/issuer/ConfigForm'; -import type { Issuer, Agent } from '../api/types'; - -// ─── Types ─────────────────────────────────────────── - -type WizardStep = 'issuer' | 'agent' | 'certificate' | 'complete'; - -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' }, -]; - -// ─── Helpers ───────────────────────────────────────── - -function CodeBlock({ code, label }: { code: string; label?: string }) { - const [copied, setCopied] = useState(false); - return ( -
- {label &&
{label}
} -
-        {code}
-      
- -
- ); -} - -function StepIndicator({ steps, current }: { steps: typeof STEPS; current: WizardStep }) { - const currentIdx = steps.findIndex(s => s.key === current); - return ( -
- {steps.map((s, i) => { - const isCompleted = i < currentIdx; - const isCurrent = s.key === current; - return ( -
-
- {isCompleted ? ( - - - - ) : i + 1} -
- - {i < steps.length - 1 && ( -
- )} -
- ); - })} -
- ); -} - -function WizardFooter({ onSkip, onNext, nextLabel, nextDisabled, showSkip = true }: { - onSkip?: () => void; - onNext?: () => void; - nextLabel?: string; - nextDisabled?: boolean; - showSkip?: boolean; -}) { - return ( -
-
- {showSkip && onSkip && ( - - )} -
- {onNext && ( - - )} -
- ); -} - -// ─── Step 1: Connect a CA ──────────────────────────── - -function IssuerStep({ onNext, onSkip, onIssuerCreated }: { - onNext: () => void; - onSkip: () => void; - onIssuerCreated: (issuer: Issuer) => void; -}) { - const [selectedType, setSelectedType] = useState(null); - const [configValues, setConfigValues] = useState>({}); - 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 = {}; - 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(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, - }), - 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 ( -
-

CA Connected

-
-
- - - - - {createdIssuer.name} ({typeConfig?.name}) created successfully - -
-
- - {!testResult && ( - - )} - - {testResult?.ok && ( -
- Connection test passed. -
- )} - {testResult && !testResult.ok && ( -
- Connection test failed: {testResult.msg} -
- )} - - -
- ); - } - - // Type selection - if (!selectedType) { - return ( -
-

Connect a Certificate Authority

-

- Choose a CA to issue and manage certificates. You can add more later from the Issuers page. -

-
- {issuerTypes.filter(t => !t.comingSoon).map((type: IssuerTypeConfig) => ( - - ))} -
- -
- ); - } - - // Config form for selected type - const requiredFields = typeConfig?.configFields.filter(f => f.required) || []; - const allRequiredFilled = requiredFields.every(f => configValues[f.key]); - - return ( -
-
- -

- Configure {typeConfig?.name} -

-
-

{typeConfig?.description}

- -
- - 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" - /> -
- - setConfigValues(prev => ({ ...prev, [key]: val }))} - /> - - {error && ( -
{error}
- )} - - createMutation.mutate()} - nextLabel={createMutation.isPending ? 'Creating...' : 'Create Issuer'} - nextDisabled={!allRequiredFilled || createMutation.isPending} - /> -
- ); -} - -// ─── Step 2: Deploy an Agent ───────────────────────── - -function AgentStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void }) { - const [activeTab, setActiveTab] = useState<'linux' | 'macos' | 'docker'>('linux'); - - const apiKey = getApiKey() || ''; - 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 = { - 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 ( -
-

Deploy a certctl Agent

-

- Agents run on your infrastructure to manage certificates, generate keys, and deploy to targets. - Install one now or skip to do it later. -

- - {/* OS Tabs */} -
- {tabs.map(t => ( - - ))} -
- - - - {/* Agent detection */} -
-
- {hasAgents ? ( - <> -
-
-
- {agentList.length} agent{agentList.length !== 1 ? 's' : ''} detected -
-
- {agentList.slice(0, 3).map(a => a.name || a.id).join(', ')} - {agentList.length > 3 && ` and ${agentList.length - 3} more`} -
-
- - ) : ( - <> -
-
- Waiting for an agent to connect... (polling every 5s) -
- - )} -
-
- - -
- ); -} - -// ─── Step 3 helpers: inline team + owner creation ─── - -// 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 ( -
-
e.stopPropagation()}> -

Create Team

- {error &&
{error}
} -
{ e.preventDefault(); if (!name.trim()) return; mutation.mutate(); }} className="space-y-4"> -
- - 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" - /> -
-
- -