From 3013afbf6678148d1c9095330c41448b3e569353 Mon Sep 17 00:00:00 2001 From: Aldon Smith Date: Sun, 24 May 2026 09:13:16 -0400 Subject: [PATCH 1/4] feat: add protocol connections workbench page --- apps/web/README.md | 13 +- .../app/connections/connections-workspace.tsx | 701 ++++++++++++++++++ apps/web/app/connections/page.tsx | 52 ++ apps/web/app/globals.css | 173 +++++ apps/web/app/layout.tsx | 13 +- .../web/e2e/operations-workbench-demo.spec.ts | 32 + apps/web/lib/api-client.ts | 94 +++ apps/web/tests/api-client.test.mjs | 7 + apps/web/tests/app-shell.test.mjs | 36 + docs/LEARNING_LOG.md | 66 ++ 10 files changed, 1182 insertions(+), 5 deletions(-) create mode 100644 apps/web/app/connections/connections-workspace.tsx create mode 100644 apps/web/app/connections/page.tsx diff --git a/apps/web/README.md b/apps/web/README.md index 35ed52d..a1e2a46 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -49,6 +49,9 @@ running the API on different local web origins. The API client currently covers: - `GET /health` +- `GET /connection-profiles` +- `POST /connection-profiles` +- `POST /connection-profiles/{profile_id}/test` - `GET /sites` - `GET /areas` - `GET /equipment` @@ -79,12 +82,16 @@ is running on a non-default port. The shell uses a persistent sidebar with an embedded status strip instead of a horizontal top bar. Existing Sentinel demo routes remain available, and the -sidebar includes planned navigation slots for Connections, Protocol Diagnostics, -and Tag/Source Browser. Those planned slots do not add production writeback -controls. +sidebar includes a Connections page plus planned navigation slots for Protocol +Diagnostics and Tag/Source Browser. Those surfaces do not add production +writeback controls. - `/` - Factory overview dashboard with site, line, asset, work order, product, active detection count, pending recommendation count, and primary detection CTA +- `/connections` - Read-only OPC-UA, MQTT, and BACnet connection profile setup + with protocol-specific controls, reference-only credential fields, disabled + profile support, local validation feedback, current health state, and + test-connection diagnostics that do not start ingestion or industrial writeback - `/detections` - Process Sentinel detection list with summary, severity, confidence, status, time window, work order, related assets, and detail links - `/detections/{detection_id}` - Read-only Process Sentinel detection detail diff --git a/apps/web/app/connections/connections-workspace.tsx b/apps/web/app/connections/connections-workspace.tsx new file mode 100644 index 0000000..d26987a --- /dev/null +++ b/apps/web/app/connections/connections-workspace.tsx @@ -0,0 +1,701 @@ +"use client"; + +import { type FormEvent, useState } from "react"; + +import { StatusBadge } from "../components/demo-state"; +import { + type ConnectionTestResult, + type Protocol, + type ProtocolConnectionProfile, + formatApiError, + workbenchApi, +} from "../../lib/api-client"; + +type ConnectionsWorkspaceProps = { + initialProfiles: ProtocolConnectionProfile[]; +}; + +type FormState = { + acquisitionSeconds: string; + bacnetDeviceInstance: string; + bacnetNetworkNumber: string; + bacnetObjectReferences: string; + description: string; + enabled: boolean; + endpoint: string; + id: string; + mappingReference: string; + mqttClientId: string; + mqttQos: "0" | "1" | "2"; + mqttTopicFilters: string; + mqttUseTls: boolean; + name: string; + opcuaNamespaceUris: string; + opcuaNodeIds: string; + opcuaSecurityMode: "none" | "sign" | "sign_and_encrypt"; + opcuaSecurityPolicy: string; + protocol: Protocol; + secretReference: string; +}; + +type ValidationError = { + field: string; + message: string; +}; + +const defaultFormState: FormState = { + acquisitionSeconds: "30", + bacnetDeviceInstance: "1001", + bacnetNetworkNumber: "", + bacnetObjectReferences: "analogInput:1", + description: "", + enabled: false, + endpoint: "opc.tcp://127.0.0.1:4840/ofi", + id: "demo-opcua-connection", + mappingReference: "mapping://demo/opcua-fill-line", + mqttClientId: "fip-workbench-demo", + mqttQos: "0", + mqttTopicFilters: "spBv1.0/OFI/DDATA/Line1/#", + mqttUseTls: true, + name: "Demo OPC-UA connection", + opcuaNamespaceUris: "urn:open-factory:demo", + opcuaNodeIds: "ns=2;s=Line1.FillWeight", + opcuaSecurityMode: "none", + opcuaSecurityPolicy: "", + protocol: "opcua", + secretReference: "", +}; + +export function ConnectionsWorkspace({ + initialProfiles, +}: ConnectionsWorkspaceProps) { + const [form, setForm] = useState(defaultFormState); + const [profiles, setProfiles] = + useState(initialProfiles); + const [validationErrors, setValidationErrors] = useState([]); + const [submitError, setSubmitError] = useState(null); + const [submitMessage, setSubmitMessage] = useState(null); + const [testResults, setTestResults] = useState>({}); + const [testingId, setTestingId] = useState(null); + const [saving, setSaving] = useState(false); + + async function createProfile(event: FormEvent) { + event.preventDefault(); + setSubmitError(null); + setSubmitMessage(null); + + const built = buildProfile(form); + if (!built.ok) { + setValidationErrors(built.errors); + return; + } + + setValidationErrors([]); + setSaving(true); + try { + const createdProfile = await workbenchApi.createConnectionProfile(built.profile); + setProfiles((current) => upsertProfile(current, createdProfile)); + setSubmitMessage( + `Created ${formatProtocol(createdProfile.protocol)} profile ${createdProfile.id}.`, + ); + } catch (error) { + setSubmitError(formatApiError(error)); + } finally { + setSaving(false); + } + } + + async function testProfile(profileId: string) { + setTestingId(profileId); + setSubmitError(null); + try { + const result = await workbenchApi.testConnectionProfile(profileId); + setTestResults((current) => ({ ...current, [profileId]: result })); + } catch (error) { + setSubmitError(formatApiError(error)); + } finally { + setTestingId(null); + } + } + + function updateField(key: Key, value: FormState[Key]) { + setForm((current) => ({ ...current, [key]: value })); + } + + function updateProtocol(protocol: Protocol) { + setForm((current) => ({ + ...current, + endpoint: defaultEndpoint(protocol), + id: `demo-${protocol}-connection`, + mappingReference: `mapping://demo/${protocol}-source`, + name: `Demo ${formatProtocol(protocol)} connection`, + protocol, + })); + } + + return ( +
+
+
+ Profile definition +

Define connection

+

+ Use reference IDs for credentials and certificates. The Workbench + does not ask for raw passwords, tokens, private keys, or certificate + bodies. +

+
+
+ Protocol + {(["opcua", "mqtt", "bacnet"] as const).map((protocol) => ( + + ))} +
+
+ + + + + + +
+