From 443db14d1cc408ff0434a991263bc00b93ab802e Mon Sep 17 00:00:00 2001 From: Aldon Smith Date: Fri, 22 May 2026 10:46:52 -0400 Subject: [PATCH] Tighten Workbench empty and error states --- apps/web/README.md | 4 +- apps/web/app/components/demo-state.tsx | 28 +++++++++-- .../web/app/detections/[detectionId]/page.tsx | 32 ++++++++----- apps/web/app/detections/page.tsx | 3 +- apps/web/app/globals.css | 14 ++++++ apps/web/app/page.tsx | 5 +- apps/web/app/rca-capa-draft/page.tsx | 23 ++++++--- apps/web/app/recommendations/page.tsx | 4 +- .../web/e2e/operations-workbench-demo.spec.ts | 6 +++ apps/web/tests/app-shell.test.mjs | 30 ++++++++++++ docs/LEARNING_LOG.md | 48 +++++++++++++++++++ docs/demo/TROUBLESHOOTING.md | 28 ++++++++++- 12 files changed, 198 insertions(+), 27 deletions(-) diff --git a/apps/web/README.md b/apps/web/README.md index e8fc77f..39de616 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -94,7 +94,9 @@ is running on a non-default port. The shell labels all demo content as simulator-backed data and states that the scenario is synthetic local data, not real plant data. The pages include loading -states, empty states, and simple user-readable API error states. +states, empty states, and simple user-readable API error states. Empty and +missing demo-data states show a short `Next step:` line so a presenter can +recover without treating local setup drift as a product failure. ## Governance Decision Feedback diff --git a/apps/web/app/components/demo-state.tsx b/apps/web/app/components/demo-state.tsx index 598e657..61dbb3c 100644 --- a/apps/web/app/components/demo-state.tsx +++ b/apps/web/app/components/demo-state.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from "react"; + import type { HealthResponse } from "../../lib/api-client"; import { getApiBaseUrl } from "../../lib/api-client"; @@ -16,6 +18,7 @@ type DemoDataBadgeProps = { }; type EmptyStateProps = { + nextStep?: string; title: string; text: string; }; @@ -24,6 +27,12 @@ type LoadingStateProps = { title?: string; }; +type MissingDataPanelProps = { + nextStep: string; + title: string; + text: ReactNode; +}; + type StatusBadgeProps = { label?: string; tone?: @@ -106,20 +115,31 @@ export function DemoDataBadge({ ); } -export function EmptyState({ title, text }: EmptyStateProps) { +export function EmptyState({ nextStep, title, text }: EmptyStateProps) { + return ( +
+ {title} + {text} + {nextStep ? Next step: {nextStep} : null} +
+ ); +} + +export function MissingDataPanel({ nextStep, text, title }: MissingDataPanelProps) { return ( -
+
{title} {text} + Next step: {nextStep}
); } export function LoadingState({ title = "Loading simulator-backed demo data" }: LoadingStateProps) { return ( -
+
-
+
{title} Connecting to the local FastAPI demo backend.
diff --git a/apps/web/app/detections/[detectionId]/page.tsx b/apps/web/app/detections/[detectionId]/page.tsx index 5e49fc9..c8f171d 100644 --- a/apps/web/app/detections/[detectionId]/page.tsx +++ b/apps/web/app/detections/[detectionId]/page.tsx @@ -1,6 +1,10 @@ import Link from "next/link"; -import { ApiErrorPanel, StatusBadge } from "../../components/demo-state"; +import { + ApiErrorPanel, + MissingDataPanel, + StatusBadge, +} from "../../components/demo-state"; import { ApiClientError, type Detection, @@ -35,14 +39,17 @@ export default async function DetectionDetailPage({ params }: DetectionDetailPag

{!result.ok && result.notFound ? ( -
- Detection not found - + The simulator-backed demo API did not return a detection for{" "} {detectionId}. Open the detection list and choose a current demo detection. - -
+ + } + title="Detection not found" + /> ) : null} {!result.ok && !result.notFound ? : null} {result.ok ? ( @@ -198,13 +205,16 @@ function EvidenceTimeline({ evidenceItems }: { evidenceItems: EvidenceItem[] })

{buildTimelineMeaning(evidenceItems)}

{evidenceItems.length === 0 ? ( -
- No evidence available - + This detection exists, but the simulator-backed API did not return evidence items for it yet. - -
+ + } + title="No evidence available" + /> ) : (
    {evidenceItems.map((item) => ( diff --git a/apps/web/app/detections/page.tsx b/apps/web/app/detections/page.tsx index c759179..41c669a 100644 --- a/apps/web/app/detections/page.tsx +++ b/apps/web/app/detections/page.tsx @@ -26,7 +26,8 @@ export default async function DetectionsPage() { {!result.ok ? : null} {result.ok && result.detections.length === 0 ? ( ) : null} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index b905754..f32a0f0 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -556,6 +556,11 @@ h3 { padding: 28px; } +.loading-panel { + min-height: 260px; + align-content: start; +} + .placeholder-list { display: grid; gap: 12px; @@ -868,6 +873,15 @@ h3 { color: #8a2b2b; } +.missing-data-panel { + border-color: #d9c18c; + background: #fffaf0; +} + +.missing-data-panel strong { + color: var(--warning); +} + .decision-note { border-left: 4px solid var(--accent); background: #edf6f1; diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 5d6f785..5bf5ecf 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -151,7 +151,10 @@ export default async function OverviewPage() { ) : ( <>

    No active detections

    -

    Run the demo simulator and Process Sentinel to populate this panel.

    +

    + Run make demo, start the local API, and refresh this + page to populate this panel. +

    )}
diff --git a/apps/web/app/rca-capa-draft/page.tsx b/apps/web/app/rca-capa-draft/page.tsx index 50a5b99..c0111af 100644 --- a/apps/web/app/rca-capa-draft/page.tsx +++ b/apps/web/app/rca-capa-draft/page.tsx @@ -1,6 +1,11 @@ import Link from "next/link"; -import { ApiErrorPanel, EmptyState, StatusBadge } from "../components/demo-state"; +import { + ApiErrorPanel, + EmptyState, + MissingDataPanel, + StatusBadge, +} from "../components/demo-state"; import { ApiClientError, type RcaCapaDraft, @@ -36,19 +41,23 @@ export default async function RcaCapaDraftPage({

{!result.ok && result.notFound ? ( -
- Draft not found - + The simulator-backed API did not return an RCA/CAPA draft for{" "} {result.detectionId}. Open a current demo detection and use its RCA/CAPA draft link. - -
+ + } + title="Draft not found" + /> ) : null} {!result.ok && !result.notFound ? : null} {result.ok && !result.draft ? ( ) : null} diff --git a/apps/web/app/recommendations/page.tsx b/apps/web/app/recommendations/page.tsx index 0563162..243a25e 100644 --- a/apps/web/app/recommendations/page.tsx +++ b/apps/web/app/recommendations/page.tsx @@ -36,12 +36,14 @@ export default async function RecommendationsPage({ {!result.ok ? : null} {result.ok && result.recommendations.length === 0 ? ( ) : null} {result.ok && result.recommendations.length > 0 && !result.selected ? ( diff --git a/apps/web/e2e/operations-workbench-demo.spec.ts b/apps/web/e2e/operations-workbench-demo.spec.ts index f0c65d1..010e49c 100644 --- a/apps/web/e2e/operations-workbench-demo.spec.ts +++ b/apps/web/e2e/operations-workbench-demo.spec.ts @@ -63,6 +63,12 @@ test("walks the simulator-backed Operations Workbench demo path", async ({ page await expect(page.getByRole("heading", { name: "CAPA placeholder" })).toBeVisible(); await expect(page.getByText("Human review", { exact: true })).toBeVisible(); await expect(page.getByText("Required", { exact: true })).toBeVisible(); + + await page.goto("/detections/not-a-current-demo-detection"); + await expect(page.getByRole("heading", { name: "Detection detail" })).toBeVisible(); + await expect(page.getByRole("status")).toContainText("Detection not found"); + await expect(page.getByRole("status")).toContainText("Next step:"); + await expect(page.getByRole("status")).toContainText("rerun make demo"); }); async function recordDecision( diff --git a/apps/web/tests/app-shell.test.mjs b/apps/web/tests/app-shell.test.mjs index b39865b..0989b81 100644 --- a/apps/web/tests/app-shell.test.mjs +++ b/apps/web/tests/app-shell.test.mjs @@ -189,6 +189,36 @@ test("app shell documents configurable API base URL", () => { assert.match(readme, /NEXT_PUBLIC_API_BASE_URL/); }); +test("workbench state panels distinguish local demo data gaps from API failures", () => { + const demoState = readFileSync(join(root, "app/components/demo-state.tsx"), "utf8"); + const overview = readFileSync(join(root, "app/page.tsx"), "utf8"); + const detections = readFileSync(join(root, "app/detections/page.tsx"), "utf8"); + const detail = readFileSync(join(root, "app/detections/[detectionId]/page.tsx"), "utf8"); + const recommendations = readFileSync(join(root, "app/recommendations/page.tsx"), "utf8"); + const draft = readFileSync(join(root, "app/rca-capa-draft/page.tsx"), "utf8"); + const styles = readFileSync(join(root, "app/globals.css"), "utf8"); + + assert.match(demoState, /role="alert"/); + assert.match(demoState, /role="status"/); + assert.match(demoState, /aria-busy="true"/); + assert.match(demoState, /MissingDataPanel/); + assert.match(demoState, /loading-panel/); + assert.match(overview, /Run make demo<\/code>/); + assert.match(detections, /The local API is reachable, but it did not return any Process Sentinel detections/); + assert.match(detections, /Run make demo from the repository root/); + assert.match(detail, /Detection not found/); + assert.match(detail, /No evidence available/); + assert.match(detail, /rerun make demo if local state was reset/); + assert.match(recommendations, /No recommendations returned/); + assert.match(recommendations, /No linked recommendation found/); + assert.match(recommendations, /Run make demo so Process Sentinel can create the demo recommendation/); + assert.match(draft, /Draft not found/); + assert.match(draft, /No detection available for draft preview/); + assert.match(draft, /Run make demo, then open the draft/); + assert.match(styles, /missing-data-panel/); + assert.match(styles, /min-height: 260px/); +}); + test("operations workbench docs link to the demo runbook", async () => { const rootReadme = readFileSync(join(root, "..", "..", "README.md"), "utf8"); const appReadme = readFileSync(join(root, "README.md"), "utf8"); diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index e4c8290..222665b 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -3370,3 +3370,51 @@ npm run typecheck Practice the demo with the API intentionally stopped once, then confirm the presenter can recover using only the visible target and recovery guidance. + +## 2026-05-22 - Workbench empty and error state audit + +### What changed + +Tightened the Operations Workbench loading, empty, missing-data, and API-error +states for issue #177. Empty and missing demo-data panels now use status +semantics and short `Next step:` recovery copy, while API connection failures +remain alert panels with local API guidance. + +### Why it was built that way + +The Workbench already had the required routes and most state panels, so the +smallest useful change was to refine existing components and route copy instead +of adding a global error boundary or production monitoring feature. Missing demo +data is separated from API connectivity to keep local rehearsal issues from +looking like product failures. + +### How data flows through it + +Each route still calls the existing local FastAPI endpoints. Empty arrays and +404-style missing IDs render local demo recovery panels. Network/API failures +flow through `ApiErrorPanel`, preserving `role="alert"` and the configured API +target guidance. + +### How to run it + +```bash +make demo +make api +cd apps/web +npm run dev +``` + +### How to test it + +```bash +cd apps/web +npm test +npm run lint +npm run typecheck +make test-e2e +``` + +### What to learn next + +During rehearsal, intentionally visit a stale detection URL and confirm the +presenter can recover from the visible `Next step:` text without opening docs. diff --git a/docs/demo/TROUBLESHOOTING.md b/docs/demo/TROUBLESHOOTING.md index 70691d3..85271a4 100644 --- a/docs/demo/TROUBLESHOOTING.md +++ b/docs/demo/TROUBLESHOOTING.md @@ -104,7 +104,7 @@ make demo - `make demo-sentinel-run` prints `detections=0`. - `/sentinel/detections` returns an empty list. -- The Workbench detection list has no Process Sentinel case. +- The Workbench detection list shows `No detections returned`. - The recommendation queue is empty because no detection was created. ### Common causes @@ -149,6 +149,9 @@ Confirm: sentinel complete: detections=1 evidence=2 recommendations=1 ``` +Refresh the Workbench after the API is started. Empty-state panels indicate the +local API is reachable but the expected simulator-backed demo state is missing. + ## API Not Running ### Symptoms @@ -319,6 +322,29 @@ Expected fields include: The draft is advisory decision support only. It does not create, close, or submit a CAPA. +## Workbench Missing-State Panels + +### Symptoms + +- A page shows `Detection not found`, `No evidence available`, + `No linked recommendation found`, or `No detection available for draft + preview`. +- The API target is reachable, but the selected ID or generated local state does + not match the current demo run. + +### Recovery + +Use the visible `Next step:` line in the Workbench panel first. For a clean +manufacturer demo reset, rebuild deterministic state: + +```bash +make demo +``` + +Restart the API if it was already running, then refresh the Workbench. These +missing-state panels are local demo recovery states; they are not production +incident handling or real plant failure modes. + ## Recommendation Decision Not Updating ### Symptoms