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_<name>_<assettype>_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_<name>_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 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-06-26 16:34:50 -04:00
parent b567de14ac
commit 37ffb4add5
15 changed files with 330 additions and 109 deletions

View File

@@ -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_<name>_<assettype>_enabled. A legacy
// global key identifier_<name>_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 }
}

View File

@@ -68,11 +68,11 @@
<span class="info-label">Name</span>
<span class="info-value">{{ equipment.name }}</span>
</div>
<div class="info-row" v-if="identifierflags.gaugelabreference && equipment.gaugelabreference">
<div class="info-row" v-if="isEnabled('gaugelabreference', 'equipment') && equipment.gaugelabreference">
<span class="info-label">Gauge Lab Reference</span>
<span class="info-value mono">{{ equipment.gaugelabreference }}</span>
</div>
<div class="info-row" v-if="identifierflags.maintenancereference && equipment.maintenancereference">
<div class="info-row" v-if="isEnabled('maintenancereference', 'equipment') && equipment.maintenancereference">
<span class="info-label">Maintenance Reference</span>
<span class="info-value mono">{{ equipment.maintenancereference }}</span>
</div>
@@ -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)

View File

@@ -34,8 +34,8 @@
</div>
</div>
<div class="form-row" v-if="identifierflags.gaugelabreference || identifierflags.maintenancereference">
<div class="form-group" v-if="identifierflags.gaugelabreference">
<div class="form-row" v-if="isEnabled('gaugelabreference', 'equipment') || isEnabled('maintenancereference', 'equipment')">
<div class="form-group" v-if="isEnabled('gaugelabreference', 'equipment')">
<label for="gaugelabreference">Gauge Lab Reference</label>
<input
id="gaugelabreference"
@@ -46,7 +46,7 @@
<small class="form-help">Authoritative gauge lab asset reference (if tracked)</small>
</div>
<div class="form-group" v-if="identifierflags.maintenancereference">
<div class="form-group" v-if="isEnabled('maintenancereference', 'equipment')">
<label for="maintenancereference">Maintenance Reference</label>
<input
id="maintenancereference"
@@ -358,7 +358,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()

View File

@@ -111,6 +111,14 @@
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ device.serialnumber || '-' }}</span>
</div>
<div class="info-row" v-if="isEnabled('gaugelabreference', 'network_device') && device.gaugelabreference">
<span class="info-label">Gauge Lab Reference</span>
<span class="info-value mono">{{ device.gaugelabreference }}</span>
</div>
<div class="info-row" v-if="isEnabled('maintenancereference', 'network_device') && device.maintenancereference">
<span class="info-label">Maintenance Reference</span>
<span class="info-value mono">{{ device.maintenancereference }}</span>
</div>
<div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">{{ device.networkdevice?.vendorname || '-' }}</span>
@@ -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()

View File

@@ -54,6 +54,27 @@
</div>
</div>
<div class="form-row" v-if="isEnabled('gaugelabreference', 'network_device') || isEnabled('maintenancereference', 'network_device')">
<div class="form-group" v-if="isEnabled('gaugelabreference', 'network_device')">
<label for="gaugelabreference">Gauge Lab Reference</label>
<input
id="gaugelabreference"
v-model="form.gaugelabreference"
type="text"
class="form-control"
/>
</div>
<div class="form-group" v-if="isEnabled('maintenancereference', 'network_device')">
<label for="maintenancereference">Maintenance Reference</label>
<input
id="maintenancereference"
v-model="form.maintenancereference"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="locationid">Location</label>
@@ -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,

View File

@@ -16,7 +16,7 @@
<div class="hero-content">
<div class="hero-title">
<h1>{{ computer.assetnumber }}</h1>
<span v-if="identifierflags.fqdn && computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
<span v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
</div>
<div class="hero-meta">
<span class="badge badge-lg badge-info">Computer</span>
@@ -57,7 +57,7 @@
<span class="info-label">Name</span>
<span class="info-value">{{ computer.name }}</span>
</div>
<div class="info-row" v-if="identifierflags.fqdn && computer.computer?.hostname">
<div class="info-row" v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname">
<span class="info-label">Hostname</span>
<span class="info-value mono">{{ computer.computer.hostname }}</span>
</div>
@@ -65,6 +65,14 @@
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ computer.serialnumber }}</span>
</div>
<div class="info-row" v-if="isEnabled('gaugelabreference', 'computer') && computer.gaugelabreference">
<span class="info-label">Gauge Lab Reference</span>
<span class="info-value mono">{{ computer.gaugelabreference }}</span>
</div>
<div class="info-row" v-if="isEnabled('maintenancereference', 'computer') && computer.maintenancereference">
<span class="info-label">Maintenance Reference</span>
<span class="info-value mono">{{ computer.maintenancereference }}</span>
</div>
</div>
</div>
@@ -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)

View File

@@ -34,7 +34,7 @@
</div>
<div class="form-row">
<div class="form-group" v-if="identifierflags.fqdn">
<div class="form-group" v-if="isEnabled('fqdn', 'computer')">
<label for="hostname">Hostname</label>
<input
id="hostname"
@@ -55,6 +55,28 @@
</div>
</div>
<div class="form-row" v-if="isEnabled('gaugelabreference', 'computer') || isEnabled('maintenancereference', 'computer')">
<div class="form-group" v-if="isEnabled('gaugelabreference', 'computer')">
<label for="gaugelabreference">Gauge Lab Reference</label>
<input
id="gaugelabreference"
v-model="form.gaugelabreference"
type="text"
class="form-control"
/>
</div>
<div class="form-group" v-if="isEnabled('maintenancereference', 'computer')">
<label for="maintenancereference">Maintenance Reference</label>
<input
id="maintenancereference"
v-model="form.maintenancereference"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="machinetypeid">PC Type *</label>
@@ -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,

View File

@@ -63,7 +63,7 @@
<span class="info-label">Windows Name</span>
<span class="info-value mono">{{ printer.printer.windowsname }}</span>
</div>
<div class="info-row" v-if="identifierflags.fqdn && printer.printer?.hostname">
<div class="info-row" v-if="isEnabled('fqdn', 'printer') && printer.printer?.hostname">
<span class="info-label">Hostname / FQDN</span>
<span class="info-value mono">{{ printer.printer.hostname }}</span>
</div>
@@ -75,6 +75,14 @@
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ printer.serialnumber }}</span>
</div>
<div class="info-row" v-if="isEnabled('gaugelabreference', 'printer') && printer.gaugelabreference">
<span class="info-label">Gauge Lab Reference</span>
<span class="info-value mono">{{ printer.gaugelabreference }}</span>
</div>
<div class="info-row" v-if="isEnabled('maintenancereference', 'printer') && printer.maintenancereference">
<span class="info-label">Maintenance Reference</span>
<span class="info-value mono">{{ printer.maintenancereference }}</span>
</div>
</div>
</div>
@@ -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)

View File

@@ -37,7 +37,7 @@
</div>
<div class="form-row">
<div class="form-group" v-if="identifierflags.fqdn">
<div class="form-group" v-if="isEnabled('fqdn', 'printer')">
<label for="hostname">Hostname (FQDN)</label>
<input
id="hostname"
@@ -61,6 +61,28 @@
</div>
</div>
<div class="form-row" v-if="isEnabled('gaugelabreference', 'printer') || isEnabled('maintenancereference', 'printer')">
<div class="form-group" v-if="isEnabled('gaugelabreference', 'printer')">
<label for="gaugelabreference">Gauge Lab Reference</label>
<input
id="gaugelabreference"
v-model="form.gaugelabreference"
type="text"
class="form-control"
/>
</div>
<div class="form-group" v-if="isEnabled('maintenancereference', 'printer')">
<label for="maintenancereference">Maintenance Reference</label>
<input
id="maintenancereference"
v-model="form.maintenancereference"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="machinetypeid">Printer Type *</label>
@@ -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,

View File

@@ -382,50 +382,35 @@
<div class="setting-group">
<p class="setting-description">
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.
</p>
<div class="setting-row">
<label class="toggle-label">
<span>Gauge Lab Reference</span>
<button
class="toggle-btn"
:class="{ active: settings.identifier_gaugelabreference_enabled }"
@click="toggleSetting('identifier_gaugelabreference_enabled')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
</div>
<div class="setting-row">
<label class="toggle-label">
<span>Maintenance Reference</span>
<button
class="toggle-btn"
:class="{ active: settings.identifier_maintenancereference_enabled }"
@click="toggleSetting('identifier_maintenancereference_enabled')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
</div>
<div class="setting-row">
<label class="toggle-label">
<span>FQDN / Hostname</span>
<button
class="toggle-btn"
:class="{ active: settings.identifier_fqdn_enabled }"
@click="toggleSetting('identifier_fqdn_enabled')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
<div class="table-container">
<table class="identifier-matrix">
<thead>
<tr>
<th>Identifier</th>
<th v-for="col in assetTypeCols" :key="col.key">{{ col.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in identifierRows" :key="row.name">
<td class="identifier-name">{{ row.label }}</td>
<td v-for="col in assetTypeCols" :key="col.key">
<button
class="toggle-btn"
:class="{ active: matrixValue(row.name, col.key) }"
@click="toggleIdentifier(row.name, col.key)"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@@ -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_<name>_<assettype>_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);
}
</style>