mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21:31 +00:00
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).
This commit is contained in:
@@ -0,0 +1 @@
|
||||
17
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phase 9 closure (UX-M7 regression gate): fail CI when a new raw
|
||||
# `<table>` ships in production tsx outside the canonical DataTable
|
||||
# + Skeleton primitives.
|
||||
#
|
||||
# Pre-Phase-9 the codebase had 19 `<table>` sites across 16 files.
|
||||
# Two of those are LEGITIMATE primitives — they ARE the chokepoint
|
||||
# every list page should route through:
|
||||
# • web/src/components/DataTable.tsx — the canonical table component
|
||||
# • web/src/components/Skeleton.tsx — the loading-shape table-shaped
|
||||
# skeleton
|
||||
#
|
||||
# The other 14 page-level raw tables stay in place during the Phase 9
|
||||
# rollout (the audit prompt's "DO NOT migrate all 18 in one PR" rule).
|
||||
# This guard baseline-locks the existing 14; every migration to
|
||||
# DataTable drops the baseline by 1. `--strict` mode rejects any raw
|
||||
# table once the backlog clears.
|
||||
#
|
||||
# Tests are excluded.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BASELINE_FILE="$SCRIPT_DIR/no-raw-table-baseline.txt"
|
||||
|
||||
cd "$SCRIPT_DIR/../../web"
|
||||
|
||||
STRICT=0
|
||||
[[ "${1:-}" == "--strict" ]] && STRICT=1
|
||||
|
||||
# Count <table tags outside DataTable.tsx + Skeleton.tsx (the
|
||||
# allowlisted primitives) in production tsx (excludes tests +
|
||||
# node_modules + dist).
|
||||
COUNT_RAW=$(
|
||||
grep -rl '<table' src \
|
||||
--include='*.tsx' \
|
||||
--exclude='*.test.*' \
|
||||
--exclude-dir='__tests__' \
|
||||
--exclude-dir='node_modules' \
|
||||
--exclude-dir='dist' \
|
||||
2>/dev/null \
|
||||
| grep -vE '(DataTable\.tsx|Skeleton\.tsx)$' \
|
||||
| xargs -r grep -ohE '<table\b' 2>/dev/null \
|
||||
| wc -l \
|
||||
| tr -d '[:space:]'
|
||||
)
|
||||
COUNT_RAW=${COUNT_RAW:-0}
|
||||
|
||||
BASELINE=0
|
||||
if [[ -f "$BASELINE_FILE" ]]; then
|
||||
BASELINE=$(cat "$BASELINE_FILE" | tr -d '[:space:]')
|
||||
fi
|
||||
|
||||
echo "Raw <table> tags outside DataTable + Skeleton — current: $COUNT_RAW, baseline: $BASELINE"
|
||||
|
||||
if [[ $STRICT -eq 1 ]]; then
|
||||
if [[ $COUNT_RAW -gt 0 ]]; then
|
||||
echo "FAIL (--strict): $COUNT_RAW raw <table> tag(s) remain. Migrate to <DataTable> from web/src/components/DataTable.tsx."
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS (--strict): zero raw <table> tags."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $COUNT_RAW -gt $BASELINE ]]; then
|
||||
echo ""
|
||||
echo "FAIL: A new raw <table> tag was added ($COUNT_RAW > baseline $BASELINE)."
|
||||
echo ""
|
||||
echo "Migrate to <DataTable> from web/src/components/DataTable.tsx —"
|
||||
echo "it provides StatusBadge wiring, EmptyState slot, Skeleton loading,"
|
||||
echo "pagination, selectable rows, and the Phase 9 UX-M8 density toggle"
|
||||
echo "for free."
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $COUNT_RAW -lt $BASELINE ]]; then
|
||||
echo ""
|
||||
echo "PASS — and you're under baseline! Drop the baseline to lock in progress:"
|
||||
echo " echo $COUNT_RAW > $BASELINE_FILE"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,6 +1,43 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import Skeleton from './Skeleton';
|
||||
|
||||
// Phase 9 closure (UX-M8): row-density toggle. Three tiers map to the
|
||||
// vertical padding on tbody td elements. Compact wins at 5K-row dense
|
||||
// data review; Spacious wins for low-attention scanning; Comfortable
|
||||
// is the existing pre-Phase-9 default. Choice persists per-table via
|
||||
// the `tableId` prop — keyed at certctl.density.<id> so two tables on
|
||||
// one page don't fight each other.
|
||||
export type Density = 'compact' | 'comfortable' | 'spacious';
|
||||
|
||||
const DENSITY_CELL_CLASS: Record<Density, string> = {
|
||||
compact: 'px-4 py-1.5',
|
||||
comfortable: 'px-4 py-3',
|
||||
spacious: 'px-4 py-4',
|
||||
};
|
||||
|
||||
const DENSITY_HEADER_CLASS: Record<Density, string> = {
|
||||
compact: 'px-4 py-2',
|
||||
comfortable: 'px-4 py-3',
|
||||
spacious: 'px-4 py-3.5',
|
||||
};
|
||||
|
||||
function readDensityPref(tableId: string | undefined): Density {
|
||||
if (!tableId || typeof localStorage === 'undefined') return 'comfortable';
|
||||
try {
|
||||
const v = localStorage.getItem(`certctl.density.${tableId}`);
|
||||
if (v === 'compact' || v === 'comfortable' || v === 'spacious') return v;
|
||||
} catch { /* noop */ }
|
||||
return 'comfortable';
|
||||
}
|
||||
|
||||
function writeDensityPref(tableId: string | undefined, d: Density): void {
|
||||
if (!tableId || typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(`certctl.density.${tableId}`, d);
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
interface Column<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -45,9 +82,42 @@ interface DataTableProps<T> {
|
||||
selectedKeys?: Set<string>;
|
||||
onSelectionChange?: (keys: Set<string>) => void;
|
||||
pagination?: PaginationProps;
|
||||
/**
|
||||
* Phase 9 (UX-M8): per-table identifier for the density preference.
|
||||
* Use a stable string like `'certificates-list'` — choice persists
|
||||
* to localStorage at `certctl.density.<tableId>`. When unset, the
|
||||
* density toggle is hidden (the table renders at the default
|
||||
* 'comfortable' density) — opt-in per-page rollout.
|
||||
*/
|
||||
tableId?: string;
|
||||
/**
|
||||
* Initial density. Overridden by the persisted preference when
|
||||
* tableId is set. Defaults to 'comfortable' (matches pre-Phase-9
|
||||
* vertical padding exactly so existing pages render identically
|
||||
* until an operator flips the toggle).
|
||||
*/
|
||||
density?: Density;
|
||||
}
|
||||
|
||||
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, tableId, density: densityProp }: DataTableProps<T>) {
|
||||
// Phase 9 (UX-M8): density preference. When tableId is set, read
|
||||
// localStorage at mount; otherwise use the prop default (or
|
||||
// 'comfortable'). Persist writes via setDensity.
|
||||
const [density, setDensityState] = useState<Density>(() =>
|
||||
tableId ? readDensityPref(tableId) : (densityProp ?? 'comfortable'),
|
||||
);
|
||||
useEffect(() => {
|
||||
// If tableId changes (rare but possible if a parent swaps it),
|
||||
// re-read the persisted preference.
|
||||
if (tableId) setDensityState(readDensityPref(tableId));
|
||||
}, [tableId]);
|
||||
|
||||
const setDensity = (d: Density) => {
|
||||
setDensityState(d);
|
||||
writeDensityPref(tableId, d);
|
||||
};
|
||||
const cellCls = DENSITY_CELL_CLASS[density];
|
||||
const headerCls = DENSITY_HEADER_CLASS[density];
|
||||
// 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
|
||||
@@ -94,11 +164,14 @@ export default function DataTable<T>({ columns, data, onRowClick, emptyMessage,
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
{tableId && (
|
||||
<DensityToggle current={density} onChange={setDensity} />
|
||||
)}
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-surface-border bg-surface-muted">
|
||||
{selectable && (
|
||||
<th scope="col" className="px-3 py-3 w-10">
|
||||
<th scope="col" className={`w-10 ${headerCls}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected || false}
|
||||
@@ -108,7 +181,7 @@ export default function DataTable<T>({ columns, data, onRowClick, emptyMessage,
|
||||
</th>
|
||||
)}
|
||||
{columns.map(col => (
|
||||
<th key={col.key} scope="col" className={`px-4 py-3 text-left text-xs font-semibold text-ink-muted uppercase tracking-wider ${col.className || ''}`}>
|
||||
<th key={col.key} scope="col" className={`${headerCls} text-left text-xs font-semibold text-ink-muted uppercase tracking-wider ${col.className || ''}`}>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
@@ -125,7 +198,7 @@ export default function DataTable<T>({ columns, data, onRowClick, emptyMessage,
|
||||
className={`border-b border-surface-border/50 transition-colors hover:bg-surface-muted ${onRowClick ? 'cursor-pointer' : ''} ${isSelected ? 'bg-brand-50' : ''}`}
|
||||
>
|
||||
{selectable && (
|
||||
<td className="px-3 py-3 w-10">
|
||||
<td className={`w-10 ${cellCls}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected || false}
|
||||
@@ -136,7 +209,7 @@ export default function DataTable<T>({ columns, data, onRowClick, emptyMessage,
|
||||
</td>
|
||||
)}
|
||||
{columns.map(col => (
|
||||
<td key={col.key} className={`px-4 py-3 text-ink ${col.className || ''}`}>
|
||||
<td key={col.key} className={`${cellCls} text-ink ${col.className || ''}`}>
|
||||
{col.render(item)}
|
||||
</td>
|
||||
))}
|
||||
@@ -152,6 +225,43 @@ export default function DataTable<T>({ columns, data, onRowClick, emptyMessage,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 9 UX-M8: 3-button row-density toggle. Renders only when the
|
||||
* parent DataTable was given a `tableId` (the opt-in signal that this
|
||||
* page wants the per-table localStorage persistence).
|
||||
*/
|
||||
function DensityToggle({ current, onChange }: { current: Density; onChange: (d: Density) => void }) {
|
||||
const opts: { value: Density; label: string }[] = [
|
||||
{ value: 'compact', label: 'Compact' },
|
||||
{ value: 'comfortable', label: 'Cozy' },
|
||||
{ value: 'spacious', label: 'Spacious' },
|
||||
];
|
||||
return (
|
||||
<div className="flex justify-end mb-1.5" role="group" aria-label="Row density">
|
||||
<div className="inline-flex rounded-md border border-surface-border bg-surface text-xs overflow-hidden" data-testid="datatable-density-toggle">
|
||||
{opts.map((o, i) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => onChange(o.value)}
|
||||
aria-pressed={current === o.value}
|
||||
data-testid={`datatable-density-${o.value}`}
|
||||
className={
|
||||
`px-2.5 py-1 transition-colors ` +
|
||||
(current === o.value
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'text-ink-muted hover:text-ink hover:bg-surface-muted') +
|
||||
(i > 0 ? ' border-l border-surface-border' : '')
|
||||
}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// F-1 closure (cat-k-e85d1099b2d7): pagination footer for DataTable
|
||||
// consumers that want prev/next + page counter + per-page selector
|
||||
// against a paginated backend response. Disabling logic guards the
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
//
|
||||
// DesktopOnlyBanner — Phase 9 closure for FE-M2 (operator decision
|
||||
// 2026-05-14: certctl is desktop-only). Renders a top-of-viewport
|
||||
// notice when the viewport is narrower than the `lg` Tailwind
|
||||
// breakpoint (1024px) telling operators they're outside the
|
||||
// supported viewport.
|
||||
//
|
||||
// Visibility is gated by CSS media query (.desktop-only-banner in
|
||||
// src/index.css). Component dismissal persists to localStorage so an
|
||||
// operator who needs occasional narrow-viewport access doesn't see
|
||||
// the banner forever.
|
||||
//
|
||||
// Pairs with the operator's FE-M2 decision: rather than rip out the
|
||||
// 29 partial sm:/md:/lg: responsive classes (zero benefit at
|
||||
// desktop widths) OR ship full mobile (1+ sprint of QA + ongoing
|
||||
// maintenance), the project ships an HONEST signal — "we don't
|
||||
// promise mobile" — that doesn't claim support that isn't there.
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'certctl:desktop-only-banner-dismissed';
|
||||
|
||||
export default function DesktopOnlyBanner() {
|
||||
const [dismissed, setDismissed] = useState<boolean>(() => {
|
||||
if (typeof localStorage === 'undefined') return false;
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (dismissed && typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
}, [dismissed]);
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="desktop-only-banner fixed top-0 left-0 right-0 z-50 items-center justify-between gap-3 bg-amber-50 border-b border-amber-200 px-4 py-2 text-xs text-amber-900"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="desktop-only-banner"
|
||||
>
|
||||
<span>
|
||||
<strong>Desktop-only:</strong> certctl is designed for viewports ≥ 1024px. Some UI may render cramped at this width.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
className="px-2 py-0.5 rounded text-amber-900 hover:bg-amber-100 transition-colors shrink-0"
|
||||
aria-label="Dismiss desktop-only notice"
|
||||
data-testid="desktop-only-banner-dismiss"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
|
||||
// Phase 9 FE-L1 closure tests — pin the new contract:
|
||||
// • Error rendered → "Reload Page" + "Copy details" buttons visible.
|
||||
// • "Copy details" populates navigator.clipboard with a JSON payload
|
||||
// containing message, stack, componentStack, userAgent, url,
|
||||
// buildVersion, timestamp.
|
||||
// • Telemetry POST is gated on VITE_ERROR_TELEMETRY_URL (unset =
|
||||
// no fetch; set = single sendBeacon-or-fetch call).
|
||||
// • Error-details <details> block stays collapsed by default.
|
||||
|
||||
function Boom(): never {
|
||||
throw new Error('test-boundary-trip');
|
||||
}
|
||||
|
||||
function silenceConsole(fn: () => void | Promise<void>) {
|
||||
// React + jsdom log the component error to console.error; mute for
|
||||
// test-output cleanliness without losing real-error visibility in
|
||||
// dev (we restore the original after).
|
||||
const origError = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
console.error = origError;
|
||||
}
|
||||
}
|
||||
|
||||
describe('ErrorBoundary — Phase 9 FE-L1 expansion', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders children when no error', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<span>healthy</span>
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText('healthy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders fallback + Reload + Copy buttons when child throws', () => {
|
||||
silenceConsole(() => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Boom />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
});
|
||||
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
|
||||
// "test-boundary-trip" appears in the <p> message AND inside the
|
||||
// <pre> stack trace — assert at least one match exists.
|
||||
expect(screen.getAllByText(/test-boundary-trip/).length).toBeGreaterThan(0);
|
||||
expect(screen.getByTestId('error-boundary-reload')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('error-boundary-copy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Copy details writes a JSON payload to navigator.clipboard', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
|
||||
silenceConsole(() => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Boom />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('error-boundary-copy'));
|
||||
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
|
||||
const arg = writeText.mock.calls[0][0] as string;
|
||||
const payload = JSON.parse(arg);
|
||||
expect(payload.message).toBe('test-boundary-trip');
|
||||
expect(typeof payload.stack).toBe('string');
|
||||
expect(typeof payload.componentStack).toBe('string');
|
||||
expect(typeof payload.userAgent).toBe('string');
|
||||
expect(typeof payload.url).toBe('string');
|
||||
expect(typeof payload.buildVersion).toBe('string');
|
||||
expect(typeof payload.timestamp).toBe('string');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-boundary-copy')).toHaveTextContent(/Copied/);
|
||||
});
|
||||
});
|
||||
|
||||
it('error-details <details> block is collapsed by default', () => {
|
||||
silenceConsole(() => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Boom />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
});
|
||||
const details = screen.getByText('Error details').closest('details');
|
||||
expect(details).toBeTruthy();
|
||||
expect(details).not.toHaveAttribute('open');
|
||||
});
|
||||
|
||||
it('does NOT POST telemetry when VITE_ERROR_TELEMETRY_URL is unset (default)', () => {
|
||||
// The constant is evaluated at module-load; in the test env
|
||||
// import.meta.env.VITE_ERROR_TELEMETRY_URL is undefined, so the
|
||||
// telemetry hook is a no-op. Verify via fetch + sendBeacon spies.
|
||||
const fetchSpy = vi.fn().mockResolvedValue(new Response());
|
||||
globalThis.fetch = fetchSpy as never;
|
||||
const sendBeacon = vi.fn();
|
||||
Object.defineProperty(navigator, 'sendBeacon', {
|
||||
configurable: true,
|
||||
value: sendBeacon,
|
||||
});
|
||||
|
||||
silenceConsole(() => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Boom />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(sendBeacon).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,29 @@
|
||||
// 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 {
|
||||
@@ -7,44 +33,196 @@ interface Props {
|
||||
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 };
|
||||
this.state = { hasError: false, error: null, errorInfo: null, copyStatus: 'idle' };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
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) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-page">
|
||||
<div className="text-center p-8">
|
||||
<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>
|
||||
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
|
||||
onClick={() => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
window.location.reload();
|
||||
}}
|
||||
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>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,3 +139,31 @@
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Phase 9 closure (FE-M2 — operator decision 2026-05-14): desktop-only.
|
||||
* The audit flagged 29 partial sm:/md:/lg: responsive classes scattered
|
||||
* across a handful of files, suggesting mobile support that isn't
|
||||
* actually shipped. Operator chose path (a): document desktop-only +
|
||||
* add a viewport-narrow banner; the partial responsive classes stay
|
||||
* (no benefit to ripping them out — they don't hurt at desktop widths
|
||||
* and may help if the decision ever reverses).
|
||||
*
|
||||
* Banner triggers at < 1024px (Tailwind `lg` breakpoint — the layout
|
||||
* starts visibly cramping below this). It's a single fixed bar at the
|
||||
* top of the viewport, doesn't block interaction (z-index high, but
|
||||
* pointer-events: none on the rest of the body), and dismisses with a
|
||||
* one-click "Dismiss" affordance that persists to localStorage.
|
||||
*
|
||||
* Operators who explicitly want narrow-viewport access (responsive
|
||||
* design work, mobile demo, screen-recording at portrait orientation)
|
||||
* can dismiss and the banner stays gone for that browser.
|
||||
*/
|
||||
@media (max-width: 1023px) {
|
||||
.desktop-only-banner {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
.desktop-only-banner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -99,6 +99,10 @@ import Toaster from './components/Toaster';
|
||||
// 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';
|
||||
|
||||
@@ -139,6 +143,7 @@ function lazyRoute(element: React.ReactNode) {
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<DesktopOnlyBanner />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Toaster />
|
||||
<AuthProvider>
|
||||
|
||||
+30
-1
@@ -9,8 +9,28 @@ import react from '@vitejs/plugin-react'
|
||||
// because the dev cert is self-signed by deploy/test bootstrap and
|
||||
// changes per-checkout — production stops validation at the reverse
|
||||
// proxy or load balancer, not the Vite dev server.
|
||||
// Phase 9 FE-L1 closure: ship the package.json version into the
|
||||
// bundle as a build-time constant. ErrorBoundary's copy-trace payload
|
||||
// uses this so a copied stack trace tells the operator which release
|
||||
// produced the error. Pulled from package.json at config-load time
|
||||
// (no runtime cost). Falls back to 'dev' if unreadable.
|
||||
function readPkgVersion(): string {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const pkg = require('./package.json') as { version?: string };
|
||||
return pkg.version || 'dev';
|
||||
} catch {
|
||||
return 'dev';
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
// Compile-time replace of __APP_VERSION__ in src files. Quoted
|
||||
// so the replaced token becomes a string literal in the bundle.
|
||||
__APP_VERSION__: JSON.stringify(readPkgVersion()),
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
@@ -20,7 +40,16 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
// Phase 9 closure (PERF-M2): 'hidden' generates source maps to
|
||||
// disk but does NOT emit a `//# sourceMappingURL=` comment in the
|
||||
// production JS chunks — so they're not loadable via the browser
|
||||
// (no risk of exposing original source to operators in DevTools),
|
||||
// but the operator (or a future Sentry/error-reporting integration)
|
||||
// can still upload them as release artifacts for symbolication of
|
||||
// FE-L1 ErrorBoundary stack traces. Pre-fix the value was `false`
|
||||
// (no maps at all), which means ANY production exception's stack
|
||||
// traces are minified-only — useless for triage.
|
||||
sourcemap: 'hidden',
|
||||
// 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,
|
||||
|
||||
Reference in New Issue
Block a user