From c1d6cf59a37e8f90a1cddfa37a77d189790e3920 Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Wed, 3 Jun 2026 17:49:13 +0100 Subject: [PATCH 1/7] Add ISA-95 vocabulary app and ISA-95 classes --- acs-service-setup/dumps/isa95.yaml | 53 +++++++++ lib/js-service-client/lib/uuids.js | 179 +++++++++++++++-------------- 2 files changed, 146 insertions(+), 86 deletions(-) create mode 100644 acs-service-setup/dumps/isa95.yaml diff --git a/acs-service-setup/dumps/isa95.yaml b/acs-service-setup/dumps/isa95.yaml new file mode 100644 index 000000000..6907a841d --- /dev/null +++ b/acs-service-setup/dumps/isa95.yaml @@ -0,0 +1,53 @@ +service: !u UUIDs.Service.ConfigDB +version: 2 +objects: + !u ConfigDB.Class.R2Class: + !u UUIDs.Class.ISA95Level: + name: "ISA-95 hierarchy level" + + !u UUIDs.Class.ISA95Level: + !u UUIDs.Class.ISA95Enterprise: + name: "ISA-95 Enterprise" + !u UUIDs.Class.ISA95Site: + name: "ISA-95 Site" + !u UUIDs.Class.ISA95Area: + name: "ISA-95 Area" + !u UUIDs.Class.ISA95WorkCenter: + name: "ISA-95 Work Center" + !u UUIDs.Class.ISA95WorkUnit: + name: "ISA-95 Work Unit" + + !u UUIDs.Class.App: + !u UUIDs.App.ISA95Vocabulary: + name: "ISA-95 vocabulary" + subclassOf: + - !u MetaDB.Class.ConfigEntry + +configs: + !u UUIDs.App.ConfigSchema: + !u UUIDs.App.ISA95Vocabulary: + type: object + properties: + aliases: + type: array + items: + type: string + description: "Alternative labels that resolve to this node's canonical name" + children: + type: array + items: + type: string + format: uuid + description: "UUIDs of the next-level ISA-95 nodes under this node" + additionalProperties: false +--- +service: !u UUIDs.Service.Authentication +version: 2 +grants: + # Any authenticated principal can read the vocabulary (needed by the + # Manager UI to populate ISA-95 dropdowns for device configurers). + # Administrators already have full ConfigDB access via ACS.PermGroup.ConfigDB, + # which covers WriteConfig and ManageObjects on ISA-95 objects. + !u Auth.Class.Principal: + !u UUIDs.Permission.ConfigDB.ReadConfig: + !u UUIDs.App.ISA95Vocabulary: false diff --git a/lib/js-service-client/lib/uuids.js b/lib/js-service-client/lib/uuids.js index 14fe21718..263fc0ea4 100644 --- a/lib/js-service-client/lib/uuids.js +++ b/lib/js-service-client/lib/uuids.js @@ -3,42 +3,48 @@ */ export const Class = { - Individual: "2494ae9b-cd87-4c01-98db-437a303b43e9", - R1Class: "04a1c90d-2295-4cbe-b33a-74eded62cbf1", - Class: "04a1c90d-2295-4cbe-b33a-74eded62cbf1", - R2Class: "705888ce-53fa-434d-afee-274b331d4642", - R3Class: "52b80183-6998-4bf9-9b30-132755e7dede", + Individual: "2494ae9b-cd87-4c01-98db-437a303b43e9", + R1Class: "04a1c90d-2295-4cbe-b33a-74eded62cbf1", + Class: "04a1c90d-2295-4cbe-b33a-74eded62cbf1", + R2Class: "705888ce-53fa-434d-afee-274b331d4642", + R3Class: "52b80183-6998-4bf9-9b30-132755e7dede", - App: "d319bd87-f42b-4b66-be4f-f82ff48b93f0", - Device: "18773d6d-a70d-443a-b29a-3f1583195290", - EdgeAgent: "00da3c0b-f62b-4761-a689-39ad0c33f864", - EdgeAgentConnection: "f371cbfc-de3e-11ef-b361-7b31622bc42f", - EdgeAgentDriver: "3874e06c-de4a-11ef-a68c-0f1c2d4d555c", - EdgeCluster: "f24d354d-abc1-4e32-98e1-0667b3e40b61", - EdgeDeployment: "e6f6a6e6-f6b2-422a-bc86-2dcb417a362a", - Bridge: "178506d2-af52-4c79-8e5d-4958ae7ddfa9", - File: 'b8fae5ab-678e-4d35-802f-2dd8ee3b5b02', - FileType: "923801b3-c97a-48d8-8af9-da97dc13fe2d", - GitRepo: "d25f2afc-1ab8-4d27-b51b-d02314624e3e", - GitRepoGroup: "b03d4dfe-7e78-4252-8e62-af594cf316c9", - HelmChart: "f9be0334-0ff7-43d3-9d8a-188d3e4d472b", - PermGroup: "ac0d5288-6136-4ced-a372-325fbbcdd70d", - Permission: "8ae784bb-c4b5-4995-9bf6-799b3c7f21ad", - Principal: "11614546-b6d7-11ef-aebd-8fbb45451d7c", - Private: "eda329ca-4e55-4a92-812d-df74993c47e2", - Requirement: "b419cbc2-ab0f-4311-bd9e-f0591f7e88cb", - Schema: "83ee28d4-023e-4c2c-ab86-12c24e86372c", - Service: "265d481f-87a7-4f93-8fc6-53fa64dc11bb", - Special: "ddb132e4-5cdd-49c8-b9b1-2f35879eab6d", - SystemHelmChart: "a5c54a2e-8f7b-4b2a-9e8d-3f7c9e7d1c6a", + App: "d319bd87-f42b-4b66-be4f-f82ff48b93f0", + Device: "18773d6d-a70d-443a-b29a-3f1583195290", + ISA95Level: "3866b165-6b31-46e3-a801-cb367e04e726", + ISA95Enterprise: "2980293e-4a82-49a3-b121-9a8fa109d0a8", + ISA95Site: "70a948a7-7d99-400c-896a-ae63ab4362e0", + ISA95Area: "d0ac7371-41d6-45b2-8a42-781f8d4c8d54", + ISA95WorkCenter: "b0633e7c-e0c1-4350-9f86-72b83e3e9cb0", + ISA95WorkUnit: "00074ce8-2142-40a6-aff3-1057a83ddad2", + EdgeAgent: "00da3c0b-f62b-4761-a689-39ad0c33f864", + EdgeAgentConnection: "f371cbfc-de3e-11ef-b361-7b31622bc42f", + EdgeAgentDriver: "3874e06c-de4a-11ef-a68c-0f1c2d4d555c", + EdgeCluster: "f24d354d-abc1-4e32-98e1-0667b3e40b61", + EdgeDeployment: "e6f6a6e6-f6b2-422a-bc86-2dcb417a362a", + Bridge: "178506d2-af52-4c79-8e5d-4958ae7ddfa9", + File: 'b8fae5ab-678e-4d35-802f-2dd8ee3b5b02', + FileType: "923801b3-c97a-48d8-8af9-da97dc13fe2d", + GitRepo: "d25f2afc-1ab8-4d27-b51b-d02314624e3e", + GitRepoGroup: "b03d4dfe-7e78-4252-8e62-af594cf316c9", + HelmChart: "f9be0334-0ff7-43d3-9d8a-188d3e4d472b", + PermGroup: "ac0d5288-6136-4ced-a372-325fbbcdd70d", + Permission: "8ae784bb-c4b5-4995-9bf6-799b3c7f21ad", + Principal: "11614546-b6d7-11ef-aebd-8fbb45451d7c", + Private: "eda329ca-4e55-4a92-812d-df74993c47e2", + Requirement: "b419cbc2-ab0f-4311-bd9e-f0591f7e88cb", + Schema: "83ee28d4-023e-4c2c-ab86-12c24e86372c", + Service: "265d481f-87a7-4f93-8fc6-53fa64dc11bb", + Special: "ddb132e4-5cdd-49c8-b9b1-2f35879eab6d", + SystemHelmChart: "a5c54a2e-8f7b-4b2a-9e8d-3f7c9e7d1c6a", }; export const Special = { - Null: "00000000-0000-0000-0000-000000000000", - FactoryPlus: "11ad7b32-1d32-4c4a-b0c9-fa049208939a", - Self: "5855a1cc-46d8-4b16-84f8-ab3916ecb230", - Unowned: "091e796a-65c0-4080-adff-c3ce01a65b2e", - Mine: "724c0316-4cfd-11f0-b355-0b70840faae8", + Null: "00000000-0000-0000-0000-000000000000", + FactoryPlus: "11ad7b32-1d32-4c4a-b0c9-fa049208939a", + Self: "5855a1cc-46d8-4b16-84f8-ab3916ecb230", + Unowned: "091e796a-65c0-4080-adff-c3ce01a65b2e", + Mine: "724c0316-4cfd-11f0-b355-0b70840faae8", }; /* back-compat */ @@ -47,72 +53,73 @@ export const FactoryPlus = Special.FactoryPlus; export const App = { - Registration: "cb40bed5-49ad-4443-a7f5-08c75009da8f", - Info: "64a8bfa9-7772-45c4-9d1a-9e6290690957", - SparkplugAddress: "8e32801b-f35a-4cbf-a5c3-2af64d3debd7", - ConfigSchema: "dbd8a535-52ba-4f6e-b4f8-9b71aefe09d3", - GitRepositoryConfig: "38d62a93-b6b4-4f63-bad4-d433e3eaff29", - ServiceConfig: "5b47881c-b012-4040-945c-eacafca539b2", - EdgeAgentConfig: "aac6f843-cfee-4683-b121-6943bfdf9173", - EdgeAgentDeployment: "f2b9417a-ef7f-421f-b387-bb8183a48cdb", - EdgeClusterStatus: "747a62c9-1b66-4a2e-8dd9-0b70a91b6b75", - EdgeClusterSetupStatus: "f6c67e6f-e48e-4f69-b4bb-bfbddcc2a517", - EdgeClusterConfiguration: "bdb13634-0b3d-4e38-a065-9d88c12ee78d", - HelmChartTemplate: "729fe070-5e67-4bc7-94b5-afd75cb42b03", - DeviceInformation: "a98ffed5-c613-4e70-bfd3-efeee250ade5", - DriverDefinition: "454e5bec-de4a-11ef-bfea-4bc400f636a5", - ConnectionConfiguration: "fa8b429c-de3e-11ef-87fd-6382f0eac944", - SchemaInformation: "32093857-9d29-470e-a897-d2b56d5aa978", - Schema: "b16e85fb-53c2-49f9-8d83-cdf6763304ba", - ServiceSetup: "5b47881c-b012-4040-945c-eacafca539b2", - FilesConfig: "731cb924-71bb-49fa-8cb8-1584bd1ebad3", + Registration: "cb40bed5-49ad-4443-a7f5-08c75009da8f", + Info: "64a8bfa9-7772-45c4-9d1a-9e6290690957", + SparkplugAddress: "8e32801b-f35a-4cbf-a5c3-2af64d3debd7", + ConfigSchema: "dbd8a535-52ba-4f6e-b4f8-9b71aefe09d3", + GitRepositoryConfig: "38d62a93-b6b4-4f63-bad4-d433e3eaff29", + ServiceConfig: "5b47881c-b012-4040-945c-eacafca539b2", + EdgeAgentConfig: "aac6f843-cfee-4683-b121-6943bfdf9173", + EdgeAgentDeployment: "f2b9417a-ef7f-421f-b387-bb8183a48cdb", + EdgeClusterStatus: "747a62c9-1b66-4a2e-8dd9-0b70a91b6b75", + EdgeClusterSetupStatus: "f6c67e6f-e48e-4f69-b4bb-bfbddcc2a517", + EdgeClusterConfiguration: "bdb13634-0b3d-4e38-a065-9d88c12ee78d", + HelmChartTemplate: "729fe070-5e67-4bc7-94b5-afd75cb42b03", + DeviceInformation: "a98ffed5-c613-4e70-bfd3-efeee250ade5", + ISA95Vocabulary: "11096075-acc6-4eae-b682-9b27d776384d", + DriverDefinition: "454e5bec-de4a-11ef-bfea-4bc400f636a5", + ConnectionConfiguration: "fa8b429c-de3e-11ef-87fd-6382f0eac944", + SchemaInformation: "32093857-9d29-470e-a897-d2b56d5aa978", + Schema: "b16e85fb-53c2-49f9-8d83-cdf6763304ba", + ServiceSetup: "5b47881c-b012-4040-945c-eacafca539b2", + FilesConfig: "731cb924-71bb-49fa-8cb8-1584bd1ebad3", }; export const Schema = { - Device_Information: "2dd093e9-1450-44c5-be8c-c0d78e48219b", - Service: "05688a03-730e-4cda-9932-172e2c62e45c", + Device_Information: "2dd093e9-1450-44c5-be8c-c0d78e48219b", + Service: "05688a03-730e-4cda-9932-172e2c62e45c", }; export const Service = { - Directory: "af4a1d66-e6f7-43c4-8a67-0fa3be2b1cf9", - ConfigDB: "af15f175-78a0-4e05-97c0-2a0bb82b9f3b", - /* Compatibility. This was a failed attempt to rename the service. */ - Registry: "af15f175-78a0-4e05-97c0-2a0bb82b9f3b", - Auth: "cab2642a-f7d9-42e5-8845-8f35affe1fd4", - /* Compatibility. This is no longer an authentication service. */ - Authentication: "cab2642a-f7d9-42e5-8845-8f35affe1fd4", - Command_Escalation: "78ea7071-24ac-4916-8351-aa3e549d8ccd", - MQTT: "feb27ba3-bd2c-4916-9269-79a61ebc4a47", - Git: "7adf4db0-2e7b-4a68-ab9d-376f4c5ce14b", - Clusters: "2706aa43-a826-441e-9cec-cd3d4ce623c2", - Manager: "619eecab-742d-4824-8b97-bcae472e5c04", - Files: 'a2a6efc5-9793-4486-9fd9-7caf9e3b5451', - DataAccess: "06cee697-29d3-4972-9479-bc392e24946e", - i3x: "018854a1-a312-46d3-b904-c274b59088c7", + Directory: "af4a1d66-e6f7-43c4-8a67-0fa3be2b1cf9", + ConfigDB: "af15f175-78a0-4e05-97c0-2a0bb82b9f3b", + /* Compatibility. This was a failed attempt to rename the service. */ + Registry: "af15f175-78a0-4e05-97c0-2a0bb82b9f3b", + Auth: "cab2642a-f7d9-42e5-8845-8f35affe1fd4", + /* Compatibility. This is no longer an authentication service. */ + Authentication: "cab2642a-f7d9-42e5-8845-8f35affe1fd4", + Command_Escalation: "78ea7071-24ac-4916-8351-aa3e549d8ccd", + MQTT: "feb27ba3-bd2c-4916-9269-79a61ebc4a47", + Git: "7adf4db0-2e7b-4a68-ab9d-376f4c5ce14b", + Clusters: "2706aa43-a826-441e-9cec-cd3d4ce623c2", + Manager: "619eecab-742d-4824-8b97-bcae472e5c04", + Files: 'a2a6efc5-9793-4486-9fd9-7caf9e3b5451', + DataAccess: "06cee697-29d3-4972-9479-bc392e24946e", + i3x: "018854a1-a312-46d3-b904-c274b59088c7", }; /* This list is not meant to be exhaustive, but these are commonly used * well-known permissions. */ export const Permission = { - Auth: { - ReadACL: "ba566181-0e8a-405b-b16e-3fb89130fbee", - ManageKerberos: "327c4cc8-9c46-4e1e-bb6b-257ace37b0f6", - ManageACL: "3a41f5ce-fc08-4669-9762-ec9e71061168", - ManageGroup: "be9b6d47-c845-49b2-b9d5-d87b83f11c3b", - }, - CmdEsc: { - Rebirth: "fbb9c25d-386d-4966-a325-f16471d9f7be", - }, - ConfigDB: { - ReadConfig: "4a339562-cd57-408d-9d1a-6529a383ea4b", - WriteConfig: "6c799ccb-d2ad-4715-a2a7-3c8728d6c0bf", - ManageObjects: "f0b7917b-d475-4888-9d5a-2af96b3c26b6", - }, - Directory: { - AdvertiseService: "4db4c39a-f18d-4e83-aeb0-5af2c14ddc2b", - }, + Auth: { + ReadACL: "ba566181-0e8a-405b-b16e-3fb89130fbee", + ManageKerberos: "327c4cc8-9c46-4e1e-bb6b-257ace37b0f6", + ManageACL: "3a41f5ce-fc08-4669-9762-ec9e71061168", + ManageGroup: "be9b6d47-c845-49b2-b9d5-d87b83f11c3b", + }, + CmdEsc: { + Rebirth: "fbb9c25d-386d-4966-a325-f16471d9f7be", + }, + ConfigDB: { + ReadConfig: "4a339562-cd57-408d-9d1a-6529a383ea4b", + WriteConfig: "6c799ccb-d2ad-4715-a2a7-3c8728d6c0bf", + ManageObjects: "f0b7917b-d475-4888-9d5a-2af96b3c26b6", + }, + Directory: { + AdvertiseService: "4db4c39a-f18d-4e83-aeb0-5af2c14ddc2b", + }, }; export const Group = { - UNS_Ingester: "e03152b8-cdab-11ef-8ee9-77393064507e", + UNS_Ingester: "e03152b8-cdab-11ef-8ee9-77393064507e", }; From 82bfc6c8fe3e2ee6b8543f915bf6a7209ecf8732 Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Thu, 4 Jun 2026 15:59:25 +0100 Subject: [PATCH 2/7] Fetch hierarchy levels into metric values --- .../OriginMapEditor/SparkplugMetric.vue | 68 ++++++++++- acs-admin/src/store/useISA95Store.js | 113 ++++++++++++++++++ 2 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 acs-admin/src/store/useISA95Store.js diff --git a/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/SparkplugMetric.vue b/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/SparkplugMetric.vue index 2eecc9e07..23e8fe20d 100644 --- a/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/SparkplugMetric.vue +++ b/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/SparkplugMetric.vue @@ -24,7 +24,17 @@ - + + - @@ -400,6 +402,23 @@ export default { this.isToggling = false; }, 100); }, + /* Handle selection of an ISA-95 hierarchy value. Sets the local value + * and clears all levels below this one in the full originMap so the + * hierarchy stays consistent. */ + on_isa95_select (new_value) { + this.localModel.Value = new_value + + const level_index = ISA95_LEVELS.indexOf(this.isa95Level) + const hierarchy = this.model?.Device_Information?.ISA95_Hierarchy + if (!hierarchy) return + + for (const level of ISA95_LEVELS.slice(level_index + 1)) { + if (hierarchy[level]?.Value !== undefined) { + delete hierarchy[level].Value + } + } + }, + isValidType (val, options) { return options.some(e => e.value === val) }, From b397653edfd95f168f5fcbea6d386b62b4a9b666 Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Thu, 4 Jun 2026 16:48:54 +0100 Subject: [PATCH 4/7] Add ISA-95 hierarchy level search --- .../OriginMapEditor/SparkplugMetric.vue | 51 ++++++++++++++----- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/SparkplugMetric.vue b/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/SparkplugMetric.vue index 505b247f6..698401bb6 100644 --- a/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/SparkplugMetric.vue +++ b/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/SparkplugMetric.vue @@ -24,18 +24,33 @@ - + + + @@ -139,6 +154,10 @@ import { Button } from '@/components/ui/button' import { useConnectionStore } from '@/store/useConnectionStore' import { useDriverStore } from '@/store/useDriverStore' import { useISA95Store, ISA95_LEVELS } from '@/store/useISA95Store.js' +import { + Combobox, ComboboxAnchor, ComboboxEmpty, + ComboboxInput, ComboboxItem, ComboboxList, ComboboxTrigger, +} from '@/components/ui/combobox' export default { name: 'SparkplugMetric', @@ -161,6 +180,13 @@ export default { SelectItem, SelectTrigger, SelectValue, + Combobox, + ComboboxAnchor, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxTrigger, Control, LinkUserDialog, GroupList, @@ -300,10 +326,7 @@ export default { console.log('isa95Options', { level: this.isa95Level, parent_level, parent_value, data: this.isa95.data }) - // No parent selected yet — show all nodes at this level so the user - // can set levels in any order. When a parent is set, filter to its - // children only. - if (!parent_value) return this.isa95.data.filter(n => n.level === this.isa95Level) + if (!parent_value) return [] const parent_node = this.isa95.find_by_name(parent_level, parent_value) console.log('parent_node', parent_node) From 69b0f851423ea9f25b8ab04b2aa66a862c322e26 Mon Sep 17 00:00:00 2001 From: Kavan Price Date: Tue, 9 Jun 2026 17:00:10 +0100 Subject: [PATCH 5/7] Move ISA-95 schema information to dedicated panel --- .../Devices/ISA95HierarchyPanel.vue | 164 ++++++++++++++++++ .../OriginMapEditor/OriginMapEditor.vue | 48 ++++- .../Devices/OriginMapEditor/SchemaGroup.vue | 7 +- .../OriginMapEditor/SparkplugMetric.vue | 5 +- .../src/pages/EdgeManager/Devices/Device.vue | 12 +- 5 files changed, 227 insertions(+), 9 deletions(-) create mode 100644 acs-admin/src/components/EdgeManager/Devices/ISA95HierarchyPanel.vue diff --git a/acs-admin/src/components/EdgeManager/Devices/ISA95HierarchyPanel.vue b/acs-admin/src/components/EdgeManager/Devices/ISA95HierarchyPanel.vue new file mode 100644 index 000000000..1633be8f7 --- /dev/null +++ b/acs-admin/src/components/EdgeManager/Devices/ISA95HierarchyPanel.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/OriginMapEditor.vue b/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/OriginMapEditor.vue index 0587116c4..6dc89cb2f 100644 --- a/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/OriginMapEditor.vue +++ b/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/OriginMapEditor.vue @@ -158,6 +158,7 @@ import { useConnectionStore } from '@store/useConnectionStore.js' import _ from 'lodash' import NewObjectOverlayForm from './NewObjectOverlayForm.vue' import { updateEdgeAgentConfig } from '@/utils/edgeAgentConfigUpdater' +import { ISA95_LEVELS } from '@/store/useISA95Store.js' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { generateCsv, downloadCsv, parseCsv, applyCsvToModel } from '@/composables/useOriginMapCsv.js' import Papa from 'papaparse' @@ -275,11 +276,14 @@ export default { immediate: true, handler(newOriginMap) { console.debug('Origin map changed:', newOriginMap) - // If the origin map is null or undefined, reset our model if (newOriginMap === null || newOriginMap === undefined) { console.debug('Resetting origin map model') this.resetModel() - // Trigger a re-render of the schema group + this.groupRerenderTrigger = +new Date() + } else if (!this.isDirty) { + // Accept external updates (e.g. from the ISA-95 sidebar panel) + // when there are no unsaved local changes in the editor. + this.model = newOriginMap this.groupRerenderTrigger = +new Date() } } @@ -888,6 +892,46 @@ export default { this.isDirty = true }, + /* Called by Device.vue when the ISA-95 sidebar panel selects a value. + * Applies the mutation directly to this.model (the object that gets + * saved) rather than relying on device.deviceInformation.originMap, + * which may be a different reference after a Pinia store refresh. + * Also clears all lower hierarchy levels and triggers a tree re-render. */ + applyISA95Selection ({ level, value, level_index }) { + if (!this.model.Device_Information) this.model.Device_Information = {} + if (!this.model.Device_Information.ISA95_Hierarchy) this.model.Device_Information.ISA95_Hierarchy = {} + + const hierarchy = this.model.Device_Information.ISA95_Hierarchy + + if (!hierarchy[level]) hierarchy[level] = {} + hierarchy[level].Value = value + if (!hierarchy[level].Sparkplug_Type) + hierarchy[level].Sparkplug_Type = 'String' + + /* Clear all levels below the one just set. + * Use null rather than delete: the save uses a JSON merge patch, where + * absent keys are preserved (not deleted). Setting Value to null is the + * RFC 7396 signal that tells ConfigDB to delete that key. */ + for (const lower of ISA95_LEVELS.slice(level_index + 1)) { + if (hierarchy[lower] !== undefined) { + if (!hierarchy[lower]) hierarchy[lower] = {} + hierarchy[lower].Value = null + } + } + + /* For fresh devices that have no originMap yet, wire device.deviceInformation.originMap + * to this.model so the ISA-95 panel's display (which reads from the device prop) + * stays in sync with what we will actually save. Once the user saves, Pinia + * refreshes the store with the real saved reference, and the two stay aligned + * via the existing originMap watcher. */ + if (this.device?.deviceInformation && !this.device.deviceInformation.originMap) { + this.device.deviceInformation.originMap = this.model + } + + this.isDirty = true + this.groupRerenderTrigger = +new Date() + }, + resetModel() { // Reset the model to an empty object this.model = {} diff --git a/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/SchemaGroup.vue b/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/SchemaGroup.vue index 3d869d166..ef71273fe 100644 --- a/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/SchemaGroup.vue +++ b/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/SchemaGroup.vue @@ -116,7 +116,6 @@ import { SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar' import { ChevronRight } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' - export default { name: 'SchemaGroup', @@ -903,9 +902,11 @@ export default { computed: { filteredKeys() { // Get all keys that aren't reserved words in the order they appear in the schema - // We use Object.keys to get the keys in the order they were defined in the schema + // We use Object.keys to get the keys in the order they were defined in the schema. + // ISA95_Hierarchy is always hidden from the tree — it is managed exclusively via + // the dedicated ISA-95 Hierarchy panel in the device sidebar. const allKeys = Object.keys(this.schema.properties).filter(e => - !['patternProperties', '$meta', 'Schema_UUID', 'Instance_UUID', 'required'].includes(e) + !['patternProperties', '$meta', 'Schema_UUID', 'Instance_UUID', 'required', 'ISA95_Hierarchy'].includes(e) ); // If not filtering, return all keys in their original order diff --git a/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/SparkplugMetric.vue b/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/SparkplugMetric.vue index 698401bb6..4bd752ef8 100644 --- a/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/SparkplugMetric.vue +++ b/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/SparkplugMetric.vue @@ -436,8 +436,9 @@ export default { if (!hierarchy) return for (const level of ISA95_LEVELS.slice(level_index + 1)) { - if (hierarchy[level]?.Value !== undefined) { - delete hierarchy[level].Value + if (hierarchy[level] !== undefined) { + if (!hierarchy[level]) hierarchy[level] = {} + hierarchy[level].Value = null } } }, diff --git a/acs-admin/src/pages/EdgeManager/Devices/Device.vue b/acs-admin/src/pages/EdgeManager/Devices/Device.vue index 2e69856c9..e012ed4ea 100644 --- a/acs-admin/src/pages/EdgeManager/Devices/Device.vue +++ b/acs-admin/src/pages/EdgeManager/Devices/Device.vue @@ -61,7 +61,7 @@ - + @@ -135,6 +135,8 @@ :value="connection?.name" /> + + @@ -159,7 +161,8 @@ import { useSchemaStore } from '@store/useSchemaStore.js' import ChangeSchemaDialog from '@components/EdgeManager/Devices/ChangeSchemaDialog.vue' import { useConnectionStore } from '@store/useConnectionStore.js' import ChangeConnectionDialog from '@components/EdgeManager/Devices/ChangeConnectionDialog.vue' -import OriginMapEditor from '@components/EdgeManager/Devices/OriginMapEditor/OriginMapEditor.vue' +import OriginMapEditor from '@components/EdgeManager/Devices/OriginMapEditor/OriginMapEditor.vue' +import ISA95HierarchyPanel from '@components/EdgeManager/Devices/ISA95HierarchyPanel.vue' export default { components: { @@ -179,6 +182,7 @@ export default { SidebarDetail, ChangeSchemaDialog, ChangeConnectionDialog, + ISA95HierarchyPanel, }, setup () { @@ -293,6 +297,10 @@ export default { } }, + on_isa95_change (payload) { + this.$refs.originMapEditor?.applyISA95Selection(payload) + }, + handleSchemaChanged(schemaUuid) { // Force refresh the device data this.d.stop() From 8a990e3f43585f02f8b0aa0e1aeca2662f0b198d Mon Sep 17 00:00:00 2001 From: Alex Godbehere Date: Thu, 9 Jul 2026 08:15:43 +0100 Subject: [PATCH 6/7] Remove stray debug toast from CSV import A leftover `toast.warning("Test message")` fired on every CSV import, showing a meaningless warning to the user. --- .../EdgeManager/Devices/OriginMapEditor/OriginMapEditor.vue | 2 -- 1 file changed, 2 deletions(-) diff --git a/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/OriginMapEditor.vue b/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/OriginMapEditor.vue index 6dc89cb2f..a6a8d9d6b 100644 --- a/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/OriginMapEditor.vue +++ b/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/OriginMapEditor.vue @@ -1133,8 +1133,6 @@ export default { }) } - toast.warning("Test message", { pauseOnHover: true }) - this.markDirty() this.groupRerenderTrigger = +new Date() this.rerenderTrigger = +new Date() From 04079cb28e9db197f257be2eacc3c0ad58b5ad9a Mon Sep 17 00:00:00 2001 From: Alex Godbehere Date: Thu, 9 Jul 2026 08:16:36 +0100 Subject: [PATCH 7/7] Ignore ISA-95 hierarchy rows on CSV import The ISA-95 levels are a controlled vocabulary, editable only through the ISA-95 Hierarchy panel, and hidden from the metric tree. CSV import knew nothing about this: it wrote every schema leaf it found, so exporting a device, typing free text into the ISA-95 columns and re-importing would bypass the vocabulary entirely. Skip those rows before the model traversal, so an import can neither write a hierarchy value nor create the ISA95_Hierarchy parent object as a side effect. An empty CSV cell can no longer delete an existing value. Export is unchanged: the columns are still emitted, so a CSV remains a complete picture of the device. Rows whose value the CSV would have changed are counted and reported in a toast, pointing the user at the panel. An untouched round-trip reports nothing. --- .../OriginMapEditor/OriginMapEditor.vue | 9 ++++- acs-admin/src/composables/useOriginMapCsv.js | 33 ++++++++++++++++++- acs-admin/src/store/useISA95Store.js | 5 +++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/OriginMapEditor.vue b/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/OriginMapEditor.vue index a6a8d9d6b..713ed1763 100644 --- a/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/OriginMapEditor.vue +++ b/acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/OriginMapEditor.vue @@ -1110,7 +1110,7 @@ export default { }, applyParsedCsv () { - if (!this.csvParsedData) return { applied: 0, skipped: 0 } + if (!this.csvParsedData) return { applied: 0, skipped: 0, ignored: 0 } // Pass the schema to applyCsvToModel so missing metrics can be created const result = applyCsvToModel(this.csvParsedData, this.model, this.schema, this.set) @@ -1133,6 +1133,13 @@ export default { }) } + if (result.ignored > 0) { + toast.warning(`${result.ignored} ISA-95 row${result.ignored === 1 ? '' : 's'} ignored`, { + description: 'Set the hierarchy in the ISA-95 Hierarchy panel.', + pauseOnHover: true, + }) + } + this.markDirty() this.groupRerenderTrigger = +new Date() this.rerenderTrigger = +new Date() diff --git a/acs-admin/src/composables/useOriginMapCsv.js b/acs-admin/src/composables/useOriginMapCsv.js index f53c39d11..2441d8883 100644 --- a/acs-admin/src/composables/useOriginMapCsv.js +++ b/acs-admin/src/composables/useOriginMapCsv.js @@ -3,10 +3,24 @@ */ import Papa from 'papaparse'; +import { ISA95_HIERARCHY_KEY } from '@/store/useISA95Store.js'; // No 'set' export from vue in Vue 3. Use plain assignment; reactivity is handled by the parent component's set helper if needed. const RESERVED_KEYS = ['Schema_UUID', 'Instance_UUID', 'patternProperties', '$meta', 'required']; +/** + * Read the value at a path of segments, without creating anything along the + * way. Returns undefined if any segment is missing. + */ +function valueAt(obj, segments) { + let current = obj; + for (const segment of segments) { + if (current == null || typeof current !== 'object') return undefined; + current = current[segment]; + } + return current; +} + /** * Determine the node type of a schema property. * @param {object} prop - A value from schema.properties @@ -86,10 +100,14 @@ function collectMetricRows(schema, model, pathSegments = [], inPlaceholder = fal * @param {Object} model - The model to mutate * @param {Object} schema - The schema * @param {Function} setFn - (optional) function(path, value, obj, delimiter) to set nested properties reactively + * @returns {{applied: number, skipped: number, ignored: number}} skipped counts + * rows whose path is absent from the schema; ignored counts ISA-95 hierarchy + * rows whose value the CSV tried to change. */ export function applyCsvToModel(rows, model, schema, setFn) { let applied = 0; let skipped = 0; + let ignored = 0; function getMetricSchema(schema, segments) { let currentSchema = schema; @@ -125,6 +143,19 @@ export function applyCsvToModel(rows, model, schema, setFn) { for (const { tagPath, fields } of rows) { const segments = tagPath.split('/'); + + // The ISA-95 hierarchy is a controlled vocabulary, managed exclusively + // by ISA95HierarchyPanel. Never let free-text CSV values write to it. + // Skip before traversal so we don't create the parent objects either. + if (segments.includes(ISA95_HIERARCHY_KEY)) { + // Only report rows where the CSV would actually have changed the + // value, so an unmodified round-trip stays quiet. + const csvValue = String(fields.Value ?? '').trim(); + const modelValue = String(valueAt(model, segments)?.Value ?? ''); + if (csvValue !== modelValue) ignored++; + continue; + } + let current = model; let found = true; let pathSoFar = []; @@ -218,7 +249,7 @@ export function applyCsvToModel(rows, model, schema, setFn) { } // ...existing code... - return { applied, skipped }; + return { applied, skipped, ignored }; } /** diff --git a/acs-admin/src/store/useISA95Store.js b/acs-admin/src/store/useISA95Store.js index 1c6d70f1a..454193cf6 100644 --- a/acs-admin/src/store/useISA95Store.js +++ b/acs-admin/src/store/useISA95Store.js @@ -12,6 +12,11 @@ import { UUIDs } from '@amrc-factoryplus/service-client' import { useServiceClientStore } from '@/store/serviceClientStore.js' import { serviceClientReady } from '@store/useServiceClientReady.js' +/* The Device Information schema key holding the ISA-95 level metrics. These + * are a controlled vocabulary: they are written only via ISA95HierarchyPanel, + * never by free-text entry. */ +export const ISA95_HIERARCHY_KEY = 'ISA95_Hierarchy' + /* Ordered list of ISA-95 levels, matching the metric key names used in the * Device Information schema (spaces included). */ export const ISA95_LEVELS = [