From 37ffb4add5a1948f7e81e2508f7b40d616cd009a Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 26 Jun 2026 16:34:50 -0400 Subject: [PATCH] Make gauge/maintenance identifiers per-asset-type and extend to all types Optional asset identifiers (gauge lab reference, maintenance reference, FQDN) were global per-identifier and only surfaced on equipment. Now they are toggleable per asset type and rendered on every asset type. - settings: replace 3 global identifier toggles with a per-type matrix. New keys identifier___enabled (3 identifiers x 4 types). IDENTIFIER_LABELS / IDENTIFIER_ASSETTYPES constants drive the seed (API seed and CLI seed settings). - composable: identifierSettings now exposes isEnabled(name, assettype), per-type flag winning over the legacy global key, defaulting on. - backend writes: computers, network, printers asset create + update now accept gaugelabreference and maintenancereference (equipment already did). Reads already flowed through Asset.to_dict. - frontend: Settings page renders an identifier x asset-type toggle matrix. Equipment, PC, printer, network forms and detail pages show gauge/maintenance (and FQDN where applicable) gated by isEnabled(name, type). Legacy global identifier__enabled keys are still honored as a fallback for older installs. SystemSettings toggles upsert (create on 404) so a deploy that has not re-seeded still works on first toggle. 144 tests pass, naming/style check green, frontend builds. Verified live: matrix renders, PC form shows the fields, PUT persists gauge/maintenance on a PC and reads back. Co-Authored-By: Claude Opus 4.8 --- .../src/composables/identifierSettings.js | 61 ++++--- frontend/src/views/machines/MachineDetail.vue | 6 +- frontend/src/views/machines/MachineForm.vue | 8 +- .../src/views/network/NetworkDeviceDetail.vue | 11 ++ .../src/views/network/NetworkDeviceForm.vue | 30 ++++ frontend/src/views/pcs/PCDetail.vue | 14 +- frontend/src/views/pcs/PCForm.vue | 32 +++- frontend/src/views/printers/PrinterDetail.vue | 12 +- frontend/src/views/printers/PrinterForm.vue | 32 +++- .../src/views/settings/SystemSettings.vue | 163 +++++++++++++----- plugins/computers/api/routes.py | 5 +- plugins/network/api/routes.py | 5 +- plugins/printers/api/asset_routes.py | 7 +- shopdb/cli/__init__.py | 12 ++ shopdb/core/api/settings.py | 41 +++-- 15 files changed, 330 insertions(+), 109 deletions(-) diff --git a/frontend/src/composables/identifierSettings.js b/frontend/src/composables/identifierSettings.js index 1e7fd87..752c669 100644 --- a/frontend/src/composables/identifierSettings.js +++ b/frontend/src/composables/identifierSettings.js @@ -1,36 +1,51 @@ -// Global enable/disable flags for optional asset identifiers, read from the -// settings table. Loaded once and shared across components. Defaults to -// enabled when a flag is missing so a fresh install shows the identifiers. +// Per-type enable/disable flags for optional asset identifiers, read from the +// settings table. Keys follow identifier___enabled. A legacy +// global key identifier__enabled is honored as a fallback for older +// installs. Missing = enabled, so a fresh install shows every identifier. import { reactive } from 'vue' import { settingsApi } from '../api' -const identifierflags = reactive({ - gaugelabreference: true, - maintenancereference: true, - fqdn: true, +// scope[name][assettype] = boolean. legacy[name] = boolean (old global flag). +const state = reactive({ + scope: {}, + legacy: {}, loaded: false }) let inflight = null -export function useIdentifierFlags() { - if (!identifierflags.loaded && !inflight) { +function loadFlags() { + if (!state.loaded && !inflight) { inflight = settingsApi.list() .then(({ data }) => { - const map = {} - ;(data.data || []).forEach(s => { map[s.key] = s.value }) - if ('identifier_gaugelabreference_enabled' in map) { - identifierflags.gaugelabreference = map.identifier_gaugelabreference_enabled !== false - } - if ('identifier_maintenancereference_enabled' in map) { - identifierflags.maintenancereference = map.identifier_maintenancereference_enabled !== false - } - if ('identifier_fqdn_enabled' in map) { - identifierflags.fqdn = map.identifier_fqdn_enabled !== false - } - identifierflags.loaded = true + ;(data.data || []).forEach(s => { + const match = /^identifier_(.+?)(?:_(equipment|computer|printer|network_device))?_enabled$/.exec(s.key) + if (!match) return + const name = match[1] + const assettype = match[2] + if (assettype) { + if (!state.scope[name]) state.scope[name] = {} + state.scope[name][assettype] = s.value !== false + } else { + state.legacy[name] = s.value !== false + } + }) + state.loaded = true }) - .catch(() => { identifierflags.loaded = true }) + .catch(() => { state.loaded = true }) } - return identifierflags +} + +// True when identifier `name` should show on `assettype`. Per-type flag wins, +// then the legacy global flag, then default-on. +function isEnabled(name, assettype) { + const perType = state.scope[name] + if (perType && assettype in perType) return perType[assettype] + if (name in state.legacy) return state.legacy[name] + return true +} + +export function useIdentifierFlags() { + loadFlags() + return { state, isEnabled } } diff --git a/frontend/src/views/machines/MachineDetail.vue b/frontend/src/views/machines/MachineDetail.vue index e1c5585..5475df0 100644 --- a/frontend/src/views/machines/MachineDetail.vue +++ b/frontend/src/views/machines/MachineDetail.vue @@ -68,11 +68,11 @@ Name {{ equipment.name }} -
+
Gauge Lab Reference {{ equipment.gaugelabreference }}
-
+
Maintenance Reference {{ equipment.maintenancereference }}
@@ -240,7 +240,7 @@ import LocationMapTooltip from '../../components/LocationMapTooltip.vue' import { useIdentifierFlags } from '../../composables/identifierSettings' const route = useRoute() -const identifierflags = useIdentifierFlags() +const { isEnabled } = useIdentifierFlags() const loading = ref(true) const equipment = ref(null) diff --git a/frontend/src/views/machines/MachineForm.vue b/frontend/src/views/machines/MachineForm.vue index b0ced1d..fdf046e 100644 --- a/frontend/src/views/machines/MachineForm.vue +++ b/frontend/src/views/machines/MachineForm.vue @@ -34,8 +34,8 @@
-
-
+
+
Authoritative gauge lab asset reference (if tracked)
-
+
Serial Number {{ device.serialnumber || '-' }}
+
+ Gauge Lab Reference + {{ device.gaugelabreference }} +
+
+ Maintenance Reference + {{ device.maintenancereference }} +
Vendor {{ device.networkdevice?.vendorname || '-' }} @@ -181,6 +189,9 @@ import { Network, Router, Shield, Wifi, Camera, Server, Server as Rack, Globe } import { useAuthStore } from '../../stores/auth' import { networkApi } from '../../api' import AssetRelationships from '../../components/AssetRelationships.vue' +import { useIdentifierFlags } from '../../composables/identifierSettings' + +const { isEnabled } = useIdentifierFlags() const route = useRoute() const router = useRouter() diff --git a/frontend/src/views/network/NetworkDeviceForm.vue b/frontend/src/views/network/NetworkDeviceForm.vue index 4184c46..b013ab5 100644 --- a/frontend/src/views/network/NetworkDeviceForm.vue +++ b/frontend/src/views/network/NetworkDeviceForm.vue @@ -54,6 +54,27 @@
+
+
+ + +
+
+ + +
+
+
@@ -229,6 +250,9 @@ import { assetsApi, businessunitsApi } from '../../api' +import { useIdentifierFlags } from '../../composables/identifierSettings' + +const { isEnabled } = useIdentifierFlags() const route = useRoute() const router = useRouter() @@ -240,6 +264,8 @@ const form = ref({ assetnumber: '', name: '', serialnumber: '', + gaugelabreference: '', + maintenancereference: '', statusid: '', locationid: '', businessunitid: '', @@ -332,6 +358,8 @@ async function loadDevice() { form.value.assetnumber = data.assetnumber || '' form.value.name = data.name || '' form.value.serialnumber = data.serialnumber || '' + form.value.gaugelabreference = data.gaugelabreference || '' + form.value.maintenancereference = data.maintenancereference || '' form.value.statusid = data.statusid || '' form.value.locationid = data.locationid || '' form.value.businessunitid = data.businessunitid || '' @@ -365,6 +393,8 @@ async function submitForm() { assetnumber: form.value.assetnumber, name: form.value.name || null, serialnumber: form.value.serialnumber || null, + gaugelabreference: form.value.gaugelabreference || null, + maintenancereference: form.value.maintenancereference || null, statusid: form.value.statusid || null, locationid: form.value.locationid || null, businessunitid: form.value.businessunitid || null, diff --git a/frontend/src/views/pcs/PCDetail.vue b/frontend/src/views/pcs/PCDetail.vue index b6aca38..d9e7b17 100644 --- a/frontend/src/views/pcs/PCDetail.vue +++ b/frontend/src/views/pcs/PCDetail.vue @@ -16,7 +16,7 @@

{{ computer.assetnumber }}

- {{ computer.computer.hostname }} + {{ computer.computer.hostname }}
Computer @@ -57,7 +57,7 @@ Name {{ computer.name }}
-
+
Hostname {{ computer.computer.hostname }}
@@ -65,6 +65,14 @@ Serial Number {{ computer.serialnumber }}
+
+ Gauge Lab Reference + {{ computer.gaugelabreference }} +
+
+ Maintenance Reference + {{ computer.maintenancereference }} +
@@ -210,7 +218,7 @@ import LocationMapTooltip from '../../components/LocationMapTooltip.vue' import { useIdentifierFlags } from '../../composables/identifierSettings' const route = useRoute() -const identifierflags = useIdentifierFlags() +const { isEnabled } = useIdentifierFlags() const loading = ref(true) const computer = ref(null) diff --git a/frontend/src/views/pcs/PCForm.vue b/frontend/src/views/pcs/PCForm.vue index 49beeae..f7d863d 100644 --- a/frontend/src/views/pcs/PCForm.vue +++ b/frontend/src/views/pcs/PCForm.vue @@ -34,7 +34,7 @@
-
+
+
+
+ + +
+ +
+ + +
+
+
@@ -276,7 +298,7 @@ import Modal from '../../components/Modal.vue' import { currentTheme } from '../../stores/theme' import { useIdentifierFlags } from '../../composables/identifierSettings' -const identifierflags = useIdentifierFlags() +const { isEnabled } = useIdentifierFlags() const route = useRoute() const router = useRouter() @@ -301,6 +323,8 @@ const form = ref({ alias: '', hostname: '', serialnumber: '', + gaugelabreference: '', + maintenancereference: '', machinetypeid: '', statusid: '', vendorid: '', @@ -371,6 +395,8 @@ onMounted(async () => { alias: pc.name && pc.name.toUpperCase() !== 'NONE' ? pc.name : '', hostname: ext.hostname || '', serialnumber: pc.serialnumber || '', + gaugelabreference: pc.gaugelabreference || '', + maintenancereference: pc.maintenancereference || '', machinetypeid: ext.computertypeid || '', statusid: pc.statusid || '', vendorid: ext.vendorid || '', @@ -423,6 +449,8 @@ async function savePC() { assetnumber: form.value.machinenumber, hostname: form.value.hostname || null, serialnumber: form.value.serialnumber || null, + gaugelabreference: form.value.gaugelabreference || null, + maintenancereference: form.value.maintenancereference || null, computertypeid: form.value.machinetypeid || null, statusid: form.value.statusid || null, vendorid: form.value.vendorid || null, diff --git a/frontend/src/views/printers/PrinterDetail.vue b/frontend/src/views/printers/PrinterDetail.vue index da13633..0077d00 100644 --- a/frontend/src/views/printers/PrinterDetail.vue +++ b/frontend/src/views/printers/PrinterDetail.vue @@ -63,7 +63,7 @@ Windows Name {{ printer.printer.windowsname }}
-
+
Hostname / FQDN {{ printer.printer.hostname }}
@@ -75,6 +75,14 @@ Serial Number {{ printer.serialnumber }}
+
+ Gauge Lab Reference + {{ printer.gaugelabreference }} +
+
+ Maintenance Reference + {{ printer.maintenancereference }} +
@@ -248,7 +256,7 @@ import LocationMapTooltip from '../../components/LocationMapTooltip.vue' import { useIdentifierFlags } from '../../composables/identifierSettings' const route = useRoute() -const identifierflags = useIdentifierFlags() +const { isEnabled } = useIdentifierFlags() const loading = ref(true) const printer = ref(null) diff --git a/frontend/src/views/printers/PrinterForm.vue b/frontend/src/views/printers/PrinterForm.vue index a22ee39..33b9956 100644 --- a/frontend/src/views/printers/PrinterForm.vue +++ b/frontend/src/views/printers/PrinterForm.vue @@ -37,7 +37,7 @@
-
+
+
+
+ + +
+ +
+ + +
+
+
@@ -273,7 +295,7 @@ import Modal from '../../components/Modal.vue' import { currentTheme } from '../../stores/theme' import { useIdentifierFlags } from '../../composables/identifierSettings' -const identifierflags = useIdentifierFlags() +const { isEnabled } = useIdentifierFlags() const route = useRoute() const router = useRouter() @@ -293,6 +315,8 @@ const form = ref({ alias: '', hostname: '', serialnumber: '', + gaugelabreference: '', + maintenancereference: '', machinetypeid: '', statusid: '', vendorid: '', @@ -462,6 +486,8 @@ onMounted(async () => { alias: printer.name && printer.name.toUpperCase() !== 'NONE' ? printer.name : '', hostname: ext.hostname || '', serialnumber: printer.serialnumber || '', + gaugelabreference: printer.gaugelabreference || '', + maintenancereference: printer.maintenancereference || '', machinetypeid: ext.printertypeid || '', statusid: printer.statusid || '', vendorid: ext.vendorid || '', @@ -520,6 +546,8 @@ async function savePrinter() { windowsname: form.value.machinenumber || null, hostname: form.value.hostname || null, serialnumber: form.value.serialnumber || null, + gaugelabreference: form.value.gaugelabreference || null, + maintenancereference: form.value.maintenancereference || null, printertypeid: form.value.machinetypeid || null, statusid: form.value.statusid || null, vendorid: form.value.vendorid || null, diff --git a/frontend/src/views/settings/SystemSettings.vue b/frontend/src/views/settings/SystemSettings.vue index 9e0cd03..52044db 100644 --- a/frontend/src/views/settings/SystemSettings.vue +++ b/frontend/src/views/settings/SystemSettings.vue @@ -382,50 +382,35 @@

- Enable or disable optional asset identifiers. When disabled, the identifier - is hidden from asset forms and detail pages across the system. + Enable or disable optional asset identifiers per asset type. When disabled + for a type, the identifier is hidden from that type's forms and detail + pages across the system.

-
- -
- -
- -
- -
- +
+ + + + + + + + + + + + + +
Identifier{{ col.label }}
{{ row.label }} + +
@@ -463,13 +448,33 @@ const settings = reactive({ saml_acs_url: '', saml_allow_local_login: true, saml_auto_create_users: true, - saml_admin_group: '', - // Asset identifiers - identifier_gaugelabreference_enabled: true, - identifier_maintenancereference_enabled: true, - identifier_fqdn_enabled: true + saml_admin_group: '' }) +// Asset identifier matrix: identifier x asset type. Keys follow +// identifier___enabled. Missing = enabled (default on). +const identifierRows = [ + { name: 'gaugelabreference', label: 'Gauge Lab Reference' }, + { name: 'maintenancereference', label: 'Maintenance Reference' }, + { name: 'fqdn', label: 'FQDN / Hostname' } +] +const assetTypeCols = [ + { key: 'equipment', label: 'Equipment' }, + { key: 'computer', label: 'PC' }, + { key: 'printer', label: 'Printer' }, + { key: 'network_device', label: 'Network' } +] +const identifierMatrix = reactive({}) + +function identifierKey(name, assettype) { + return `identifier_${name}_${assettype}_enabled` +} + +function matrixValue(name, assettype) { + const key = identifierKey(name, assettype) + return key in identifierMatrix ? identifierMatrix[key] : true +} + const loading = ref(true) const saving = ref(false) const testingEmail = ref(false) @@ -530,6 +535,8 @@ async function loadSettings() { for (const setting of data.data) { if (setting.key in settings) { settings[setting.key] = setting.value + } else if (/^identifier_.+_(equipment|computer|printer|network_device)_enabled$/.test(setting.key)) { + identifierMatrix[setting.key] = setting.value !== false } } } catch (e) { @@ -545,6 +552,41 @@ async function toggleSetting(key) { await saveSetting(key, newValue) } +// Toggle a per-type identifier flag. The key may not be seeded yet on older +// installs, so fall back to creating it when the update returns 404. +async function toggleIdentifier(name, assettype) { + const key = identifierKey(name, assettype) + const newValue = !matrixValue(name, assettype) + try { + saving.value = true + error.value = '' + success.value = '' + try { + await settingsApi.update(key, newValue) + } catch (e) { + if (e.response?.status === 404) { + await settingsApi.create({ + key, + value: newValue, + valuetype: 'boolean', + category: 'identifiers', + description: `Show the ${name} identifier on ${assettype} assets` + }) + } else { + throw e + } + } + identifierMatrix[key] = newValue + success.value = 'Setting saved' + setTimeout(() => { success.value = '' }, 2000) + } catch (e) { + error.value = e.response?.data?.message || 'Failed to save setting' + console.error(e) + } finally { + saving.value = false + } +} + async function saveSetting(key, value) { try { saving.value = true @@ -797,4 +839,31 @@ onMounted(loadSettings) margin-top: -0.5rem; margin-left: 0; } + +.identifier-matrix { + width: 100%; + border-collapse: collapse; +} + +.identifier-matrix th, +.identifier-matrix td { + padding: 0.6rem 0.75rem; + text-align: center; + border-bottom: 1px solid var(--border); +} + +.identifier-matrix th:first-child, +.identifier-matrix td.identifier-name { + text-align: left; +} + +.identifier-matrix th { + color: var(--text-light); + font-weight: 600; + font-size: 0.9rem; +} + +.identifier-matrix .identifier-name { + color: var(--text); +} diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py index 84eec0b..9f4f6c5 100644 --- a/plugins/computers/api/routes.py +++ b/plugins/computers/api/routes.py @@ -328,6 +328,8 @@ def create_computer(): assetnumber=data['assetnumber'], name=data.get('name'), serialnumber=data.get('serialnumber'), + gaugelabreference=data.get('gaugelabreference'), + maintenancereference=data.get('maintenancereference'), assettypeid=computer_type.assettypeid, statusid=data.get('statusid', 1), locationid=data.get('locationid'), @@ -424,7 +426,8 @@ def update_computer(computer_id: int): changes = {} # Update asset fields - asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', + asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference', + 'maintenancereference', 'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'] for key in asset_fields: if key in data: diff --git a/plugins/network/api/routes.py b/plugins/network/api/routes.py index 2a71493..40e1113 100644 --- a/plugins/network/api/routes.py +++ b/plugins/network/api/routes.py @@ -324,6 +324,8 @@ def create_network_device(): assetnumber=data['assetnumber'], name=data.get('name'), serialnumber=data.get('serialnumber'), + gaugelabreference=data.get('gaugelabreference'), + maintenancereference=data.get('maintenancereference'), assettypeid=network_type.assettypeid, statusid=data.get('statusid', 1), locationid=data.get('locationid'), @@ -406,7 +408,8 @@ def update_network_device(device_id: int): changes = {} # Update asset fields - asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', + asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference', + 'maintenancereference', 'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'] for key in asset_fields: if key in data: diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py index 5e234e7..ef3b2fe 100644 --- a/plugins/printers/api/asset_routes.py +++ b/plugins/printers/api/asset_routes.py @@ -285,6 +285,8 @@ def create_printer(): assetnumber=data['assetnumber'], name=data.get('name'), serialnumber=data.get('serialnumber'), + gaugelabreference=data.get('gaugelabreference'), + maintenancereference=data.get('maintenancereference'), assettypeid=printer_type.assettypeid, statusid=data.get('statusid', 1), locationid=data.get('locationid'), @@ -364,8 +366,9 @@ def update_printer(printer_id: int): http_code=409 ) - # Update asset fields (gauge lab / maintenance refs are equipment-only) - asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid', + # Update asset fields (optional identifiers gated per-type in Settings) + asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference', + 'maintenancereference', 'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'] for key in asset_fields: diff --git a/shopdb/cli/__init__.py b/shopdb/cli/__init__.py index 381b2c0..d67fa6a 100644 --- a/shopdb/cli/__init__.py +++ b/shopdb/cli/__init__.py @@ -361,6 +361,18 @@ def seed_settings(): }, ] + # Asset identifier toggles, per identifier AND per asset type (ADR-001). + from shopdb.core.api.settings import IDENTIFIER_LABELS, IDENTIFIER_ASSETTYPES + for name, label in IDENTIFIER_LABELS.items(): + for assettype in IDENTIFIER_ASSETTYPES: + defaults.append({ + 'key': f'identifier_{name}_{assettype}_enabled', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'identifiers', + 'description': f'Show the {label} identifier on {assettype} assets', + }) + created = 0 for d in defaults: if not Setting.query.filter_by(key=d['key']).first(): diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py index 9f058d0..3625e0d 100644 --- a/shopdb/core/api/settings.py +++ b/shopdb/core/api/settings.py @@ -17,6 +17,16 @@ SETTINGS_CACHE_TTL = 300 # 5 minutes # exposed in plaintext. Sending it back on update is treated as "unchanged". SECRET_MASK = '********' +# Optional asset identifiers and the asset types they can be toggled on. +# Drives per-type seed keys and the Settings matrix UI. The asset type names +# match the AssetType.assettype values seeded by each plugin. +IDENTIFIER_LABELS = { + 'gaugelabreference': 'Gauge Lab Reference', + 'maintenancereference': 'Maintenance Reference', + 'fqdn': 'FQDN / hostname', +} +IDENTIFIER_ASSETTYPES = ['equipment', 'computer', 'printer', 'network_device'] + def _is_secret(key: str) -> bool: return 'password' in key or 'token' in key or 'secret' in key @@ -155,29 +165,22 @@ def create_setting(): @jwt_required() def seed_default_settings(): """Seed default settings if they don't exist.""" - defaults = [ - # Asset identifier feature toggles (global, per identifier) + # Asset identifier feature toggles, per identifier AND per asset type. + # Key format: identifier___enabled (boolean). Admins pick + # which optional identifiers show on which asset types. See ADR-001. + identifierdefaults = [ { - 'key': 'identifier_gaugelabreference_enabled', + 'key': f'identifier_{name}_{assettype}_enabled', 'value': 'true', 'valuetype': 'boolean', 'category': 'identifiers', - 'description': 'Show the Gauge Lab Reference identifier on assets' - }, - { - 'key': 'identifier_maintenancereference_enabled', - 'value': 'true', - 'valuetype': 'boolean', - 'category': 'identifiers', - 'description': 'Show the Maintenance Reference identifier on assets' - }, - { - 'key': 'identifier_fqdn_enabled', - 'value': 'true', - 'valuetype': 'boolean', - 'category': 'identifiers', - 'description': 'Show the FQDN / hostname identifier on assets' - }, + 'description': f'Show the {label} identifier on {assettype} assets', + } + for name, label in IDENTIFIER_LABELS.items() + for assettype in IDENTIFIER_ASSETTYPES + ] + + defaults = identifierdefaults + [ # Zabbix integration { 'key': 'zabbix_enabled',