Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,15 @@ The API client currently covers:
- `POST /recommendations/{recommendation_id}/defer`
- `GET /reports/rca-capa-drafts/{detection_id}`

The Overview page shows the configured API target, `/health` status, and
simulator-backed demo source so a presenter can confirm the browser is reading
from the expected local backend.

When the backend is unavailable, the demo pages render a readable API connection
message instead of failing silently.
message instead of failing silently. The recovery panel shows the configured
target and prompts the presenter to run `make demo`, start the API with
`make api`, and restart the Workbench with `NEXT_PUBLIC_API_BASE_URL` if the API
is running on a non-default port.

## Routes

Expand All @@ -85,8 +92,9 @@ message instead of failing silently.
- `/rca-capa-draft?detection_id={detection_id}` - RCA/CAPA draft preview
scoped to the selected Process Sentinel detection

The shell labels all demo content as simulator-backed data. The pages include
loading states, empty states, and simple user-readable API error states.
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.

## Governance Decision Feedback

Expand Down
80 changes: 77 additions & 3 deletions apps/web/app/components/demo-state.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import type { HealthResponse } from "../../lib/api-client";
import { getApiBaseUrl } from "../../lib/api-client";

type ApiErrorPanelProps = {
apiBaseUrl?: string;
message: string;
};

type ApiConnectionBannerProps = {
apiBaseUrl?: string;
health?: HealthResponse;
};

type DemoDataBadgeProps = {
label?: string;
};

type EmptyStateProps = {
title: string;
text: string;
Expand All @@ -23,15 +36,76 @@ type StatusBadgeProps = {
value: string;
};

export function ApiErrorPanel({ message }: ApiErrorPanelProps) {
export function ApiConnectionBanner({
apiBaseUrl = getApiBaseUrl(),
health,
}: ApiConnectionBannerProps) {
return (
<section className="api-connection-banner" aria-label="Local API connection state">
<div>
<strong>Local API connected</strong>
<span>
Workbench data is coming from the configured FastAPI target for this
simulator-backed demo.
</span>
</div>
<dl className="api-connection-details">
<div>
<dt>API target</dt>
<dd>{apiBaseUrl}</dd>
</div>
<div>
<dt>Health</dt>
<dd>{health?.status ?? "Not checked"}</dd>
</div>
<div>
<dt>Source</dt>
<dd>
{health?.simulator_backed
? "Simulator-backed demo API"
: "Simulator-backed demo API expected"}
</dd>
</div>
</dl>
</section>
);
}

export function ApiErrorPanel({
apiBaseUrl = getApiBaseUrl(),
message,
}: ApiErrorPanelProps) {
return (
<div className="state-panel error-panel" role="alert">
<strong>API connection issue</strong>
<span>{message}</span>
<span>
The Workbench could not reach the local simulator-backed API at{" "}
<code>{apiBaseUrl}</code>.
</span>
<span>
Start the demo state with <code>make demo</code>, start the API with{" "}
<code>make api</code>, then refresh this page. If the API is using a
different port, restart the Workbench with{" "}
<code>NEXT_PUBLIC_API_BASE_URL</code> set to that target.
</span>
<span>Details: {message}</span>
</div>
);
}

export function DemoDataBadge({
label = "Simulator-backed demo data",
}: DemoDataBadgeProps) {
return (
<span className="demo-notice">
<span className="demo-notice-label">{label}</span>
<span className="demo-notice-copy">
Synthetic local scenario; not real plant data.
</span>
</span>
);
}

export function EmptyState({ title, text }: EmptyStateProps) {
return (
<div className="state-panel">
Expand All @@ -44,7 +118,7 @@ export function EmptyState({ title, text }: EmptyStateProps) {
export function LoadingState({ title = "Loading simulator-backed demo data" }: LoadingStateProps) {
return (
<section className="content-panel" aria-busy="true">
<span className="demo-label">Simulator-backed demo data</span>
<DemoDataBadge />
<div className="state-panel">
<strong>{title}</strong>
<span>Connecting to the local FastAPI demo backend.</span>
Expand Down
83 changes: 83 additions & 0 deletions apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,29 @@ a {
padding: 7px 8px;
}

.demo-notice {
display: inline-grid;
width: fit-content;
gap: 4px;
border: 1px solid #d9c18c;
border-radius: 7px;
background: #fff8e7;
color: var(--warning);
line-height: 1.2;
padding: 7px 9px;
}

.demo-notice-label {
font-size: 0.78rem;
font-weight: 760;
}

.demo-notice-copy {
color: #765011;
font-size: 0.72rem;
font-weight: 650;
}

.status-badge {
display: inline-flex;
width: fit-content;
Expand Down Expand Up @@ -337,6 +360,58 @@ h3 {
margin-top: 24px;
}

.api-connection-banner {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(320px, 1.1fr);
gap: 18px;
align-items: start;
margin-top: 18px;
border: 1px solid #b7d4c8;
border-left: 4px solid var(--accent);
border-radius: 8px;
background: #f3faf6;
padding: 16px;
}

.api-connection-banner strong {
display: block;
margin-bottom: 5px;
}

.api-connection-banner span,
.api-connection-details dd {
color: #244337;
line-height: 1.45;
}

.api-connection-details {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin: 0;
}

.api-connection-details div {
display: grid;
gap: 4px;
min-width: 0;
border-left: 1px solid #b7d4c8;
padding-left: 10px;
}

.api-connection-details dt {
color: #4d665b;
font-size: 0.74rem;
font-weight: 760;
text-transform: uppercase;
}

.api-connection-details dd {
margin: 0;
overflow-wrap: anywhere;
font-weight: 720;
}

.overview-panel,
.metric-card {
border: 1px solid var(--border);
Expand Down Expand Up @@ -923,6 +998,10 @@ code {
justify-content: flex-start;
}

.api-connection-banner {
grid-template-columns: 1fr;
}

.content-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
Expand Down Expand Up @@ -986,6 +1065,10 @@ code {
grid-template-columns: 1fr;
}

.api-connection-details {
grid-template-columns: 1fr;
}

.detection-fields {
grid-template-columns: 1fr;
}
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import type { ReactNode } from "react";

import { DemoDataBadge } from "./components/demo-state";
import "./globals.css";

export const metadata: Metadata = {
Expand Down Expand Up @@ -34,6 +35,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
</Link>
))}
</nav>
<DemoDataBadge />
</div>
</header>
<main className="page-shell">{children}</main>
Expand Down
13 changes: 11 additions & 2 deletions apps/web/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import Link from "next/link";

import { ApiErrorPanel, StatusBadge } from "./components/demo-state";
import {
ApiConnectionBanner,
ApiErrorPanel,
DemoDataBadge,
StatusBadge,
} from "./components/demo-state";
import {
type Area,
type Batch,
Expand Down Expand Up @@ -35,7 +40,7 @@ export default async function OverviewPage() {
<>
<section className="hero">
<div className="hero-copy">
<span className="demo-label">Simulator-backed demo data</span>
<DemoDataBadge />
<h1>{overview.ok ? overview.context.siteName : "Operations Workbench"}</h1>
<p className="lead">
{overview.ok
Expand Down Expand Up @@ -73,6 +78,10 @@ export default async function OverviewPage() {
</aside>
</section>

{overview.ok ? (
<ApiConnectionBanner apiBaseUrl={getApiBaseUrl()} health={overview.health} />
) : null}

{!overview.ok ? <ApiErrorPanel message={overview.message} /> : null}

{overview.ok ? (
Expand Down
5 changes: 5 additions & 0 deletions apps/web/e2e/operations-workbench-demo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ test("walks the simulator-backed Operations Workbench demo path", async ({ page

await page.goto("/");
await expect(page.getByText("Simulator-backed demo data").first()).toBeVisible();
await expect(page.getByText("Synthetic local scenario; not real plant data.").first()).toBeVisible();
await expect(page.getByRole("region", { name: "Local API connection state" })).toBeVisible();
await expect(page.getByText("API target").first()).toBeVisible();
await expect(page.getByText("Health", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Simulator-backed demo API")).toBeVisible();
await expect(page.getByRole("heading", { name: "Greenville Demo Site" })).toBeVisible();
await expect(page.getByText("Active detections")).toBeVisible();
await expect(page.getByText("Pending recommendations")).toBeVisible();
Expand Down
3 changes: 3 additions & 0 deletions apps/web/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ export function formatApiError(error: unknown): string {
const status = error.status ? ` (${error.status})` : "";
return `${error.message}${status}`;
}
if (error instanceof TypeError) {
return "The Workbench could not reach the configured FastAPI demo backend.";
}
if (error instanceof Error) {
return error.message;
}
Expand Down
14 changes: 14 additions & 0 deletions apps/web/tests/app-shell.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,25 @@ test("navigation includes the required demo routes", () => {
assert.match(layout, /Detections/);
assert.match(layout, /Recommendations/);
assert.match(layout, /RCA\/CAPA Draft/);
assert.match(layout, /DemoDataBadge/);
});

test("overview page contains manufacturer demo dashboard content", () => {
const overview = readFileSync(join(root, "app/page.tsx"), "utf8");
const demoState = readFileSync(join(root, "app/components/demo-state.tsx"), "utf8");

assert.match(overview, /Current demo context/);
assert.match(overview, /Active detections/);
assert.match(overview, /Pending recommendations/);
assert.match(overview, /Most important detection/);
assert.match(overview, /Open detection/);
assert.match(overview, /selectImportantDetection/);
assert.match(overview, /ApiConnectionBanner/);
assert.match(overview, /API base URL/);
assert.match(overview, /API health/);
assert.match(demoState, /Local API connected/);
assert.match(demoState, /API target/);
assert.match(demoState, /Simulator-backed demo API/);
});

test("detections pages contain list and detail content", () => {
Expand Down Expand Up @@ -167,11 +175,17 @@ test("RCA CAPA page contains selected detection draft preview", () => {
test("app shell documents configurable API base URL", () => {
const config = readFileSync(join(root, "lib/api-config.ts"), "utf8");
const client = readFileSync(join(root, "lib/api-client.ts"), "utf8");
const demoState = readFileSync(join(root, "app/components/demo-state.tsx"), "utf8");
const readme = readFileSync(join(root, "README.md"), "utf8");

assert.match(config, /NEXT_PUBLIC_API_BASE_URL/);
assert.match(client, /apiBaseUrl/);
assert.match(client, /configured FastAPI demo backend/);
assert.match(config, /http:\/\/127\.0\.0\.1:8000/);
assert.match(demoState, /make demo/);
assert.match(demoState, /make api/);
assert.match(demoState, /NEXT_PUBLIC_API_BASE_URL/);
assert.match(demoState, /Synthetic local scenario; not real plant data/);
assert.match(readme, /NEXT_PUBLIC_API_BASE_URL/);
});

Expand Down
Loading
Loading