Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions acs-admin/src/components/EdgeManager/Devices/ISA95HierarchyPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
<!--
- ACS Admin
- ISA-95 hierarchy quick-select panel for the device sidebar.
- Copyright 2026 University of Sheffield AMRC
-->

<template>
<template v-if="show">
<div class="flex items-center justify-between gap-2 p-4 border-b">
<div class="font-semibold text-lg">ISA-95 Hierarchy</div>
</div>
<div class="space-y-3 p-4">
<div v-for="(level, index) in ISA95_LEVELS" :key="level">
<div class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">
{{ level }}
</div>
<Combobox
:model-value="get_value(level)"
@update:model-value="(v) => on_select(level, v, index)"
:reset-search-term-on-select="true"
>
<ComboboxAnchor class="w-full relative flex items-center">
<ComboboxInput
:display-value="(v) => v ?? ''"
:placeholder="parent_not_set(index) ? `Set ${ISA95_LEVELS[index - 1]} first` : `Select ${level}...`"
:disabled="parent_not_set(index)"
class="bg-white disabled:opacity-50 disabled:cursor-not-allowed"
/>
<ComboboxTrigger class="absolute right-2" :disabled="parent_not_set(index)">
<i class="fa-solid fa-chevron-down text-xs text-gray-400"/>
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxList class="w-[var(--reka-popper-anchor-width)]">
<ComboboxEmpty>No results</ComboboxEmpty>
<ComboboxItem
v-for="opt in get_options(level, index)"
:key="opt.uuid"
:value="opt.name"
:text-value="`${opt.name} ${opt.aliases.join(' ')}`"
>
{{ opt.name }}
</ComboboxItem>
</ComboboxList>
</Combobox>
</div>
</div>
</template>
</template>

<script>
import { ISA95_LEVELS, useISA95Store } from '@/store/useISA95Store.js'
import {
Combobox, ComboboxAnchor, ComboboxEmpty,
ComboboxInput, ComboboxItem, ComboboxList, ComboboxTrigger,
} from '@/components/ui/combobox'

export default {
name: 'ISA95HierarchyPanel',

components: {
Combobox, ComboboxAnchor, ComboboxEmpty,
ComboboxInput, ComboboxItem, ComboboxList, ComboboxTrigger,
},

emits: ['change'],

setup () {
return {
isa95: useISA95Store(),
ISA95_LEVELS,
}
},

props: {
device: {
required: true,
type: Object,
},
schema: {
required: false,
type: Object,
default: null,
},
},

mounted () {
this.isa95.start()
},

computed: {
/* Show the panel only when:
* 1. ISA-95 vocabulary has been configured (at least one enterprise exists), and
* 2. The device schema references the Device Information schema (UUID
* 2dd093e9-1450-44c5-be8c-c0d78e48219b), which is the sub-schema that
* contains ISA95_Hierarchy. The raw schema uses $ref links so we check
* by scanning the serialised schema for that UUID string. */
show () {
if (this.isa95.enterprises.length === 0) return false
if (!this.schema?.schema) return false
return JSON.stringify(this.schema.schema)
.includes('2dd093e9-1450-44c5-be8c-c0d78e48219b')
},

hierarchy () {
return this.device?.deviceInformation?.originMap
?.Device_Information?.ISA95_Hierarchy ?? {}
},
},

methods: {
get_value (level) {
return this.hierarchy[level]?.Value ?? null
},

parent_not_set (index) {
if (index === 0) return false
return !this.get_value(ISA95_LEVELS[index - 1])
},

get_options (level, index) {
if (index === 0) return this.isa95.enterprises

const parent_level = ISA95_LEVELS[index - 1]
const parent_value = this.get_value(parent_level)
if (!parent_value) return []

const parent_node = this.isa95.find_by_name(parent_level, parent_value)
if (!parent_node) return []

return this.isa95.children_of(parent_node.uuid)
},

on_select (level, new_value, level_index) {
/* Mutate device.deviceInformation.originMap in place so that the
* panel's own display (get_value / get_options) updates immediately.
* We also emit the payload so that OriginMapEditor can apply the
* same change to its own this.model — the object that actually gets
* saved — and trigger a tree re-render. Doing both is safe: if both
* point at the same reference (the common case) the second mutation
* is idempotent; if they've diverged (Pinia refresh race) both
* copies end up correct. */
const origin_map = this.device.deviceInformation.originMap
if (origin_map) {
if (!origin_map.Device_Information)
origin_map.Device_Information = {}
if (!origin_map.Device_Information.ISA95_Hierarchy)
origin_map.Device_Information.ISA95_Hierarchy = {}

const hierarchy = origin_map.Device_Information.ISA95_Hierarchy
if (!hierarchy[level]) hierarchy[level] = {}
hierarchy[level].Value = new_value
if (!hierarchy[level].Sparkplug_Type)
hierarchy[level].Sparkplug_Type = 'String'
for (const lower of ISA95_LEVELS.slice(level_index + 1)) {
if (hierarchy[lower]?.Value !== undefined)
hierarchy[lower].Value = null
}
}

this.$emit('change', { level, value: new_value, level_index })
},
},
}
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
}
}
Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -1066,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)
Expand All @@ -1089,7 +1133,12 @@ export default {
})
}

toast.warning("Test message", { pauseOnHover: true })
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading