mirror of
https://github.com/certctl-io/certctl.git
synced 2026-08-26 21:11:24 +02:00
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).
229 lines
8.4 KiB
TypeScript
229 lines
8.4 KiB
TypeScript
// Copyright 2026 certctl LLC. All rights reserved.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
//
|
|
// ErrorBoundary — Phase 9 closure for FE-L1 (50-line stub with no copy-
|
|
// stack-trace affordance, no telemetry hook). Pre-Phase-9 a production
|
|
// exception left operators staring at a one-line "Something went wrong"
|
|
// with no way to capture the stack for a bug report.
|
|
//
|
|
// Phase 9 expansion adds:
|
|
// • Full stack trace + component-stack rendered in a <details> block
|
|
// (collapsed by default so the visual posture stays calm; expert
|
|
// operators expand for triage).
|
|
// • "Copy details" button that copies a structured JSON payload to
|
|
// the clipboard for paste into a bug report or Slack thread.
|
|
// Payload: { message, stack, componentStack, userAgent, url,
|
|
// buildVersion, timestamp }.
|
|
// • Optional telemetry POST gated on the VITE_ERROR_TELEMETRY_URL
|
|
// build-time env var. When set, the boundary fires a single POST
|
|
// with the same payload to the configured endpoint. No-op when
|
|
// unset (no Sentry-class endpoint is part of certctl-server v2;
|
|
// this hook is forward-compat for when one lands).
|
|
//
|
|
// Pairs with Phase 9's PERF-M2 closure: vite.config.ts now emits
|
|
// `sourcemap: 'hidden'` so a future Sentry release-artifact upload
|
|
// can symbolicate these stack traces against the unminified source.
|
|
|
|
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
|
|
interface Props {
|
|
children: ReactNode;
|
|
}
|
|
|
|
interface State {
|
|
hasError: boolean;
|
|
error: Error | null;
|
|
errorInfo: ErrorInfo | null;
|
|
copyStatus: 'idle' | 'copied' | 'failed';
|
|
}
|
|
|
|
interface ErrorPayload {
|
|
message: string;
|
|
stack: string;
|
|
componentStack: string;
|
|
userAgent: string;
|
|
url: string;
|
|
buildVersion: string;
|
|
timestamp: string;
|
|
}
|
|
|
|
/**
|
|
* Buildversion is injected by Vite at build time via define() —
|
|
* falling back to 'dev' if missing means local dev doesn't fail to
|
|
* compile.
|
|
*/
|
|
const BUILD_VERSION = (
|
|
typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
|
|
);
|
|
|
|
declare const __APP_VERSION__: string;
|
|
|
|
/**
|
|
* Optional Sentry-class endpoint. When set, the boundary POSTs the
|
|
* error payload as JSON. Empty / unset = no telemetry (the safe
|
|
* default; v2 certctl-server doesn't expose a /telemetry/errors
|
|
* endpoint).
|
|
*/
|
|
const TELEMETRY_URL = (
|
|
// Vite exposes build-time env vars on import.meta.env (typed as
|
|
// `unknown` in TS until vite/client types load). Cast through unknown
|
|
// so the unset-undefined path stays sound.
|
|
(import.meta.env as Record<string, string | undefined>)
|
|
.VITE_ERROR_TELEMETRY_URL || ''
|
|
);
|
|
|
|
function buildPayload(error: Error, errorInfo: ErrorInfo | null): ErrorPayload {
|
|
return {
|
|
message: error.message || 'Unknown error',
|
|
stack: error.stack || '(no stack)',
|
|
componentStack: errorInfo?.componentStack || '(no component stack)',
|
|
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown',
|
|
url: typeof window !== 'undefined' ? window.location.href : 'unknown',
|
|
buildVersion: BUILD_VERSION,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
async function copyToClipboard(text: string): Promise<boolean> {
|
|
// Prefer navigator.clipboard (modern + async). Falls back to the
|
|
// execCommand path only if clipboard isn't available (e.g. old
|
|
// browsers, file://, http:// in some browsers). Returns true on
|
|
// success.
|
|
try {
|
|
if (navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(text);
|
|
return true;
|
|
}
|
|
} catch { /* fall through */ }
|
|
// Legacy fallback — works in jsdom for tests + on http origins.
|
|
try {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.position = 'fixed';
|
|
ta.style.opacity = '0';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
const ok = document.execCommand?.('copy') ?? false;
|
|
document.body.removeChild(ta);
|
|
return ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function postTelemetry(payload: ErrorPayload): void {
|
|
if (!TELEMETRY_URL) return;
|
|
// Best-effort fire-and-forget. We deliberately don't await — a slow
|
|
// telemetry endpoint MUST NOT block the user's "click Reload" path.
|
|
// navigator.sendBeacon is the right primitive for this case (queued
|
|
// by the browser, survives navigation) but it requires a Blob; fall
|
|
// back to fetch() with keepalive: true otherwise.
|
|
try {
|
|
const body = JSON.stringify(payload);
|
|
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
|
|
navigator.sendBeacon(TELEMETRY_URL, new Blob([body], { type: 'application/json' }));
|
|
return;
|
|
}
|
|
fetch(TELEMETRY_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body,
|
|
keepalive: true,
|
|
}).catch(() => { /* swallow; telemetry must never raise */ });
|
|
} catch { /* swallow */ }
|
|
}
|
|
|
|
export default class ErrorBoundary extends Component<Props, State> {
|
|
constructor(props: Props) {
|
|
super(props);
|
|
this.state = { hasError: false, error: null, errorInfo: null, copyStatus: 'idle' };
|
|
}
|
|
|
|
static getDerivedStateFromError(error: Error): Partial<State> {
|
|
return { hasError: true, error };
|
|
}
|
|
|
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
console.error('Uncaught component error:', error, errorInfo);
|
|
this.setState({ errorInfo });
|
|
postTelemetry(buildPayload(error, errorInfo));
|
|
}
|
|
|
|
handleCopy = async () => {
|
|
if (!this.state.error) return;
|
|
const payload = buildPayload(this.state.error, this.state.errorInfo);
|
|
const ok = await copyToClipboard(JSON.stringify(payload, null, 2));
|
|
this.setState({ copyStatus: ok ? 'copied' : 'failed' });
|
|
// Reset to idle after 2s so the operator can copy again if needed.
|
|
setTimeout(() => this.setState({ copyStatus: 'idle' }), 2_000);
|
|
};
|
|
|
|
handleReload = () => {
|
|
this.setState({ hasError: false, error: null, errorInfo: null, copyStatus: 'idle' });
|
|
window.location.reload();
|
|
};
|
|
|
|
render() {
|
|
if (!this.state.hasError || !this.state.error) {
|
|
return this.props.children;
|
|
}
|
|
const payload = buildPayload(this.state.error, this.state.errorInfo);
|
|
const copyLabel =
|
|
this.state.copyStatus === 'copied' ? 'Copied!' :
|
|
this.state.copyStatus === 'failed' ? 'Copy failed' :
|
|
'Copy details';
|
|
|
|
return (
|
|
<div className="flex items-center justify-center min-h-screen bg-page">
|
|
<div className="max-w-2xl w-full p-8" role="alert" aria-live="assertive">
|
|
<h1 className="text-xl font-semibold text-red-700 mb-2">Something went wrong</h1>
|
|
<p className="text-sm text-ink-muted mb-4">
|
|
{this.state.error.message || 'An unexpected error occurred'}
|
|
</p>
|
|
|
|
<div className="flex gap-2 mb-4">
|
|
<button
|
|
type="button"
|
|
onClick={this.handleReload}
|
|
className="px-4 py-2 bg-brand-500 text-white rounded text-sm hover:bg-brand-600"
|
|
data-testid="error-boundary-reload"
|
|
>
|
|
Reload Page
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={this.handleCopy}
|
|
className="px-4 py-2 bg-surface border border-surface-border text-ink rounded text-sm hover:bg-surface-muted"
|
|
data-testid="error-boundary-copy"
|
|
aria-live="polite"
|
|
>
|
|
{copyLabel}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Stack trace collapsed by default. Expert operators expand
|
|
for triage; copy-button surfaces the same payload as JSON
|
|
for paste into bug reports. */}
|
|
<details className="bg-surface border border-surface-border rounded p-3 text-xs font-mono text-ink-muted">
|
|
<summary className="cursor-pointer text-ink select-none">Error details</summary>
|
|
<div className="mt-3 space-y-3">
|
|
<div>
|
|
<div className="text-ink-faint uppercase tracking-wide mb-1">Build</div>
|
|
<div>{payload.buildVersion} · {payload.timestamp}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-ink-faint uppercase tracking-wide mb-1">Stack</div>
|
|
<pre className="whitespace-pre-wrap break-words text-2xs">{payload.stack}</pre>
|
|
</div>
|
|
<div>
|
|
<div className="text-ink-faint uppercase tracking-wide mb-1">Component stack</div>
|
|
<pre className="whitespace-pre-wrap break-words text-2xs">{payload.componentStack}</pre>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
}
|