diff --git a/apps/web/README.md b/apps/web/README.md index 35ed52d..05618e3 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -49,6 +49,10 @@ running the API on different local web origins. The API client currently covers: - `GET /health` +- `GET /connection-profiles` +- `POST /connection-profiles` +- `PUT /connection-profiles/{profile_id}` +- `POST /connection-profiles/{profile_id}/test` - `GET /sites` - `GET /areas` - `GET /equipment` @@ -79,12 +83,18 @@ 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` - OPC-UA, MQTT, and BACnet connections page with a title, + top-right add button, table, modal profile definition form, display-name link + to reopen the modal with prefilled values, 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..3910f79 --- /dev/null +++ b/apps/web/app/connections/connections-workspace.tsx @@ -0,0 +1,858 @@ +"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 ModalMode = "create" | "edit"; + +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); + const [modalMode, setModalMode] = useState(null); + const [editingProfile, setEditingProfile] = + useState(null); + + const modalOpen = modalMode !== null; + + function openCreateModal() { + setForm(defaultFormState); + setEditingProfile(null); + setValidationErrors([]); + setSubmitError(null); + setSubmitMessage(null); + setModalMode("create"); + } + + function openEditModal(profile: ProtocolConnectionProfile) { + setForm(profileToFormState(profile)); + setEditingProfile(profile); + setValidationErrors([]); + setSubmitError(null); + setSubmitMessage(null); + setModalMode("edit"); + } + + function closeModal() { + if (saving) { + return; + } + setModalMode(null); + setEditingProfile(null); + setValidationErrors([]); + setSubmitError(null); + } + + async function saveProfile(event: FormEvent) { + event.preventDefault(); + setSubmitError(null); + setSubmitMessage(null); + + const built = buildProfile(form, editingProfile); + if (!built.ok) { + setValidationErrors(built.errors); + return; + } + + setValidationErrors([]); + setSaving(true); + try { + const savedProfile = + modalMode === "edit" + ? await workbenchApi.updateConnectionProfile(built.profile) + : await workbenchApi.createConnectionProfile(built.profile); + setProfiles((current) => upsertProfile(current, savedProfile)); + setSubmitMessage( + `${modalMode === "edit" ? "Updated" : "Created"} ${formatProtocol( + savedProfile.protocol, + )} profile ${savedProfile.id}.`, + ); + setEditingProfile(savedProfile); + setForm(profileToFormState(savedProfile)); + setModalMode("edit"); + } 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 ( +
+

Connections

+
+ +
+
+
+ + + + + + + + + + + + {profiles.length === 0 ? ( + + + + ) : ( + profiles.map((profile) => { + const testResult = testResults[profile.id]; + return ( + + + + + + + + ); + }) + )} + +
Connection display nameConnection statusProtocolLast test resultRead-only test
+ No connections configured. +
+ { + event.preventDefault(); + openEditModal(profile); + }} + > + {profile.name} + + + + {formatProtocol(profile.protocol)}{testResult ? testResult.status : "Not run"} + +
+
+
+
+ {submitMessage ?? "Connection profile actions are ready."} +
+ {submitError !== null ? ( +
+ Connection profile action failed + {submitError} +
+ ) : null} + {modalOpen ? ( + + ) : null} +
+ ); +} + +function ProfileModal({ + editingProfile, + form, + mode, + onClose, + onSubmit, + saving, + submitError, + submitMessage, + updateField, + updateProtocol, + validationErrors, +}: { + editingProfile: ProtocolConnectionProfile | null; + form: FormState; + mode: ModalMode | null; + onClose: () => void; + onSubmit: (event: FormEvent) => void; + saving: boolean; + submitError: string | null; + submitMessage: string | null; + updateField: (key: Key, value: FormState[Key]) => void; + updateProtocol: (protocol: Protocol) => void; + validationErrors: ValidationError[]; +}) { + const title = + mode === "edit" + ? `Profile definition: ${editingProfile?.name ?? form.name}` + : "Profile definition"; + + return ( +
+
+
+
+ + {mode === "edit" ? "Configured connection" : "New connection"} + +

{title}

+
+ +
+
+

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

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