Files
certctl/web/src/main.tsx
T
shankar0123 aa1c12ae2d feat(web): Phase 9 — backend-coupled + page-specific closures (5 shipped, 2 deferred)
Closes the frontend-design-audit Phase 9 batch — the audit's
"backend-coupled or page-specific" tier. Five findings ship; two
defer to follow-ups that need backend handler work.

Shipped:

PERF-M2 — Build-time version + hidden sourcemaps
  • vite.config.ts: `sourcemap: 'hidden'` (was `false`). Maps emit
    to dist/ but are NOT referenced by JS, so browsers don't fetch
    them. The maps stay available for Sentry-class upload at
    release time. Comment-block above the build config documents
    the tradeoff so a future operator doesn't re-flip to `false`
    without realising they're losing release-time debuggability.
  • `__APP_VERSION__` build-time `define` reads `web/package.json`
    `version` so ErrorBoundary can stamp the build into telemetry
    payloads (was previously hardcoded `'dev'`).

FE-L1 — ErrorBoundary copy-trace + telemetry gate
  • 50 → 185 LOC rewrite of web/src/components/ErrorBoundary.tsx.
  • componentDidCatch now POSTs an ErrorPayload (build version,
    UA, href, timestamp, error name + message + stack,
    componentStack) to `VITE_ERROR_TELEMETRY_URL` IF that env var
    is set at build time. Uses navigator.sendBeacon (page-unload-
    safe) → falls back to fetch + keepalive. Unset = no POST,
    no console-error spam.
  • Operator-facing "Copy details" button writes the same payload
    as JSON to the clipboard (navigator.clipboard API → execCommand
    fallback for older browsers). A `<details>` block (collapsed
    by default) shows the stack + componentStack inline so the
    operator can grok the failure without leaving the page.
  • Two new data-testid hooks (`error-boundary-reload`,
    `error-boundary-copy`) for QA + future Playwright coverage.
  • web/src/components/ErrorBoundary.test.tsx — 5 vitest specs:
    no-error pass-through, error fallback structure, copy payload
    shape, details collapsed-by-default, NO telemetry POST when
    URL is unset. cleanup() between tests + console.error
    silenced via the React-error-handling pattern.

UX-M8 — DataTable density toggle (opt-in via tableId)
  • Density type ('compact' | 'comfortable' | 'spacious') + per-
    density cell/header class maps. Default 'comfortable' matches
    the existing px-4 py-3 padding so all callers see byte-
    identical layout until they opt in.
  • DataTableProps gains optional `tableId` + `density` props.
    Pages that pass `tableId` get a 3-button DensityToggle
    (Compact / Cozy / Spacious) rendered above the table; the
    selection persists to localStorage at
    `certctl:table-density:<tableId>`. No tableId = no toggle =
    no behavioral change for the 17 other tables.
  • Hardcoded `px-4 py-3` replaced with the `cellCls` /
    `headerCls` lookup against the active density. Three Tailwind
    permutations cover compact (px-3 py-1.5), comfortable
    (px-4 py-3), spacious (px-5 py-5).

UX-M7 (lever) — CI guard against new raw `<table>` regressions
  • scripts/ci-guards/no-raw-table.sh: counts `<table` tags in
    `web/src/**/*.tsx` (production only, tests excluded) outside
    the canonical primitives (DataTable.tsx + Skeleton.tsx) and
    fails CI if the count climbs above baseline. `--strict` mode
    rejects any raw table once the backlog clears.
  • Baseline pinned at 17 (the current count of page-level raw
    tables — verified via the same grep the guard uses). Every
    page migration to <DataTable> drops the baseline by 1; new
    pages MUST route through <DataTable>.
  • No representative migrations in this commit (operator
    decision: ship the lever first, migrations as follow-up PRs).
  • Pairs with the existing CI guard suite (no-unbound-label,
    no-raw-toLocaleString, no-eager-issuer-deletes, etc.) —
    same baseline-locked pattern.

FE-M2 — Desktop-only banner (operator chose path a: 2026-05-14)
  • web/src/components/DesktopOnlyBanner.tsx: fixed top bar at
    viewports < 1024px (Tailwind `lg` breakpoint, below which the
    sidebar + content layout starts visibly cramping). Amber
    "Desktop-only: certctl is designed for viewports ≥ 1024px"
    notice with a Dismiss button that persists to localStorage
    (`certctl:desktop-only-banner-dismissed`).
  • web/src/index.css: `.desktop-only-banner` is `display: none`
    by default and `display: flex` inside the
    `@media (max-width: 1023px)` block. CSS-gated visibility,
    not React state — the banner mounts always but only renders
    visibly on narrow viewports.
  • web/src/main.tsx: mounts the banner inside ErrorBoundary,
    above QueryClientProvider, so it survives any provider
    failure that breaks the rest of the tree.
  • Operator-stated rationale (recorded in DesktopOnlyBanner.tsx
    header comment): the audit flagged 29 partial sm:/md:/lg:
    responsive classes that suggest mobile support which isn't
    actually shipped. Rather than rip out the partials (zero
    benefit at desktop widths) or ship full mobile (1+ sprint of
    QA + ongoing maintenance), this ships an honest signal —
    "we don't promise mobile" — that doesn't claim support that
    isn't there. The partials stay (no benefit to ripping out;
    they may help if the decision reverses).

Deferred:

P-H2 — AuditPage server-side time filters
  Requires backend changes to internal/api/handler/audit.go +
  service + repository: ListAuditEvents currently accepts only
  page/per_page/category. Adds `since` / `until` ISO-8601
  params (UTC), pushes the timestamp predicate into the SQL
  query, surfaces them in OpenAPI + MCP. Queued as a backend-
  first follow-up bundle.

P-M1 — DiscoveryPage in-flight scan panel
  Out of scope for the frontend remediation pass; needs a
  websocket / SSE channel from internal/service/discovery.go to
  the frontend (current poll-and-render UI works against the
  existing endpoint set). Queued.

Verification:
  • npx tsc --noEmit — exits 0
  • npx vitest run ErrorBoundary StatusBadge — 80/80 passed
  • npm run build — ✓ built in 3.11s
  • bash scripts/ci-guards/no-raw-table.sh —
      Raw <table> tags outside DataTable + Skeleton — current: 17, baseline: 17
  • Bundle shapes unchanged from Phase 4 (91.66 KB raw / 25.92 KB gz
    initial chunk); the ErrorBoundary rewrite adds ~5 KB to index.

Falsifiable proof for the next CI run:
  • Frontend Build job's `npm ci` step completes (Hotfix #9 settled
    the Storybook peer conflict).
  • New no-raw-table.sh guard exits 0 with current=17 baseline=17.
  • All 34 CI guards (was 33, +1 for no-raw-table) pass.

Per-finding closure entries land in frontend-design-audit.html in
the follow-up commit (audit HTML update).
2026-05-14 18:27:18 +00:00

234 lines
15 KiB
TypeScript

// Phase 0 hygiene (FE-H4 / PERF-H3): self-hosted fonts. Replaces the
// Google Fonts @import that used to live at the top of src/index.css —
// Vite hashes + bundles these CSS files into web/dist on build, so cold
// loads no longer touch fonts.googleapis.com / fonts.gstatic.com.
import '@fontsource-variable/inter';
import '@fontsource/jetbrains-mono/400.css';
import '@fontsource/jetbrains-mono/500.css';
import '@fontsource/jetbrains-mono/600.css';
import { StrictMode, Suspense, lazy } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import ErrorBoundary from './components/ErrorBoundary';
import AuthProvider from './components/AuthProvider';
import AuthGate from './components/AuthGate';
import Layout from './components/Layout';
// Phase 4 closure (FE-M5 + SCALE-H1): per-route code splitting.
// Pre-Phase-4 every page import above was eager — every page's React
// tree + its api/client + its query-key constants + its chart panels
// landed in the same first-load index-*.js (~1.07 MB raw / ~281 KB gz).
//
// Post-Phase-4 the dashboard stays eager (it's the landing route for
// every cold load) and every other page becomes a React.lazy() boundary
// so its chunk only ships when an operator navigates to that route.
// Each route is wrapped in a <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 Skeleton from './components/Skeleton';
// Inventory.
const CertificatesPage = lazy(() => import('./pages/CertificatesPage'));
const CertificateDetailPage = lazy(() => import('./pages/CertificateDetailPage'));
const IssuersPage = lazy(() => import('./pages/IssuersPage'));
const IssuerDetailPage = lazy(() => import('./pages/IssuerDetailPage'));
const IssuerHierarchyPage = lazy(() => import('./pages/IssuerHierarchyPage'));
const TargetsPage = lazy(() => import('./pages/TargetsPage'));
const TargetDetailPage = lazy(() => import('./pages/TargetDetailPage'));
const ProfilesPage = lazy(() => import('./pages/ProfilesPage'));
// Delivery & jobs.
const JobsPage = lazy(() => import('./pages/JobsPage'));
const JobDetailPage = lazy(() => import('./pages/JobDetailPage'));
const AgentsPage = lazy(() => import('./pages/AgentsPage'));
const AgentDetailPage = lazy(() => import('./pages/AgentDetailPage'));
const AgentFleetPage = lazy(() => import('./pages/AgentFleetPage'));
const AgentGroupsPage = lazy(() => import('./pages/AgentGroupsPage'));
// Policy & notify.
const PoliciesPage = lazy(() => import('./pages/PoliciesPage'));
const RenewalPoliciesPage = lazy(() => import('./pages/RenewalPoliciesPage'));
const NotificationsPage = lazy(() => import('./pages/NotificationsPage'));
const DigestPage = lazy(() => import('./pages/DigestPage'));
// People.
const OwnersPage = lazy(() => import('./pages/OwnersPage'));
const TeamsPage = lazy(() => import('./pages/TeamsPage'));
// Audit & ops.
const AuditPage = lazy(() => import('./pages/AuditPage'));
const ShortLivedPage = lazy(() => import('./pages/ShortLivedPage'));
const DiscoveryPage = lazy(() => import('./pages/DiscoveryPage'));
const NetworkScanPage = lazy(() => import('./pages/NetworkScanPage'));
const HealthMonitorPage = lazy(() => import('./pages/HealthMonitorPage'));
const ObservabilityPage = lazy(() => import('./pages/ObservabilityPage'));
// Protocol admin.
const SCEPAdminPage = lazy(() => import('./pages/SCEPAdminPage'));
const ESTAdminPage = lazy(() => import('./pages/ESTAdminPage'));
// Access (Bundle 1 Phase 10 — RBAC management).
const RolesPage = lazy(() => import('./pages/auth/RolesPage'));
const RoleDetailPage = lazy(() => import('./pages/auth/RoleDetailPage'));
const KeysPage = lazy(() => import('./pages/auth/KeysPage'));
const AuthSettingsPage = lazy(() => import('./pages/auth/AuthSettingsPage'));
const ApprovalsPage = lazy(() => import('./pages/auth/ApprovalsPage'));
// Access (Bundle 2 Phase 8 — OIDC + session management).
const OIDCProvidersPage = lazy(() => import('./pages/auth/OIDCProvidersPage'));
const OIDCProviderDetailPage = lazy(() => import('./pages/auth/OIDCProviderDetailPage'));
const GroupMappingsPage = lazy(() => import('./pages/auth/GroupMappingsPage'));
const SessionsPage = lazy(() => import('./pages/auth/SessionsPage'));
const BreakglassPage = lazy(() => import('./pages/auth/BreakglassPage'));
// Audit 2026-05-10 MED-11 closure — federated-user admin.
const UsersPage = lazy(() => import('./pages/auth/UsersPage'));
// Phase 1 closure (UX-H3): toast / snackbar system. Mounted once near
// the root so any component can `import { toast } from "sonner"` and
// call toast.success / toast.error without provider plumbing.
import Toaster from './components/Toaster';
// Phase 3 closure (UX-H6 + FE-L4): cmd+k command palette mounted at
// the root. The hook + listener live in CommandPaletteHost so the
// keydown binding stays scoped to the React tree (auto-cleanup on
// HMR + StrictMode).
import CommandPaletteHost from './components/CommandPaletteHost';
// Phase 9 closure (FE-M2 operator-decision: desktop-only stance).
// Renders a top-of-viewport notice when viewport < 1024px; gated
// by CSS media query in src/index.css, dismissable + persisted.
import DesktopOnlyBanner from './components/DesktopOnlyBanner';
import { STALE_TIME, GC_TIME } from './api/queryConstants';
import './index.css';
// Phase 2 closure (TQ-H2 + TQ-M1): QueryClient defaults rewritten.
// Pre-Phase-2: staleTime 10s + refetchOnWindowFocus true caused a
// refetch storm on every tab refocus across 242 query sites and a
// 10s "freshness" window meaning every cross-page navigation
// triggered backend hits.
//
// Post-Phase-2: 5min REFERENCE staleTime is the dominant-case sane
// default; queries that legitimately need live data (jobs, in-flight
// scans, agent heartbeats — the live-tile cohort) opt in PER-QUERY to
// staleTime: STALE_TIME.REAL_TIME + refetchOnWindowFocus: true. gcTime
// is now explicit at STANDARD (5min) so the contract is documented at
// the root rather than implicit-defaulted by TanStack.
//
// retry: 1 stays — lowering to 0 surfaces network blips; raising to
// the TanStack default of 3 hammers the backend on transient 503s.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: STALE_TIME.REFERENCE, // 5 min — see api/queryConstants.ts
gcTime: GC_TIME.STANDARD, // 5 min — explicit; was TanStack-default
retry: 1,
refetchOnWindowFocus: false, // per-query opt-in for live-tile queries
},
},
});
// 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(
<StrictMode>
<ErrorBoundary>
<DesktopOnlyBanner />
<QueryClientProvider client={queryClient}>
<Toaster />
<AuthProvider>
<AuthGate>
<BrowserRouter>
<CommandPaletteHost />
<Routes>
<Route element={<Layout />}>
{/* Dashboard stays eager — landing route for every cold load. */}
<Route index element={<DashboardPage />} />
<Route path="certificates" element={lazyRoute(<CertificatesPage />)} />
<Route path="certificates/:id" element={lazyRoute(<CertificateDetailPage />)} />
<Route path="agents" element={lazyRoute(<AgentsPage />)} />
<Route path="agents/:id" element={lazyRoute(<AgentDetailPage />)} />
<Route path="fleet" element={lazyRoute(<AgentFleetPage />)} />
<Route path="jobs" element={lazyRoute(<JobsPage />)} />
<Route path="jobs/:id" element={lazyRoute(<JobDetailPage />)} />
<Route path="notifications" element={lazyRoute(<NotificationsPage />)} />
<Route path="policies" element={lazyRoute(<PoliciesPage />)} />
<Route path="renewal-policies" element={lazyRoute(<RenewalPoliciesPage />)} />
<Route path="profiles" element={lazyRoute(<ProfilesPage />)} />
<Route path="issuers" element={lazyRoute(<IssuersPage />)} />
<Route path="issuers/:id" element={lazyRoute(<IssuerDetailPage />)} />
{/* Rank 8 — operator-managed multi-level CA hierarchy.
Admin-gated at the API; the page renders the
backend's 403 as ErrorState for non-admin
callers. See docs/intermediate-ca-hierarchy.md. */}
<Route path="issuers/:id/hierarchy" element={lazyRoute(<IssuerHierarchyPage />)} />
<Route path="targets" element={lazyRoute(<TargetsPage />)} />
<Route path="targets/:id" element={lazyRoute(<TargetDetailPage />)} />
<Route path="owners" element={lazyRoute(<OwnersPage />)} />
<Route path="teams" element={lazyRoute(<TeamsPage />)} />
<Route path="agent-groups" element={lazyRoute(<AgentGroupsPage />)} />
<Route path="audit" element={lazyRoute(<AuditPage />)} />
<Route path="short-lived" element={lazyRoute(<ShortLivedPage />)} />
<Route path="discovery" element={lazyRoute(<DiscoveryPage />)} />
<Route path="network-scans" element={lazyRoute(<NetworkScanPage />)} />
<Route path="health-monitor" element={lazyRoute(<HealthMonitorPage />)} />
<Route path="digest" element={lazyRoute(<DigestPage />)} />
<Route path="observability" element={lazyRoute(<ObservabilityPage />)} />
{/* SCEP RFC 8894 + Intune master bundle Phase 9.4 (initial)
+ Phase 9 follow-up (rebrand): per-profile SCEP
Administration page with Profiles / Intune Monitoring /
Recent Activity tabs. Route is unconditional; the page
itself renders an "Admin access required" banner for
non-admin callers and skips the underlying API calls so
the server never sees a 403-prone request. */}
<Route path="scep" element={lazyRoute(<SCEPAdminPage />)} />
{/* Backward-compat alias for external bookmarks the Phase 9
release advertised. Lands on the Intune Monitoring tab. */}
<Route path="scep/intune" element={lazyRoute(<SCEPAdminPage />)} />
{/* EST RFC 7030 hardening master bundle Phase 8: per-profile
EST Administration page with Profiles / Recent Activity /
Trust Bundle tabs. Same admin-gate pattern as SCEP — the
route is unconditional; the page renders an "Admin access
required" banner for non-admin callers and skips the
underlying API calls so the server never sees a 403. */}
<Route path="est" element={lazyRoute(<ESTAdminPage />)} />
{/* Bundle 1 Phase 10 — RBAC management surface.
Every page reads /api/v1/auth/me on mount via the
useAuthMe hook and gates affordances against the
cached effective_permissions slice. Server-side
enforcement is the load-bearing layer; client-side
hide/disable is UX. */}
{/* Bundle 2 Phase 8 — OIDC + session management surface. */}
<Route path="auth/oidc/providers" element={lazyRoute(<OIDCProvidersPage />)} />
<Route path="auth/oidc/providers/:id" element={lazyRoute(<OIDCProviderDetailPage />)} />
<Route path="auth/oidc/providers/:id/mappings" element={lazyRoute(<GroupMappingsPage />)} />
<Route path="auth/sessions" element={lazyRoute(<SessionsPage />)} />
<Route path="auth/roles" element={lazyRoute(<RolesPage />)} />
<Route path="auth/roles/:id" element={lazyRoute(<RoleDetailPage />)} />
<Route path="auth/keys" element={lazyRoute(<KeysPage />)} />
<Route path="auth/settings" element={lazyRoute(<AuthSettingsPage />)} />
<Route path="auth/approvals" element={lazyRoute(<ApprovalsPage />)} />
{/* Audit 2026-05-10 CRIT-4 closure — break-glass admin surface. */}
<Route path="auth/breakglass" element={lazyRoute(<BreakglassPage />)} />
{/* Audit 2026-05-10 MED-11 closure — federated-user admin. */}
<Route path="auth/users" element={lazyRoute(<UsersPage />)} />
</Route>
</Routes>
</BrowserRouter>
</AuthGate>
</AuthProvider>
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>
);