mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:01:30 +00:00
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:
@@ -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) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
}
|
}
|
||||||
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],
|
[searchParams, setSearchParams],
|
||||||
);
|
);
|
||||||
|
|||||||
+124
-83
@@ -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>
|
||||||
|
|||||||
+71
-149
@@ -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,17 +302,19 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
if ((showWizard && !onboardingDismissed) || forceOnboarding) {
|
if ((showWizard && !onboardingDismissed) || forceOnboarding) {
|
||||||
return (
|
return (
|
||||||
<OnboardingWizard onDismiss={() => {
|
<Suspense fallback={<Skeleton variant="page" ariaLabel="Loading onboarding wizard" />}>
|
||||||
try { localStorage.setItem('certctl:onboarding-dismissed', 'true'); } catch { /* noop */ }
|
<OnboardingWizard onDismiss={() => {
|
||||||
setOnboardingDismissed(true);
|
try { localStorage.setItem('certctl:onboarding-dismissed', 'true'); } catch { /* noop */ }
|
||||||
setShowWizard(false);
|
setOnboardingDismissed(true);
|
||||||
// Strip ?onboarding=1 so page refresh doesn't relaunch the wizard
|
setShowWizard(false);
|
||||||
if (searchParams.has('onboarding')) {
|
// Strip ?onboarding=1 so page refresh doesn't relaunch the wizard
|
||||||
const next = new URLSearchParams(searchParams);
|
if (searchParams.has('onboarding')) {
|
||||||
next.delete('onboarding');
|
const next = new URLSearchParams(searchParams);
|
||||||
setSearchParams(next, { replace: true });
|
next.delete('onboarding');
|
||||||
}
|
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
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
];
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user