diff --git a/apps/web/app/components/demo-state.tsx b/apps/web/app/components/demo-state.tsx index 61dbb3c..77c058c 100644 --- a/apps/web/app/components/demo-state.tsx +++ b/apps/web/app/components/demo-state.tsx @@ -13,10 +13,6 @@ type ApiConnectionBannerProps = { health?: HealthResponse; }; -type DemoDataBadgeProps = { - label?: string; -}; - type EmptyStateProps = { nextStep?: string; title: string; @@ -102,19 +98,6 @@ export function ApiErrorPanel({ ); } -export function DemoDataBadge({ - label = "Simulator-backed demo data", -}: DemoDataBadgeProps) { - return ( - - {label} - - Synthetic local scenario; not real plant data. - - - ); -} - export function EmptyState({ nextStep, title, text }: EmptyStateProps) { return (
@@ -135,10 +118,9 @@ export function MissingDataPanel({ nextStep, text, title }: MissingDataPanelProp ); } -export function LoadingState({ title = "Loading simulator-backed demo data" }: LoadingStateProps) { +export function LoadingState({ title = "Loading local demo data" }: LoadingStateProps) { return (
-
{title} Connecting to the local FastAPI demo backend. diff --git a/apps/web/app/connections/connections-workspace.tsx b/apps/web/app/connections/connections-workspace.tsx index 3910f79..1959bbd 100644 --- a/apps/web/app/connections/connections-workspace.tsx +++ b/apps/web/app/connections/connections-workspace.tsx @@ -178,8 +178,11 @@ export function ConnectionsWorkspace({ return (
-

Connections

-
+
+
+ Protocol Operations +

Connections

+
-
+
diff --git a/apps/web/app/connections/page.tsx b/apps/web/app/connections/page.tsx index 732ab47..41f0f4a 100644 --- a/apps/web/app/connections/page.tsx +++ b/apps/web/app/connections/page.tsx @@ -12,7 +12,7 @@ export default async function ConnectionsPage() { const result = await loadConnectionProfiles(); return ( -
+
{!result.ok ? : null} {result.ok ? : null}
diff --git a/apps/web/app/detections/[detectionId]/page.tsx b/apps/web/app/detections/[detectionId]/page.tsx index e767375..e9dc571 100644 --- a/apps/web/app/detections/[detectionId]/page.tsx +++ b/apps/web/app/detections/[detectionId]/page.tsx @@ -28,7 +28,6 @@ export default async function DetectionDetailPage({ params }: DetectionDetailPag return (
- Simulator-backed demo data
Back to detections diff --git a/apps/web/app/detections/page.tsx b/apps/web/app/detections/page.tsx index 41c669a..a292b5a 100644 --- a/apps/web/app/detections/page.tsx +++ b/apps/web/app/detections/page.tsx @@ -15,7 +15,6 @@ export default async function DetectionsPage() { return (
- Simulator-backed demo data

Detections

diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 241af5e..2fcc2ea 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -282,29 +282,6 @@ a { padding: 7px 8px; } -.demo-notice { - display: inline-grid; - width: fit-content; - gap: 4px; - border: 1px solid #f5d38d; - border-radius: 7px; - background: var(--warning-bg); - color: var(--warning); - line-height: 1.2; - padding: 7px 9px; -} - -.demo-notice-label { - font-size: 0.78rem; - font-weight: 760; -} - -.demo-notice-copy { - color: #7a4f0f; - font-size: 0.72rem; - font-weight: 650; -} - .status-badge { display: inline-flex; width: fit-content; @@ -421,8 +398,9 @@ h3 { .status-label { color: var(--muted); font-size: 0.78rem; + font-variant-caps: all-small-caps; font-weight: 760; - text-transform: uppercase; + text-transform: none; } .status-value { @@ -1084,11 +1062,169 @@ h3 { gap: 18px; } +.protocol-operations-page { + display: grid; + gap: 22px; +} + +.protocol-diagnostics-workspace { + display: grid; + gap: 22px; +} + +.protocol-diagnostics-page { + align-content: start; +} + +.page-heading, +.section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.page-heading h1, +.section-heading h2 { + margin-bottom: 0; +} + +.page-heading h1 { + font-size: clamp(2rem, 3vw, 3.1rem); +} + +.protocol-operations-page .page-heading { + min-height: 58px; + align-items: center; + border-bottom: 1px solid var(--border); + padding-bottom: 18px; +} + +.diagnostics-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +} + +.diagnostics-card { + display: grid; + min-width: 0; + gap: 12px; + align-content: start; + border: 1px solid var(--border); + border-top: 4px solid var(--accent); + border-radius: 8px; + background: var(--surface); + padding: 16px; +} + +.diagnostics-card-heading { + display: grid; + gap: 8px; +} + +.diagnostics-card h2 { + margin-bottom: 0; + font-size: 1rem; +} + +.diagnostics-card p { + margin-bottom: 0; + color: var(--muted); + font-size: 0.9rem; + line-height: 1.45; +} + +.diagnostics-card-meta { + color: var(--muted-2); + font-size: 0.8rem; + font-weight: 650; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.diagnostics-table td span { + display: block; + margin-top: 4px; + color: var(--muted); + font-size: 0.84rem; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.diagnostics-table th, +.diagnostics-table td { + white-space: nowrap; +} + +.diagnostics-table th:first-child, +.diagnostics-table td:first-child, +.diagnostics-table th:last-child, +.diagnostics-table td:last-child { + white-space: normal; +} + +.protocol-operations-page .table-wrap { + box-shadow: var(--shadow); +} + +.protocol-diagnostics-page .table-wrap { + max-height: 520px; + overflow: auto; +} + +.protocol-diagnostics-page .connection-table th { + position: sticky; + z-index: 1; + top: 0; +} + +.search-field { + display: grid; + min-width: min(100%, 320px); + gap: 6px; + color: var(--muted); + font-size: 0.78rem; + font-weight: 760; + text-transform: uppercase; +} + +.search-field input { + width: 100%; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--surface); + color: var(--text); + font: inherit; + line-height: 1.5; + padding: 11px 12px; +} + +.search-field input:focus { + border-color: var(--accent); + outline: 3px solid var(--focus-ring); + outline-offset: 1px; +} + +.troubleshooting-panel { + border-left: 4px solid var(--accent); +} + +.command-list { + display: grid; + gap: 8px; + margin: 8px 0 0; + padding-left: 18px; +} + +.command-list code { + overflow-wrap: anywhere; +} + .connections-title { margin-bottom: 0; } -.connections-actions, .modal-header { display: flex; align-items: flex-start; @@ -1096,10 +1232,6 @@ h3 { gap: 16px; } -.connections-actions { - justify-content: flex-end; -} - .icon-action { display: inline-flex; width: 42px; @@ -1402,6 +1534,14 @@ code { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .diagnostics-card-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .section-heading { + display: grid; + } + .workflow-links { grid-template-columns: 1fr; } @@ -1445,6 +1585,10 @@ code { .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .diagnostics-card-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } } @media (max-width: 560px) { @@ -1473,6 +1617,10 @@ code { grid-template-columns: 1fr; } + .diagnostics-card-grid { + grid-template-columns: 1fr; + } + .api-connection-details { grid-template-columns: 1fr; } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index bbb618c..e55bdea 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -2,7 +2,6 @@ import type { Metadata } from "next"; import Link from "next/link"; import type { ReactNode } from "react"; -import { DemoDataBadge } from "./components/demo-state"; import { getApiBaseUrl } from "../lib/api-client"; import "./globals.css"; @@ -33,7 +32,7 @@ const navGroups: NavGroup[] = [ { items: [ { href: "/connections", label: "Connections" }, - { label: "Protocol Diagnostics", status: "Planned" }, + { href: "/protocol-diagnostics", label: "Protocol Diagnostics" }, { label: "Tag/Source Browser", status: "Planned" }, ], label: "Protocol operations", @@ -54,7 +53,6 @@ export default function RootLayout({ children }: { children: ReactNode }) { Factory Intelligence Platform Operator Console -

+ + + + + + + + + + + + {profiles.length === 0 ? ( + + + + ) : ( + profiles.map((profile) => { + const testLoad = testResultByConnection.get(profile.id); + const result = testLoad?.result ?? null; + return ( + + + + + + + + + ); + }) + )} + +
ConnectionProtocolConfigured statusLast test resultLast checkedError or next step
+ No configured connections were returned by the FIP API. +
+ {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

+
+ +
+
+ + + + + + + + + + + + + {visibleRows.length === 0 ? ( + + + + ) : ( + visibleRows.map((row) => ( + + + + + + + + + )) + )} + +
Source/tagProtocolConnectionMapping referenceFIP signalUnit
+ {mappingRows.length === 0 + ? "No protocol source mappings are available yet." + : "No mappings match the current search."} +
{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",