From ef0079fc64a6c3f2d5e489091e4aeb4fe064e611 Mon Sep 17 00:00:00 2001 From: Alex Godbehere Date: Thu, 9 Jul 2026 08:47:44 +0100 Subject: [PATCH 1/3] Add level-class map and parents_of to the ISA-95 store The vocabulary editor needs to create objects in the right level class and to walk the tree upwards (showing a node's parent, unlinking on delete). Export the level-to-class map instead of keeping it local to start(), and add a parents_of getter alongside children_of. --- acs-admin/src/store/useISA95Store.js | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/acs-admin/src/store/useISA95Store.js b/acs-admin/src/store/useISA95Store.js index 454193cf6..4dac3ef4a 100644 --- a/acs-admin/src/store/useISA95Store.js +++ b/acs-admin/src/store/useISA95Store.js @@ -27,6 +27,15 @@ export const ISA95_LEVELS = [ 'Work Unit', ] +/* ConfigDB class holding the nodes at each level. */ +export const ISA95_LEVEL_CLASSES = { + 'Enterprise': UUIDs.Class.ISA95Enterprise, + 'Site': UUIDs.Class.ISA95Site, + 'Area': UUIDs.Class.ISA95Area, + 'Work Center': UUIDs.Class.ISA95WorkCenter, + 'Work Unit': UUIDs.Class.ISA95WorkUnit, +} + export const useISA95Store = defineStore('isa95', { state: () => ({ data: [], @@ -50,6 +59,10 @@ export const useISA95Store = defineStore('isa95', { .filter(Boolean) }, + /* Nodes whose children list includes the given UUID. */ + parents_of: (state) => (uuid) => + state.data.filter(n => n.children.includes(uuid)), + /* Find a node at a given level by its canonical name or an alias. */ find_by_name: (state) => (level, name) => state.data.find(n => @@ -65,16 +78,8 @@ export const useISA95Store = defineStore('isa95', { await serviceClientReady() const cdb = useServiceClientStore().client.ConfigDB - const level_classes = { - 'Enterprise': UUIDs.Class.ISA95Enterprise, - 'Site': UUIDs.Class.ISA95Site, - 'Area': UUIDs.Class.ISA95Area, - 'Work Center': UUIDs.Class.ISA95WorkCenter, - 'Work Unit': UUIDs.Class.ISA95WorkUnit, - } - const member_obs = Object.fromEntries( - Object.entries(level_classes).map(([level, class_uuid]) => [ + Object.entries(ISA95_LEVEL_CLASSES).map(([level, class_uuid]) => [ level, cdb.watch_members(class_uuid), ]) From 7c93252d1a4daea93e804e35a68f126f9a1ace58 Mon Sep 17 00:00:00 2001 From: Alex Godbehere Date: Thu, 9 Jul 2026 08:48:02 +0100 Subject: [PATCH 2/3] Add ISA-95 hierarchy editor to the admin UI A new top-level ISA-95 page for authoring the controlled vocabulary introduced by the ISA-95 hierarchy work, following the same shape as the Bridges page: a searchable table of nodes with a details sidebar, and a dialog for creating and editing nodes. Nodes are ConfigDB objects in the five level classes. The dialog sets the canonical name via the Info app and aliases via the ISA95Vocabulary app, and links the node into its parent's children list. Deleting a node unlinks it from every parent first so no vocabulary config is left pointing at a dead UUID; nodes with children cannot be deleted, which forces pruning bottom-up rather than orphaning subtrees. --- acs-admin/src/App.vue | 3 + .../components/ISA95/NewISA95NodeDialog.vue | 300 ++++++++++++++++++ acs-admin/src/components/Nav/Nav.vue | 6 + acs-admin/src/main.js | 9 + acs-admin/src/pages/ISA95/ISA95.vue | 208 ++++++++++++ acs-admin/src/pages/ISA95/isa95Columns.ts | 74 +++++ 6 files changed, 600 insertions(+) create mode 100644 acs-admin/src/components/ISA95/NewISA95NodeDialog.vue create mode 100644 acs-admin/src/pages/ISA95/ISA95.vue create mode 100644 acs-admin/src/pages/ISA95/isa95Columns.ts diff --git a/acs-admin/src/App.vue b/acs-admin/src/App.vue index d7c8c1add..853390a9b 100644 --- a/acs-admin/src/App.vue +++ b/acs-admin/src/App.vue @@ -75,6 +75,7 @@ + @@ -101,6 +102,7 @@ import NewEdgeDeploymentDialog from '@components/EdgeManager/Nodes/NewEdgeDeploy import NewConnectionDialog from '@components/EdgeManager/Connections/NewConnectionDialog.vue' import NewDeviceDialog from '@components/EdgeManager/Devices/NewDeviceDialog.vue' import NewBridgeDialog from '@components/Bridges/NewBridgeDialog.vue' +import NewISA95NodeDialog from '@components/ISA95/NewISA95NodeDialog.vue' import MonitorButton from '@components/MonitorButton.vue' import MonitorDialog from '@components/MonitorDialog.vue' import { useMonitorStore } from '@store/useMonitorStore.js' @@ -160,6 +162,7 @@ export default { Toaster, NewConnectionDialog, NewBridgeDialog, + NewISA95NodeDialog, MonitorButton, MonitorDialog, }, diff --git a/acs-admin/src/components/ISA95/NewISA95NodeDialog.vue b/acs-admin/src/components/ISA95/NewISA95NodeDialog.vue new file mode 100644 index 000000000..d30c3c1ab --- /dev/null +++ b/acs-admin/src/components/ISA95/NewISA95NodeDialog.vue @@ -0,0 +1,300 @@ + + + + + diff --git a/acs-admin/src/components/Nav/Nav.vue b/acs-admin/src/components/Nav/Nav.vue index 00e6fe90f..f29f5ec67 100644 --- a/acs-admin/src/components/Nav/Nav.vue +++ b/acs-admin/src/components/Nav/Nav.vue @@ -63,6 +63,12 @@ const sidebarNavItems: Item[] = [ href: '/bridges', icon: 'arrow-down-up-across-line', auth: true + }, + { + title: 'ISA-95', + href: '/isa95', + icon: 'sitemap', + auth: true } ] diff --git a/acs-admin/src/main.js b/acs-admin/src/main.js index 32f63bbed..41da06924 100644 --- a/acs-admin/src/main.js +++ b/acs-admin/src/main.js @@ -27,6 +27,7 @@ import ApplicationObjectEditor from "@pages/ConfigDB/Applications/ApplicationObj import ObjectPage from "@pages/ConfigDB/Objects/ObjectPage.vue"; import Files from "@pages/Files/Files.vue"; import Bridges from "@pages/Bridges/Bridges.vue"; +import ISA95 from "@pages/ISA95/ISA95.vue"; // Create an event bus window.events = mitt() @@ -151,6 +152,14 @@ const routes = [ name: 'Bridges', icon: 'arrow-down-up-across-line' } + }, + { + path: '/isa95', + component: ISA95, + meta: { + name: 'ISA-95', + icon: 'sitemap' + } } ] diff --git a/acs-admin/src/pages/ISA95/ISA95.vue b/acs-admin/src/pages/ISA95/ISA95.vue new file mode 100644 index 000000000..71dfab1b7 --- /dev/null +++ b/acs-admin/src/pages/ISA95/ISA95.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/acs-admin/src/pages/ISA95/isa95Columns.ts b/acs-admin/src/pages/ISA95/isa95Columns.ts new file mode 100644 index 000000000..b0c902c7f --- /dev/null +++ b/acs-admin/src/pages/ISA95/isa95Columns.ts @@ -0,0 +1,74 @@ +/* + * Copyright (c) University of Sheffield AMRC 2026. + */ + +import type { ColumnDef } from '@tanstack/vue-table' +import { h } from 'vue' + +import DataTableColumnHeader from '@/components/ui/data-table/DataTableColumnHeader.vue' + +export interface ISA95Node { + uuid: string, + name: string, + level: string, + aliases: string[], + children: string[], + parents: string[], // parent names, resolved by the page +} + +export const isa95Columns: ColumnDef[] = [ + { + accessorKey: 'name', + accessorFn: (row) => row.name, + header: ({ column }) => h(DataTableColumnHeader, { + column, + title: 'Name' + }), + cell: ({ row }) => { + return h('div', { class: 'max-w-[500px] truncate' }, [ + h('div', { class: 'max-w-[500px] truncate font-medium' }, row.getValue('name')), + h('div', { class: 'max-w-[500px] truncate text-gray-400' }, row.original.uuid) + ]) + }, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)) + }, + }, + { + accessorKey: 'level', + accessorFn: (row) => row.level, + header: ({ column }) => h(DataTableColumnHeader, { + column, + title: 'Level' + }), + cell: ({ row }) => h('div', { class: 'truncate' }, row.getValue('level')), + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)) + }, + }, + { + accessorKey: 'parents', + accessorFn: (row) => row.parents.join(', ') || 'None', + header: ({ column }) => h(DataTableColumnHeader, { + column, + title: 'Parent' + }), + cell: ({ row }) => h('div', { + class: row.original.parents.length ? 'truncate' : 'truncate text-gray-400' + }, row.getValue('parents')), + }, + { + accessorKey: 'aliases', + accessorFn: (row) => row.aliases.join(', '), + header: ({ column }) => h(DataTableColumnHeader, { + column, + title: 'Aliases' + }), + cell: ({ row }) => { + const aliases = row.original.aliases + return aliases.length + ? h('div', { class: 'max-w-[300px] truncate' }, aliases.join(', ')) + : h('div', { class: 'text-gray-400' }, 'None') + }, + }, +] From 94a04fabe00ab00cb26e8f591797b942c878f8b5 Mon Sep 17 00:00:00 2001 From: Alex Godbehere Date: Thu, 9 Jul 2026 08:48:19 +0100 Subject: [PATCH 3/3] Import the ISA-95 hierarchy from existing devices Installations that predate the controlled vocabulary have free-text hierarchy values saved on their devices. An "Import from Devices" action on the ISA-95 page scans every device's origin map, previews the result and builds the vocabulary from it, so nobody retypes their site structure. The operator can then prune or rename from the editor. Matching is scoped to the parent so the same site name under two different enterprises stays two nodes, and it is case-insensitive: where the typed text differs from the canonical name it is added as an alias, which makes the device's exact value resolve to the node. Values below a gap in a device's hierarchy (an Area with no Site) cannot be placed in the tree; they are counted, reported and left untouched. The planning logic is a pure module, separate from the dialog, with the ConfigDB writes confined to apply_plan. --- acs-admin/src/App.vue | 3 + .../components/ISA95/ImportISA95Dialog.vue | 204 ++++++++++++++++++ .../src/composables/useISA95Migration.js | 190 ++++++++++++++++ acs-admin/src/pages/ISA95/ISA95.vue | 7 + 4 files changed, 404 insertions(+) create mode 100644 acs-admin/src/components/ISA95/ImportISA95Dialog.vue create mode 100644 acs-admin/src/composables/useISA95Migration.js diff --git a/acs-admin/src/App.vue b/acs-admin/src/App.vue index 853390a9b..c70aec936 100644 --- a/acs-admin/src/App.vue +++ b/acs-admin/src/App.vue @@ -76,6 +76,7 @@ + @@ -103,6 +104,7 @@ import NewConnectionDialog from '@components/EdgeManager/Connections/NewConnecti import NewDeviceDialog from '@components/EdgeManager/Devices/NewDeviceDialog.vue' import NewBridgeDialog from '@components/Bridges/NewBridgeDialog.vue' import NewISA95NodeDialog from '@components/ISA95/NewISA95NodeDialog.vue' +import ImportISA95Dialog from '@components/ISA95/ImportISA95Dialog.vue' import MonitorButton from '@components/MonitorButton.vue' import MonitorDialog from '@components/MonitorDialog.vue' import { useMonitorStore } from '@store/useMonitorStore.js' @@ -163,6 +165,7 @@ export default { NewConnectionDialog, NewBridgeDialog, NewISA95NodeDialog, + ImportISA95Dialog, MonitorButton, MonitorDialog, }, diff --git a/acs-admin/src/components/ISA95/ImportISA95Dialog.vue b/acs-admin/src/components/ISA95/ImportISA95Dialog.vue new file mode 100644 index 000000000..810ace7e2 --- /dev/null +++ b/acs-admin/src/components/ISA95/ImportISA95Dialog.vue @@ -0,0 +1,204 @@ + + + + + diff --git a/acs-admin/src/composables/useISA95Migration.js b/acs-admin/src/composables/useISA95Migration.js new file mode 100644 index 000000000..134f8ddb5 --- /dev/null +++ b/acs-admin/src/composables/useISA95Migration.js @@ -0,0 +1,190 @@ +/* + * ACS Admin + * Build ISA-95 vocabulary nodes from the free-text hierarchy values saved + * on existing devices, so an installation can adopt the controlled + * vocabulary without retyping its site structure. + * Copyright 2026 University of Sheffield AMRC + */ + +import { UUIDs } from '@amrc-factoryplus/service-client' + +import { ISA95_LEVELS, ISA95_LEVEL_CLASSES, ISA95_HIERARCHY_KEY } + from '@/store/useISA95Store.js' + +/* Pull the free-text hierarchy off a device store entry. Returns null when + * the device has no hierarchy at all. `chain` is the contiguous run of + * values from Enterprise down; `ignored_tail` is true when values exist + * below a gap (e.g. an Area with no Site), which cannot be placed in the + * tree and are left alone. */ +export function extract_hierarchy (device) { + const h = device.deviceInformation?.originMap + ?.Device_Information?.[ISA95_HIERARCHY_KEY] + if (!h || typeof h !== 'object') return null + + const values = ISA95_LEVELS.map(l => String(h[l]?.Value ?? '').trim()) + let gap = values.findIndex(v => v === '') + if (gap === -1) gap = values.length + + const chain = values.slice(0, gap) + if (chain.length === 0) return null + + return { + chain, + ignored_tail: values.slice(gap).some(v => v !== ''), + } +} + +const fold = s => s.trim().toLowerCase() + +/* Work out what vocabulary changes are needed to make every device's + * hierarchy valid. Pure: takes the device store data and the ISA-95 store + * data, touches nothing. + * + * Matching is scoped to the parent: a device value only matches an + * existing node that is a child of the node matched at the level above + * (or, for Enterprise, any root). Matching is case-insensitive against + * canonical names and aliases. When the typed string differs from the + * canonical name, it is recorded as an alias so the device's exact text + * resolves to the node. + * + * Returns: + * creates: [{ key, level, name, aliases: [], children: [key] }] + * new nodes; children only ever reference other new nodes + * links: [{ parent_uuid, child_key }] + * new nodes to append to an existing node's children + * aliases: [{ uuid, alias }] + * aliases to append to existing nodes + * stats: { imported, no_hierarchy, ignored_tails } + */ +export function plan_import (devices, nodes) { + const creates = new Map() + const links = [] + const aliases = [] + const stats = { imported: 0, no_hierarchy: 0, ignored_tails: 0 } + + const existing_alias_adds = new Map() + + const match = (scope, value) => scope.find(n => + fold(n.name) === fold(value) || + n.aliases.some(a => fold(a) === fold(value))) + + const ensure_alias = (node, value) => { + if (node.name === value) return + if (node.aliases.includes(value)) return + if (node.key !== undefined) { + if (!node.aliases.includes(value)) node.aliases.push(value) + return + } + const pending = existing_alias_adds.get(node.uuid) ?? [] + if (!pending.includes(value)) { + pending.push(value) + existing_alias_adds.set(node.uuid, pending) + } + } + + for (const device of devices) { + const h = extract_hierarchy(device) + if (!h) { + stats.no_hierarchy++ + continue + } + if (h.ignored_tail) stats.ignored_tails++ + + let parent = null + for (let i = 0; i < h.chain.length; i++) { + const value = h.chain[i] + const level = ISA95_LEVELS[i] + + /* Candidate nodes: children of the matched parent, or roots. + * Existing children come from the vocabulary; pending children + * come from this plan. */ + const scope = [] + if (parent === null) { + scope.push(...nodes.filter(n => n.level === level)) + for (const c of creates.values()) + if (c.level === level && c.root) scope.push(c) + } else if (parent.key !== undefined) { + for (const k of parent.children) scope.push(creates.get(k)) + } else { + for (const uuid of parent.children) { + const n = nodes.find(n => n.uuid === uuid) + if (n) scope.push(n) + } + for (const l of links) + if (l.parent_uuid === parent.uuid) + scope.push(creates.get(l.child_key)) + } + + /* Include aliases pending on existing nodes, so two devices + * with the same variant spelling match the same node. */ + let node = match(scope.map(n => n.uuid && existing_alias_adds.has(n.uuid) + ? { ...n, aliases: [...n.aliases, ...existing_alias_adds.get(n.uuid)] } + : n), value) + if (node?.uuid) node = scope.find(n => n.uuid === node.uuid) + + if (!node) { + const key = `${i}:${parent ? (parent.key ?? parent.uuid) : ''}:${fold(value)}` + node = { key, level, name: value, aliases: [], children: [], root: parent === null } + creates.set(key, node) + if (parent === null) { + /* Root: nothing to link. */ + } else if (parent.key !== undefined) { + parent.children.push(key) + } else { + links.push({ parent_uuid: parent.uuid, child_key: key }) + } + } else { + ensure_alias(node, value) + } + parent = node + } + stats.imported++ + } + + for (const [uuid, add] of existing_alias_adds) + for (const alias of add) aliases.push({ uuid, alias }) + + return { creates: [...creates.values()], links, aliases, stats } +} + +/* Apply a plan through the ConfigDB client. Creates the new objects first, + * then writes every vocabulary config with the real UUIDs resolved. */ +export async function apply_plan (cdb, plan, nodes) { + const uuid_of = new Map() + + for (const c of plan.creates) { + const uuid = await cdb.create_object(ISA95_LEVEL_CLASSES[c.level]) + uuid_of.set(c.key, uuid) + await cdb.put_config(UUIDs.App.Info, uuid, { name: c.name }) + } + + for (const c of plan.creates) + await cdb.put_config(UUIDs.App.ISA95Vocabulary, uuid_of.get(c.key), { + aliases: c.aliases, + children: c.children.map(k => uuid_of.get(k)), + }) + + /* Merge new children and aliases into existing nodes' configs. */ + const updates = new Map() + const base = uuid => { + if (!updates.has(uuid)) { + const n = nodes.find(n => n.uuid === uuid) + updates.set(uuid, { + aliases: [...(n?.aliases ?? [])], + children: [...(n?.children ?? [])], + }) + } + return updates.get(uuid) + } + for (const l of plan.links) + base(l.parent_uuid).children.push(uuid_of.get(l.child_key)) + for (const a of plan.aliases) { + const u = base(a.uuid) + if (!u.aliases.includes(a.alias)) u.aliases.push(a.alias) + } + + for (const [uuid, config] of updates) + await cdb.put_config(UUIDs.App.ISA95Vocabulary, uuid, config) + + return { created: plan.creates.length, updated: updates.size } +} diff --git a/acs-admin/src/pages/ISA95/ISA95.vue b/acs-admin/src/pages/ISA95/ISA95.vue index 71dfab1b7..34ab485ed 100644 --- a/acs-admin/src/pages/ISA95/ISA95.vue +++ b/acs-admin/src/pages/ISA95/ISA95.vue @@ -16,6 +16,10 @@ @row-click="e => nodeClick(e.original)" >