-
{overview.ok ? overview.context.siteName : "Operations Workbench"}
{overview.ok
diff --git a/apps/web/app/protocol-diagnostics/loading.tsx b/apps/web/app/protocol-diagnostics/loading.tsx
new file mode 100644
index 0000000..5388d4a
--- /dev/null
+++ b/apps/web/app/protocol-diagnostics/loading.tsx
@@ -0,0 +1,11 @@
+export default function ProtocolDiagnosticsLoading() {
+ return (
+
+ Protocol Operations
+ Protocol Diagnostics
+
+ Loading read-only protocol diagnostics from the Factory Intelligence API.
+
+
+ );
+}
diff --git a/apps/web/app/protocol-diagnostics/page.tsx b/apps/web/app/protocol-diagnostics/page.tsx
new file mode 100644
index 0000000..272dba3
--- /dev/null
+++ b/apps/web/app/protocol-diagnostics/page.tsx
@@ -0,0 +1,78 @@
+import { ApiErrorPanel } from "../components/demo-state";
+import {
+ type ConnectionTestResult,
+ type ProtocolConnectionProfile,
+ formatApiError,
+ workbenchApi,
+} from "../../lib/api-client";
+import { ProtocolDiagnosticsWorkspace } from "./protocol-diagnostics-workspace";
+
+export const dynamic = "force-dynamic";
+
+type LoadResult =
+ | { data: T; error: null; ok: true }
+ | { data: null; error: string; ok: false };
+
+type ConnectionTestLoadResult =
+ | { connectionId: string; result: ConnectionTestResult; error: null; ok: true }
+ | { connectionId: string; result: null; error: string; ok: false };
+
+export default async function ProtocolDiagnosticsPage() {
+ const [healthResult, profilesResult, signalsResult] = await Promise.all([
+ loadData(() => workbenchApi.getHealth()),
+ loadData(() => workbenchApi.listConnectionProfiles()),
+ loadData(() => workbenchApi.listProcessSignals()),
+ ]);
+
+ const profiles = profilesResult.data ?? [];
+ const testResults = profilesResult.ok
+ ? await Promise.all(
+ profiles.map(async (profile) => loadConnectionTest(profile.id)),
+ )
+ : [];
+ const pageError =
+ healthResult.error ?? profilesResult.error ?? signalsResult.error ?? null;
+
+ return (
+
+ {pageError !== null ? : null}
+
+
+ );
+}
+
+async function loadData(loader: () => Promise): Promise> {
+ try {
+ return { data: await loader(), error: null, ok: true };
+ } catch (error) {
+ return { data: null, error: formatApiError(error), ok: false };
+ }
+}
+
+async function loadConnectionTest(
+ connectionId: string,
+): Promise {
+ try {
+ return {
+ connectionId,
+ error: null,
+ ok: true,
+ result: await workbenchApi.testConnectionProfile(connectionId),
+ };
+ } catch (error) {
+ return {
+ connectionId,
+ error: formatApiError(error),
+ ok: false,
+ result: null,
+ };
+ }
+}
diff --git a/apps/web/app/protocol-diagnostics/protocol-diagnostics-workspace.tsx b/apps/web/app/protocol-diagnostics/protocol-diagnostics-workspace.tsx
new file mode 100644
index 0000000..3bebc16
--- /dev/null
+++ b/apps/web/app/protocol-diagnostics/protocol-diagnostics-workspace.tsx
@@ -0,0 +1,494 @@
+"use client";
+
+import { useMemo, useState } from "react";
+
+import { StatusBadge } from "../components/demo-state";
+import {
+ type ConnectionTestResult,
+ type HealthResponse,
+ type ProcessSignal,
+ type Protocol,
+ type ProtocolConnectionProfile,
+} from "../../lib/api-client";
+
+type ConnectionTestLoadResult =
+ | { connectionId: string; result: ConnectionTestResult; error: null; ok: true }
+ | { connectionId: string; result: null; error: string; ok: false };
+
+type ProtocolDiagnosticsWorkspaceProps = {
+ health: HealthResponse | null;
+ healthError: string | null;
+ processSignals: ProcessSignal[];
+ processSignalsError: string | null;
+ profiles: ProtocolConnectionProfile[];
+ profilesError: string | null;
+ testResults: ConnectionTestLoadResult[];
+};
+
+type DiagnosticsCard = {
+ detail: string;
+ label: string;
+ meta: string;
+ status: string;
+ tone: "danger" | "draft" | "neutral" | "success" | "warning";
+};
+
+type MappingRow = {
+ connectionId: string;
+ connectionName: string;
+ fipSignalName: string;
+ mappingReference: string;
+ protocol: Protocol;
+ sourceReference: string;
+ unit: string;
+};
+
+const protocols: Protocol[] = ["opcua", "mqtt", "bacnet"];
+
+const demoFactoryTroubleshootingCommands = [
+ "cd ~/Documents/Open\\ Factory\\ Initiative/Demo-Factory",
+ "docker compose ps",
+ "python -m pytest tests/test_protocol_services.py",
+ "npm run build",
+];
+
+export function ProtocolDiagnosticsWorkspace({
+ health,
+ healthError,
+ processSignals,
+ processSignalsError,
+ profiles,
+ profilesError,
+ testResults,
+}: ProtocolDiagnosticsWorkspaceProps) {
+ const [query, setQuery] = useState("");
+ const testResultByConnection = useMemo(
+ () => indexConnectionTests(testResults),
+ [testResults],
+ );
+ const mappingRows = useMemo(
+ () => buildMappingRows(profiles, processSignals),
+ [processSignals, profiles],
+ );
+ const visibleRows = useMemo(
+ () => filterMappingRows(mappingRows, query),
+ [mappingRows, query],
+ );
+ const diagnosticsCards = useMemo(
+ () =>
+ buildDiagnosticsCards({
+ health,
+ healthError,
+ processSignals,
+ processSignalsError,
+ profiles,
+ profilesError,
+ testResultByConnection,
+ }),
+ [
+ health,
+ healthError,
+ processSignals,
+ processSignalsError,
+ profiles,
+ profilesError,
+ testResultByConnection,
+ ],
+ );
+
+ return (
+
+
+
+
+ {diagnosticsCards.map((card) => (
+
+
+
{card.label}
+
+
+ {card.detail}
+ {card.meta}
+
+ ))}
+
+
+
+
+
+ Configured connection status
+
Connection Diagnostics
+
+
+
+
+
+
+ | Connection |
+ Protocol |
+ Configured status |
+ Last test result |
+ Last checked |
+ Error or next step |
+
+
+
+ {profiles.length === 0 ? (
+
+ |
+ No configured connections were returned by the FIP API.
+ |
+
+ ) : (
+ profiles.map((profile) => {
+ const testLoad = testResultByConnection.get(profile.id);
+ const result = testLoad?.result ?? null;
+ return (
+
+ |
+ {profile.name}
+ {profile.endpoint}
+ |
+ {formatProtocol(profile.protocol)} |
+
+
+ |
+
+ {result !== null ? (
+
+ ) : (
+
+ )}
+ |
+ {result ? formatDateTime(result.checked_at) : "Not checked"} |
+ {connectionMessage(testLoad, result)} |
+
+ );
+ })
+ )}
+
+
+
+
+
+
+
+
+ Protocol/tag mapping
+
Source Mapping Table
+
+
+
+
+
+
+
+ | Source/tag |
+ Protocol |
+ Connection |
+ Mapping reference |
+ FIP signal |
+ Unit |
+
+
+
+ {visibleRows.length === 0 ? (
+
+ |
+ {mappingRows.length === 0
+ ? "No protocol source mappings are available yet."
+ : "No mappings match the current search."}
+ |
+
+ ) : (
+ visibleRows.map((row) => (
+
+ | {row.sourceReference} |
+ {formatProtocol(row.protocol)} |
+ {row.connectionName} |
+ {row.mappingReference} |
+ {row.fipSignalName} |
+ {row.unit} |
+
+ ))
+ )}
+
+
+
+
+
+
+ Local Demo-Factory validation next steps
+
+ Use these commands when comparing FIP diagnostics against the local
+ Demo-Factory protocol services. This page reads FIP API data only; it
+ does not inspect Demo-Factory runtime state directly.
+
+
+ {demoFactoryTroubleshootingCommands.map((command) => (
+ -
+
{command}
+
+ ))}
+
+
+
+ );
+}
+
+function indexConnectionTests(testResults: ConnectionTestLoadResult[]) {
+ const index = new Map();
+ for (const result of testResults) {
+ index.set(result.connectionId, result);
+ }
+ return index;
+}
+
+function buildDiagnosticsCards({
+ health,
+ healthError,
+ processSignals,
+ processSignalsError,
+ profiles,
+ profilesError,
+ testResultByConnection,
+}: {
+ health: HealthResponse | null;
+ healthError: string | null;
+ processSignals: ProcessSignal[];
+ processSignalsError: string | null;
+ profiles: ProtocolConnectionProfile[];
+ profilesError: string | null;
+ testResultByConnection: Map;
+}): DiagnosticsCard[] {
+ const protocolCards = protocols.map((protocol) =>
+ buildProtocolCard(protocol, profiles, profilesError, testResultByConnection),
+ );
+ return [
+ ...protocolCards,
+ {
+ detail:
+ healthError ??
+ (health
+ ? `FastAPI health endpoint returned ${health.status}.`
+ : "API health has not been loaded."),
+ label: "API",
+ meta: health?.connection_profiles_store
+ ? `Profiles: ${health.connection_profiles_store}`
+ : "Connection profile store unavailable",
+ status: healthError ? "error" : health?.status ?? "unknown",
+ tone: healthError ? "danger" : health?.status === "ok" ? "success" : "warning",
+ },
+ {
+ detail:
+ processSignalsError ??
+ (health?.events_store
+ ? `Events store configured at ${health.events_store}.`
+ : "Ingestion event store is not available from health data."),
+ label: "Ingestion",
+ meta: `${processSignals.length} process signals available for mapping`,
+ status: processSignalsError ? "error" : health?.events_store ? "configured" : "unknown",
+ tone: processSignalsError ? "danger" : health?.events_store ? "success" : "warning",
+ },
+ ];
+}
+
+function buildProtocolCard(
+ protocol: Protocol,
+ profiles: ProtocolConnectionProfile[],
+ profilesError: string | null,
+ testResultByConnection: Map,
+): DiagnosticsCard {
+ if (profilesError !== null) {
+ return {
+ detail: profilesError,
+ label: formatProtocol(protocol),
+ meta: "Connection profiles unavailable",
+ status: "error",
+ tone: "danger",
+ };
+ }
+
+ const protocolProfiles = profiles.filter((profile) => profile.protocol === protocol);
+ if (protocolProfiles.length === 0) {
+ return {
+ detail: `No configured ${formatProtocol(protocol)} connections returned by the FIP API.`,
+ label: formatProtocol(protocol),
+ meta: "0 configured connections",
+ status: "empty",
+ tone: "neutral",
+ };
+ }
+
+ const enabledCount = protocolProfiles.filter((profile) => profile.enabled).length;
+ const testResults = protocolProfiles
+ .map((profile) => testResultByConnection.get(profile.id)?.result ?? null)
+ .filter((result): result is ConnectionTestResult => result !== null);
+ const failedCount = testResults.filter((result) =>
+ ["failed", "invalid"].includes(result.status),
+ ).length;
+ const disabledCount = testResults.filter((result) => result.status === "disabled").length;
+ const healthyCount = testResults.filter((result) => result.status === "healthy").length;
+ const status =
+ failedCount > 0
+ ? "error"
+ : healthyCount > 0
+ ? "healthy"
+ : disabledCount === protocolProfiles.length
+ ? "disabled"
+ : "unknown";
+
+ return {
+ detail: `${enabledCount} enabled, ${protocolProfiles.length - enabledCount} disabled.`,
+ label: formatProtocol(protocol),
+ meta: `${protocolProfiles.length} configured connection${
+ protocolProfiles.length === 1 ? "" : "s"
+ }`,
+ status,
+ tone: status === "healthy" ? "success" : status === "error" ? "danger" : "warning",
+ };
+}
+
+function buildMappingRows(
+ profiles: ProtocolConnectionProfile[],
+ processSignals: ProcessSignal[],
+): MappingRow[] {
+ const rows: MappingRow[] = [];
+ for (const profile of profiles) {
+ for (const sourceReference of sourceReferencesForProfile(profile)) {
+ const signal = findMappedSignal(sourceReference, profile.mapping_reference, processSignals);
+ rows.push({
+ connectionId: profile.id,
+ connectionName: profile.name,
+ fipSignalName: signal?.name ?? "Mapping pending",
+ mappingReference: profile.mapping_reference,
+ protocol: profile.protocol,
+ sourceReference,
+ unit: signal?.unit ?? "-",
+ });
+ }
+ }
+ return rows;
+}
+
+function sourceReferencesForProfile(profile: ProtocolConnectionProfile): string[] {
+ if (profile.protocol === "opcua") {
+ return profile.opcua?.node_ids ?? [];
+ }
+ if (profile.protocol === "mqtt") {
+ return profile.mqtt?.topic_filters ?? [];
+ }
+ return profile.bacnet?.object_references ?? [];
+}
+
+function findMappedSignal(
+ sourceReference: string,
+ mappingReference: string,
+ processSignals: ProcessSignal[],
+): ProcessSignal | undefined {
+ const haystack = `${sourceReference} ${mappingReference}`.toLowerCase();
+ return processSignals.find((signal) => haystack.includes(signal.signal_id.toLowerCase()));
+}
+
+function filterMappingRows(rows: MappingRow[], query: string): MappingRow[] {
+ const normalizedQuery = query.trim().toLowerCase();
+ if (normalizedQuery.length === 0) {
+ return rows;
+ }
+ return rows.filter((row) =>
+ [
+ row.connectionName,
+ row.fipSignalName,
+ row.mappingReference,
+ row.protocol,
+ row.sourceReference,
+ row.unit,
+ ]
+ .join(" ")
+ .toLowerCase()
+ .includes(normalizedQuery),
+ );
+}
+
+function connectionMessage(
+ testLoad: ConnectionTestLoadResult | undefined,
+ result: ConnectionTestResult | null,
+): string {
+ if (testLoad?.error) {
+ return testLoad.error;
+ }
+ if (result) {
+ return result.message;
+ }
+ return "Run a read-only connection test from the FIP API.";
+}
+
+function formatProtocol(protocol: Protocol | string): string {
+ if (protocol === "opcua") {
+ return "OPC-UA";
+ }
+ if (protocol === "mqtt") {
+ return "MQTT";
+ }
+ if (protocol === "bacnet") {
+ return "BACnet";
+ }
+ return protocol;
+}
+
+function healthTone(state: ProtocolConnectionProfile["health_state"]) {
+ if (state === "healthy") {
+ return "success";
+ }
+ if (state === "failed") {
+ return "danger";
+ }
+ if (state === "degraded") {
+ return "warning";
+ }
+ if (state === "disabled") {
+ return "draft";
+ }
+ return "neutral";
+}
+
+function testTone(status: ConnectionTestResult["status"]) {
+ if (status === "healthy") {
+ return "success";
+ }
+ if (status === "failed" || status === "invalid") {
+ return "danger";
+ }
+ if (status === "disabled") {
+ return "draft";
+ }
+ return "warning";
+}
+
+function formatDateTime(value: string): string {
+ return new Intl.DateTimeFormat("en", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(new Date(value));
+}
diff --git a/apps/web/e2e/operations-workbench-demo.spec.ts b/apps/web/e2e/operations-workbench-demo.spec.ts
index 8856242..97cf930 100644
--- a/apps/web/e2e/operations-workbench-demo.spec.ts
+++ b/apps/web/e2e/operations-workbench-demo.spec.ts
@@ -25,9 +25,20 @@ test("walks the simulator-backed Operations Workbench demo path", async ({ page
await expect(page.getByText("Disabled", { exact: true })).toBeVisible();
await page.locator('a[href="/connections"]').first().click();
await createDisabledConnectionProfile(page);
+ await page.locator('a[href="/protocol-diagnostics"]').first().click();
+ await expect(page.getByRole("heading", { name: "Protocol Diagnostics" })).toBeVisible();
+ await expect(page.getByRole("region", { name: "Protocol and platform health" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Connection Diagnostics" })).toBeVisible();
+ await expect(page.getByRole("table").first()).toContainText("Last test result");
+ await expect(page.getByRole("heading", { name: "Source Mapping Table" })).toBeVisible();
+ await expect(page.getByLabel("Search mappings")).toBeVisible();
+ await expect(page.getByText("Local Demo-Factory validation next steps")).toBeVisible();
+ await expect(page.getByText("docker compose ps")).toBeVisible();
+ await page.getByLabel("Search mappings").fill("playwright");
+ await expect(page.getByRole("table").nth(1)).toContainText("playwright");
await page.locator('a[href="/"]').first().click();
- 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.getByText("Simulator-backed demo data")).toHaveCount(0);
+ await expect(page.getByText("Synthetic local scenario; not real plant data.")).toHaveCount(0);
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();
@@ -98,8 +109,8 @@ async function createDisabledConnectionProfile(page: Page) {
const modal = page.getByRole("dialog", { name: "Profile definition" });
await expect(modal).toBeVisible();
await expect(modal.getByText("OPC-UA controls")).toBeVisible();
- await expect(page.getByText("MQTT", { exact: true })).toBeVisible();
- await expect(page.getByText("BACnet", { exact: true })).toBeVisible();
+ await expect(modal.getByText("MQTT", { exact: true })).toBeVisible();
+ await expect(modal.getByText("BACnet", { exact: true })).toBeVisible();
await expect(modal.getByText("does not start ingestion")).toBeVisible();
await expect(modal.getByText("does not write to PLC")).toBeVisible();
await expect(modal.getByLabel("Enabled for read-only diagnostics")).not.toBeChecked();
diff --git a/apps/web/lib/api-client.ts b/apps/web/lib/api-client.ts
index 5555787..35cc629 100644
--- a/apps/web/lib/api-client.ts
+++ b/apps/web/lib/api-client.ts
@@ -5,6 +5,7 @@ export type HealthResponse = {
simulator_backed: boolean;
events_store: string;
sentinel_state_dir: string;
+ connection_profiles_store?: string;
};
export type Protocol = "opcua" | "mqtt" | "bacnet";
@@ -109,6 +110,15 @@ export type Equipment = {
criticality: "low" | "medium" | "high";
};
+export type ProcessSignal = {
+ signal_id: string;
+ equipment_id: string;
+ name: string;
+ unit: string;
+ normal_min: number;
+ normal_max: number;
+};
+
export type Batch = {
batch_id: string;
site_id: string;
@@ -253,6 +263,7 @@ export const workbenchApi = {
listSites: () => requestJson("/sites"),
listAreas: () => requestJson("/areas"),
listEquipment: () => requestJson("/equipment"),
+ listProcessSignals: () => requestJson("/process-signals"),
listBatches: () => requestJson("/batches"),
listDetections: () => requestJson("/sentinel/detections"),
getDetection: (detectionId: string) =>
diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts
index cc9aa7b..a6bdc80 100644
--- a/apps/web/playwright.config.ts
+++ b/apps/web/playwright.config.ts
@@ -3,6 +3,10 @@ import path from "node:path";
const webRoot = __dirname;
const repoRoot = path.resolve(__dirname, "../..");
+const apiPort = process.env.PLAYWRIGHT_API_PORT ?? "8000";
+const webPort = process.env.PLAYWRIGHT_WEB_PORT ?? "3000";
+const apiUrl = `http://127.0.0.1:${apiPort}`;
+const webUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${webPort}`;
export default defineConfig({
expect: {
@@ -13,30 +17,34 @@ export default defineConfig({
testDir: "./e2e",
timeout: 90_000,
use: {
- baseURL: "http://127.0.0.1:3000",
+ baseURL: webUrl,
trace: "retain-on-failure",
},
webServer: [
{
command:
- "make api EVENTS_STORE=.local/storage/fill_weight_drift_demo_events.jsonl SENTINEL_STATE_DIR=.local/storage/fill_weight_drift_demo_sentinel",
+ `.venv/bin/uvicorn factory_api.main:app --app-dir services/api --host 127.0.0.1 --port ${apiPort}`,
cwd: repoRoot,
env: {
- FACTORY_API_CORS_ORIGINS: "http://127.0.0.1:3000",
+ FACTORY_API_CORS_ORIGINS: webUrl,
+ FACTORY_EVENTS_STORE: ".local/storage/fill_weight_drift_demo_events.jsonl",
+ PYTHONPATH:
+ "packages/factory-events:services/simulator:services/ingestion:services/process-sentinel:services/api",
+ SENTINEL_STATE_DIR: ".local/storage/fill_weight_drift_demo_sentinel",
},
reuseExistingServer: false,
timeout: 30_000,
- url: "http://127.0.0.1:8000/health",
+ url: `${apiUrl}/health`,
},
{
- command: "npm run dev",
+ command: `npm run dev -- --port ${webPort}`,
cwd: webRoot,
env: {
- NEXT_PUBLIC_API_BASE_URL: "http://127.0.0.1:8000",
+ NEXT_PUBLIC_API_BASE_URL: apiUrl,
},
reuseExistingServer: false,
timeout: 45_000,
- url: "http://127.0.0.1:3000",
+ url: webUrl,
},
],
workers: 1,
diff --git a/apps/web/tests/api-client.test.mjs b/apps/web/tests/api-client.test.mjs
index d314d11..03962ec 100644
--- a/apps/web/tests/api-client.test.mjs
+++ b/apps/web/tests/api-client.test.mjs
@@ -16,6 +16,7 @@ const requiredEndpoints = [
"/sites",
"/areas",
"/equipment",
+ "/process-signals",
"/batches",
"/sentinel/detections",
"/sentinel/detections/${encodeURIComponent(detectionId)}",
@@ -41,6 +42,7 @@ test("typed workbench API client covers demo endpoints", () => {
assert.match(client, /listSites/);
assert.match(client, /listAreas/);
assert.match(client, /listEquipment/);
+ assert.match(client, /listProcessSignals/);
assert.match(client, /listBatches/);
assert.match(client, /listDetections/);
assert.match(client, /getDetection/);
@@ -56,6 +58,7 @@ test("typed workbench API client covers demo endpoints", () => {
assert.match(client, /export type AuditEvent/);
assert.match(client, /export type ProtocolConnectionProfile/);
assert.match(client, /export type ConnectionTestResult/);
+ assert.match(client, /export type ProcessSignal/);
assert.match(client, /details: Record/);
});
diff --git a/apps/web/tests/app-shell.test.mjs b/apps/web/tests/app-shell.test.mjs
index d2f93c8..542d924 100644
--- a/apps/web/tests/app-shell.test.mjs
+++ b/apps/web/tests/app-shell.test.mjs
@@ -11,6 +11,9 @@ const requiredRoutes = [
"app/page.tsx",
"app/connections/page.tsx",
"app/connections/connections-workspace.tsx",
+ "app/protocol-diagnostics/page.tsx",
+ "app/protocol-diagnostics/protocol-diagnostics-workspace.tsx",
+ "app/protocol-diagnostics/loading.tsx",
"app/detections/page.tsx",
"app/detections/[detectionId]/page.tsx",
"app/recommendations/page.tsx",
@@ -39,6 +42,7 @@ test("navigation includes the required demo routes", () => {
assert.match(layout, /Connections/);
assert.match(layout, /href: "\/connections"/);
assert.match(layout, /Protocol Diagnostics/);
+ assert.match(layout, /href: "\/protocol-diagnostics"/);
assert.match(layout, /Tag\/Source Browser/);
assert.match(layout, /aria-disabled="true"/);
assert.match(layout, /Workbench status strip/);
@@ -47,7 +51,54 @@ test("navigation includes the required demo routes", () => {
assert.match(layout, /Read-only diagnostics/);
assert.match(layout, /Writeback/);
assert.match(layout, /Disabled/);
- assert.match(layout, /DemoDataBadge/);
+ assert.doesNotMatch(layout, /DemoDataBadge/);
+ assert.doesNotMatch(layout, /Simulator-backed demo data/);
+});
+
+test("protocol diagnostics page uses FIP API data and searchable mapping UI", () => {
+ const page = readFileSync(join(root, "app/protocol-diagnostics/page.tsx"), "utf8");
+ const workspace = readFileSync(
+ join(root, "app/protocol-diagnostics/protocol-diagnostics-workspace.tsx"),
+ "utf8",
+ );
+ const styles = readFileSync(join(root, "app/globals.css"), "utf8");
+ const client = readFileSync(join(root, "lib/api-client.ts"), "utf8");
+ const e2e = readFileSync(join(root, "e2e/operations-workbench-demo.spec.ts"), "utf8");
+
+ assert.match(page, /getHealth/);
+ assert.match(page, /listConnectionProfiles/);
+ assert.match(page, /listProcessSignals/);
+ assert.match(page, /testConnectionProfile/);
+ assert.match(page, /Promise\.all/);
+ assert.match(page, /ProtocolDiagnosticsWorkspace/);
+ assert.match(workspace, /Protocol Diagnostics/);
+ assert.match(workspace, /Protocol and platform health/);
+ assert.match(workspace, /Connection Diagnostics/);
+ assert.match(workspace, /Configured connection status/);
+ assert.match(workspace, /Last test result/);
+ assert.match(workspace, /Error or next step/);
+ assert.match(workspace, /Source Mapping Table/);
+ assert.match(workspace, /Search mappings/);
+ assert.match(workspace, /No configured connections were returned by the FIP API/);
+ assert.match(workspace, /No protocol source mappings are available yet/);
+ assert.match(workspace, /No mappings match the current search/);
+ assert.match(workspace, /OPC-UA/);
+ assert.match(workspace, /MQTT/);
+ assert.match(workspace, /BACnet/);
+ assert.match(workspace, /API/);
+ assert.match(workspace, /Ingestion/);
+ assert.match(workspace, /Local Demo-Factory validation next steps/);
+ assert.match(workspace, /docker compose ps/);
+ assert.match(workspace, /tests\/test_protocol_services\.py/);
+ assert.match(workspace, /This page reads FIP API data only/);
+ assert.doesNotMatch(workspace, /Start ingestion/);
+ assert.doesNotMatch(workspace, /writeback/);
+ assert.match(client, /export type ProcessSignal/);
+ assert.match(styles, /protocol-diagnostics-workspace/);
+ assert.match(styles, /diagnostics-card-grid/);
+ assert.match(styles, /search-field/);
+ assert.match(e2e, /protocol-diagnostics/);
+ assert.match(e2e, /Source Mapping Table/);
});
test("connections page supports read-only protocol profile setup", () => {
@@ -63,6 +114,7 @@ test("connections page supports read-only protocol profile setup", () => {
assert.doesNotMatch(page, /Test connection only checks/);
assert.match(page, /listConnectionProfiles/);
assert.match(page, /ConnectionsWorkspace/);
+ assert.match(workspace, /Protocol Operations/);
assert.match(workspace, /Connections/);
assert.match(workspace, /Add connection profile/);
assert.match(workspace, /Profile definition/);
@@ -92,6 +144,7 @@ test("connections page supports read-only protocol profile setup", () => {
assert.match(styles, /connections-workspace/);
assert.match(styles, /connection-table/);
assert.match(styles, /modal-backdrop/);
+ assert.doesNotMatch(workspace, /connections-actions/);
});
test("operator shell uses the Demo Factory console palette", () => {
@@ -179,7 +232,7 @@ test("detections pages contain list and detail content", () => {
assert.match(detail, /recommendations\?detection_id=/);
assert.match(detail, /RCA\/CAPA draft/);
assert.match(detail, /rca-capa-draft\?detection_id=/);
- assert.match(detail, /Simulator-backed demo data/);
+ assert.doesNotMatch(detail, /Simulator-backed demo data/);
assert.match(detail, /Simulator-backed evidence/);
assert.match(demoState, /function StatusBadge/);
assert.match(styles, /status-badge/);
@@ -267,7 +320,7 @@ test("app shell documents configurable API base URL", () => {
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.doesNotMatch(demoState, /Synthetic local scenario; not real plant data/);
assert.match(readme, /NEXT_PUBLIC_API_BASE_URL/);
});
@@ -371,7 +424,7 @@ test("operations workbench docs link to the demo runbook", async () => {
assert.match(rootReadme, /docs\/demo\/OPERATIONS_WORKBENCH_DEMO_RUNBOOK\.md/);
assert.match(appReadme, /OPERATIONS_WORKBENCH_DEMO_RUNBOOK\.md/);
assert.match(manufacturerRunbook, /OPERATIONS_WORKBENCH_DEMO_RUNBOOK\.md/);
- assert.match(operationsRunbook, /Simulator-backed demo data/);
+ assert.match(operationsRunbook, /simulator-backed demo data/);
assert.match(operationsRunbook, /BATCH-DEMO-1007/);
assert.match(operationsRunbook, /governed recommendation/);
assert.match(operationsRunbook, /not production data/);
diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md
index bf2c8f2..acf06b1 100644
--- a/docs/LEARNING_LOG.md
+++ b/docs/LEARNING_LOG.md
@@ -22,6 +22,61 @@ This file should be updated by Codex after each meaningful change.
### What to learn next
```
+## 2026-05-25 - Protocol Diagnostics Page
+
+### What changed
+
+Added a Protocol Diagnostics Workbench route that shows API-backed health cards
+for OPC-UA, MQTT, BACnet, the FIP API, and ingestion, plus configured
+connection diagnostics and a searchable source mapping table. Follow-up UI
+polish aligned the Protocol Operations pages around the same heading rhythm,
+removed the visible simulator-backed demo data card, and constrained long
+diagnostics tables so local test profiles do not make the page unwieldy. The
+demo runbook, information architecture notes, and safety-copy tests were also
+updated so they keep simulator-backed boundaries without requiring the removed
+visible card.
+
+### Why it matters
+
+This gives operators a Demo-Factory-style diagnostics view without copying
+Demo-Factory state into FIP. The page reads existing FIP API data: connection
+profiles, read-only connection test results, API health, and process signals.
+
+### How it works
+
+The server page loads health, connection profiles, and process signals in
+parallel. It then calls the existing read-only test endpoint for each configured
+connection and passes those results to a small client component. The client
+component summarizes protocol status cards, shows configured connection status
+and readable test messages, and filters the source mapping table in the browser.
+
+### How to run it
+
+Start the local demo API and web app, then open `/protocol-diagnostics`:
+
+```bash
+make demo
+make api
+cd apps/web
+npm run dev
+```
+
+### How to test it
+
+```bash
+cd apps/web && npm test
+cd apps/web && npm run lint
+cd apps/web && npm run typecheck
+cd apps/web && npm run build
+cd apps/web && npm run test:e2e
+```
+
+### What to learn next
+
+The diagnostics page currently derives source mappings from connection profile
+source identifiers and `mapping_reference` values. The next learning step is a
+dedicated tag/source browser backed by explicit mapping records.
+
## 2026-05-25 - BACnet Read-Only Adapter Foundation
### What changed
diff --git a/docs/demo/DEMO_SAFE_COPY_GUIDELINES.md b/docs/demo/DEMO_SAFE_COPY_GUIDELINES.md
index 2d17317..d142faf 100644
--- a/docs/demo/DEMO_SAFE_COPY_GUIDELINES.md
+++ b/docs/demo/DEMO_SAFE_COPY_GUIDELINES.md
@@ -95,8 +95,8 @@ The demo must not imply compliance status:
### Overview
-- "Simulator-backed demo data"
- "Current demo context"
+- "Local demo state"
- "Open the primary Process Sentinel finding for the demo run."
- "Run `make demo` to prepare the deterministic local scenario."
diff --git a/docs/demo/MANUFACTURER_DEMO_USER_JOURNEY.md b/docs/demo/MANUFACTURER_DEMO_USER_JOURNEY.md
index 8c0df35..cd31213 100644
--- a/docs/demo/MANUFACTURER_DEMO_USER_JOURNEY.md
+++ b/docs/demo/MANUFACTURER_DEMO_USER_JOURNEY.md
@@ -50,9 +50,9 @@ integration, or an AI/model platform demo.
| Step | Screen or Moment | User Goal | Required Screen or Component | Backend/API Data | UX Copy and Safety Language | Risks or Confusion To Avoid | Demo Success Criteria |
| --- | --- | --- | --- | --- | --- | --- | --- |
-| 1 | Overview dashboard | Understand the demo context before opening a finding. | `AppShell`, `DemoDataBadge`, `ApiConnectionBanner`, overview cards, primary detection CTA, `LoadingState`, `ErrorState`, `EmptyState`. | `GET /health`, `GET /sites`, `GET /areas`, `GET /equipment`, `GET /batches`, `GET /sentinel/detections`, `GET /recommendations`. | Show `Simulator-backed demo data`, site, area, work order, affected asset, active detections, and pending recommendations. Use "local demo" and "human-reviewed decision support." | Do not make the app feel like a disconnected mockup. Avoid implying real plant connectivity, production monitoring, or authentication coverage. | Reviewer can identify Greenville Demo Site, Line 2, `WO-DEMO-1007`, `filler_f_201`, one active detection, and one pending recommendation. |
+| 1 | Overview dashboard | Understand the demo context before opening a finding. | `AppShell`, `ApiConnectionBanner`, overview cards, primary detection CTA, `LoadingState`, `ErrorState`, `EmptyState`. | `GET /health`, `GET /sites`, `GET /areas`, `GET /equipment`, `GET /batches`, `GET /sentinel/detections`, `GET /recommendations`. | Show local demo context, site, area, work order, affected asset, active detections, and pending recommendations. Use "local demo" and "human-reviewed decision support." | Do not make the app feel like a disconnected mockup. Avoid implying real plant connectivity, production monitoring, or authentication coverage. | Reviewer can identify Greenville Demo Site, Line 2, `WO-DEMO-1007`, `filler_f_201`, one active detection, and one pending recommendation. |
| 2 | Detection list | See the Process Sentinel finding in a short queue. | `DetectionCard`, status/severity badges, detection route link, loading/empty/error states. | `GET /sentinel/detections`. | Use "Process Sentinel detections from the local demo run" and keep severity/confidence readable. | Avoid raw JSON, alarm-wall density, or production alert-management language. | Reviewer can open `det_fill_weight_gradual_drift` from the list and see it is medium severity. |
-| 3 | Detection detail | Understand what Process Sentinel flagged and where it applies. | Detection detail page, `DemoDataBadge`, context fields, recommendation link, RCA/CAPA draft link. | `GET /sentinel/detections/{detection_id}`. | Use advisory language such as "Why this was flagged" and avoid saying the finding proves root cause. | Avoid root-cause certainty, quality disposition claims, or language that implies the system is closing an investigation. | Reviewer can explain the finding, affected work order, time window, related assets, confidence, and status. |
+| 3 | Detection detail | Understand what Process Sentinel flagged and where it applies. | Detection detail page, context fields, recommendation link, RCA/CAPA draft link. | `GET /sentinel/detections/{detection_id}`. | Use advisory language such as "Why this was flagged" and avoid saying the finding proves root cause. | Avoid root-cause certainty, quality disposition claims, or language that implies the system is closing an investigation. | Reviewer can explain the finding, affected work order, time window, related assets, confidence, and status. |
| 4 | Evidence timeline | Judge whether the finding is backed by traceable evidence. | `EvidenceTimeline`, evidence cards, source event IDs, related asset/batch/work-order fields. | `GET /sentinel/detections/{detection_id}/evidence`. | Use "Simulator-backed evidence" and "traceable demo events." Explain baseline versus recent fill weight and matching quality-result movement. | Avoid hiding the evidence behind a generic insight. Avoid presenting synthetic evidence as production batch-record evidence. | Reviewer can identify the process signal evidence, quality result evidence, source event IDs, `BATCH-DEMO-1007`, and `WO-DEMO-1007`. |
| 5 | Governed recommendation review | See the proposed action and the required human approval gate. | `RecommendationPanel`, `DecisionForm`, reviewer input, reason input, approve/reject/defer controls. | `GET /recommendations`, `GET /recommendations/{recommendation_id}`, recommendation decision POST endpoints. | Use "Recommendations are advisory decision support" and "A human reviewer must approve, reject, or defer." | Avoid autonomous action language. Avoid implying approval changes machine settings, releases product, or submits records externally. | Reviewer can see the recommendation rationale, linked evidence IDs, risk level, required approval flag, and enabled human decision controls. |
| 6 | Decision feedback | Confirm who reviewed the recommendation, what decision was made, and why. | `DecisionResultCard`, status badge, refreshed recommendation state. | Decision POST response, `GET /recommendations/{recommendation_id}`, future reads from `GET /recommendations/{recommendation_id}/decisions` and `GET /recommendations/{recommendation_id}/audit`. | Use "Demo audit feedback" and "local demo audit trail." State it is not a validated production audit record or electronic signature. | Avoid presenting the local decision as an enterprise audit trail, e-signature, QMS update, or CAPA approval. | Reviewer can see reviewer, decision, reason, timestamp, recommendation ID, and updated status. |
diff --git a/docs/demo/OPERATIONS_WORKBENCH_DEMO_RUNBOOK.md b/docs/demo/OPERATIONS_WORKBENCH_DEMO_RUNBOOK.md
index bbd38b8..5283833 100644
--- a/docs/demo/OPERATIONS_WORKBENCH_DEMO_RUNBOOK.md
+++ b/docs/demo/OPERATIONS_WORKBENCH_DEMO_RUNBOOK.md
@@ -7,10 +7,10 @@ Process Sentinel demo. It covers the local browser flow that lets a reviewer see
factory context, inspect a detection, read the evidence timeline, review a
governed recommendation, record a human decision, and preview an RCA/CAPA draft.
-All content is simulator-backed demo data. The visible Workbench label is
-`Simulator-backed demo data`, with supporting copy that this is a synthetic
-local scenario and not real plant data. It is not production data, a validated
-audit record, an electronic signature, or an industrial writeback workflow.
+All content is simulator-backed demo data. The Workbench communicates that
+through local API context, demo state language, and workflow safety copy rather
+than a persistent demo-data card. It is not production data, a validated audit
+record, an electronic signature, or an industrial writeback workflow.
## Start The Demo
@@ -41,8 +41,7 @@ http://127.0.0.1:3000
## Browser Flow
-1. Open the overview page and confirm it is labeled `Simulator-backed demo
- data`.
+1. Open the overview page and confirm it shows current local demo context.
2. Confirm the overview shows the configured API target, `/health` status, and
simulator-backed demo API source.
3. Confirm the overview shows Greenville Demo Site, current factory context,
diff --git a/docs/demo/OPERATIONS_WORKBENCH_INFORMATION_ARCHITECTURE.md b/docs/demo/OPERATIONS_WORKBENCH_INFORMATION_ARCHITECTURE.md
index 2cccd7c..6349a70 100644
--- a/docs/demo/OPERATIONS_WORKBENCH_INFORMATION_ARCHITECTURE.md
+++ b/docs/demo/OPERATIONS_WORKBENCH_INFORMATION_ARCHITECTURE.md
@@ -68,7 +68,7 @@ search, or enterprise settings.
| Page | Purpose | Primary Action | Secondary Actions | Required API/Data | Empty State | Loading State | Error State | Demo Copy | Accessibility Considerations |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
-| Overview | Establish the local demo context and point to the most important finding. | Open the primary detection. | Scan active detections, pending recommendations, site, area, work order, product, and asset context. | `GET /health`, `GET /sites`, `GET /areas`, `GET /equipment`, `GET /batches`, `GET /sentinel/detections`, `GET /recommendations`. | Explain that no active demo detection is available and direct the user to rerun `make demo`. | Use `LoadingState` with readable "Loading demo overview" language. | Use `ApiConnectionBanner` or `ErrorState` with local API recovery guidance. | Use "Simulator-backed demo data" and "Current demo context." | Keep the primary CTA keyboard reachable; expose status counts as text, not only color. |
+| Overview | Establish the local demo context and point to the most important finding. | Open the primary detection. | Scan active detections, pending recommendations, site, area, work order, product, and asset context. | `GET /health`, `GET /sites`, `GET /areas`, `GET /equipment`, `GET /batches`, `GET /sentinel/detections`, `GET /recommendations`. | Explain that no active demo detection is available and direct the user to rerun `make demo`. | Use `LoadingState` with readable "Loading demo overview" language. | Use `ApiConnectionBanner` or `ErrorState` with local API recovery guidance. | Use "Current demo context" and local demo recovery language. | Keep the primary CTA keyboard reachable; expose status counts as text, not only color. |
| Detections | Present a short Process Sentinel queue for the demo run. | Open a detection detail page. | Compare severity, confidence, status, time window, work order, and related assets. | `GET /sentinel/detections`. | State that no detections were returned for the current local demo state. | Use `LoadingState` for the detections list. | Explain that detections could not be loaded from the local API. | Use "Process Sentinel detections from the local demo run." | Detection cards need semantic links and readable badge text. |
| Detection Detail | Explain the selected finding and keep evidence close to the finding. | Review the embedded Evidence Timeline. | Navigate to Recommendation Review or RCA/CAPA Draft for the same detection. | `GET /sentinel/detections/{detection_id}`, `GET /sentinel/detections/{detection_id}/evidence`. | If evidence is empty, state that no evidence is available for the selected detection. | Use route-level `LoadingState` for detection detail. | Show detection-not-found or API connection recovery guidance. | Use "Why this was flagged" and "Simulator-backed evidence." Avoid root-cause certainty. | Keep heading order clear; evidence rows need readable labels for score, assets, batches, work orders, and source event IDs. |
| Evidence Timeline | Show traceable evidence for the detection without adding a separate route. | Read process and quality evidence in chronological order. | Use source event IDs and context fields to explain traceability. | `GET /sentinel/detections/{detection_id}/evidence`. | Keep the empty evidence message inside Detection Detail. | Covered by Detection Detail loading state. | Covered by Detection Detail error state. | Use "traceable demo events" and "baseline versus recent fill weight." | Timeline order should be conveyed by text and document order, not only visual styling. |
diff --git a/services/simulator/tests/test_demo_safe_copy_guidelines_docs.py b/services/simulator/tests/test_demo_safe_copy_guidelines_docs.py
index 6023f55..e9734e5 100644
--- a/services/simulator/tests/test_demo_safe_copy_guidelines_docs.py
+++ b/services/simulator/tests/test_demo_safe_copy_guidelines_docs.py
@@ -113,7 +113,7 @@ def test_current_workbench_copy_keeps_demo_safe_terms_visible() -> None:
app_copy = "\n".join(path.read_text(encoding="utf-8") for path in app_files)
required_safe_copy = [
- "Simulator-backed demo data",
+ "simulator-backed demo",
"API connection issue",
"Why this was flagged",
"Simulator-backed evidence",
diff --git a/services/simulator/tests/test_manufacturer_demo_user_journey_docs.py b/services/simulator/tests/test_manufacturer_demo_user_journey_docs.py
index e79bb84..a01a0f6 100644
--- a/services/simulator/tests/test_manufacturer_demo_user_journey_docs.py
+++ b/services/simulator/tests/test_manufacturer_demo_user_journey_docs.py
@@ -47,7 +47,6 @@ def test_manufacturer_demo_user_journey_maps_goal_component_data_risk_and_succes
"Risks or Confusion To Avoid",
"Demo Success Criteria",
"AppShell",
- "DemoDataBadge",
"ApiConnectionBanner",
"DetectionCard",
"EvidenceTimeline",