The map was one picture of one floor. A second floor was added, the blueprint changed size, and machines moved, so a position now records WHICH DRAWING its coordinates belong to. Buildings and levels (ADR-017). Each level owns its blueprint per theme and its own native pixel size; assets.mapx/mapy are pixels of assets.levelid, not of the site. A position whose level is unknown renders "level unknown" and is never drawn on the default level, because a marker on the wrong floor plan looks entirely correct while pointing at the wrong place. Repositioning in bulk: filter by unplaced, needs-review or level, search, place, confirm. Landmark recalibration solves the transform PER AXIS from landmark pairs and never from image dimensions - the canvas grew taller without rescaling, so a dimension-derived scale would stretch Y by 1.57 and be wrong everywhere. It defaults to a dry run, reports what would land off the drawing, snapshots before applying, and clears mapverifiedat because a transform is a guess awaiting review. Snapshots restore, including the level and the review state, and a restore snapshots first so an undo is undoable. Search: gaugelabreference was matched only for measuring tools and maintenancereference was matched nowhere at all, for any asset type, while Settings happily offers both identifiers on machines and PCs. A tag an operator is told to record has to be findable or it is a write-only field. USB devices and printed items were unreachable from search entirely - neither is an asset, so the generic asset search could not see them and no searcher existed; they now match on serial, asset tag, label, bin code and gage-lab tag, honouring isactive, with Settings toggles and result labels to match. The retired-application rule was half a rule: GET /api/knowledgebase hid articles whose topic application is retired while global search still returned them and printed the retired application as the subject. A filter is only real if every path that reaches the row applies it. Contract to 0.20.0 (additive): Asset gained levelid and mapverifiedat, Location gained levelid, and resolve_asset_position returns the levelid belonging to whichever source supplied the coordinates. The five plugins that write a map position are re-pinned. The install-list text format gained levelid as a NINTH field, appended, because the shipped Pascal installer reads fields 0-7 by index. That installer still compiles in one drawing's dimensions and bundles one blueprint, so its map is accurate for the default level only; /api/maplevels is deliberately unauthenticated so it can read both at runtime once rebuilt. Recorded in PRINTER-INSTALLER.md section 6 along with the other known gaps. Migration 7d33 converts an existing single-map site into one building and one default level carrying the old map_* settings, then assigns every placed asset and location to it. Nothing moves on screen. Old settings rows are kept so a rollback still finds them. Verified end to end on MySQL 5.6 from a production-shaped database.
365 lines
12 KiB
JavaScript
365 lines
12 KiB
JavaScript
// Shared plumbing for the individual system-settings pages.
|
|
// Each settings page calls useSystemSettings() in its own setup, so state is
|
|
// per-page (no cross-page leak). The composable owns the load-all/save/toggle/
|
|
// upload machinery plus the static display catalogs, keeping the pages thin.
|
|
// Per-page connection-status computeds (zabbix/dell/smtp/saml) stay in the pages.
|
|
import { ref, reactive } from 'vue'
|
|
import { settingsApi, computersApi } from '../api'
|
|
import { setIdentifierFlag } from './identifierSettings'
|
|
import { apiError } from '../utils/apiError'
|
|
|
|
// Branding logo widgets. kind maps to the backend endpoint; key is the setting
|
|
// the resulting URL is stored under.
|
|
export const brandingLogos = [
|
|
{ kind: 'site', key: 'site_logo', label: 'Site logo', accept: 'image/*',
|
|
placeholder: '/ge-aerospace-logo.svg',
|
|
hint: 'Shown in the app header and login. Upload an image or type a path/URL.' },
|
|
{ kind: 'qr', key: 'qr_logo', label: 'QR overlay logo', accept: 'image/*',
|
|
placeholder: '/ge-monogram.svg',
|
|
hint: 'Logo overlaid on printed QR codes. Leave blank for no overlay.' },
|
|
{ kind: 'badge', key: 'badge_logo', label: 'Machine badge logo', accept: 'image/*',
|
|
placeholder: '/ge-aerospace-logo.svg',
|
|
hint: 'Logo printed on machine badges. Upload an image or type a path/URL.' },
|
|
{ kind: 'favicon', key: 'site_favicon', label: 'Favicon', accept: 'image/*,.ico',
|
|
placeholder: '(blank = shipped /favicon.svg)',
|
|
hint: 'Browser-tab icon. Leave blank to use the shipped favicon.' },
|
|
]
|
|
|
|
// Asset identifier matrix: identifier x asset type. Keys follow
|
|
// identifier_<name>_<assettype>_enabled. Missing = enabled (default on).
|
|
export const identifierRows = [
|
|
{ name: 'gaugelabreference', label: 'Gauge Lab Reference' },
|
|
{ name: 'maintenancereference', label: 'Maintenance Reference' },
|
|
{ name: 'fqdn', label: 'FQDN / Hostname' }
|
|
]
|
|
export const assetTypeCols = [
|
|
{ key: 'machine', label: 'Machine' },
|
|
{ key: 'computer', label: 'PC' },
|
|
{ key: 'printer', label: 'Printer' },
|
|
{ key: 'network_device', label: 'Network' },
|
|
{ key: 'measuring_tool', label: 'Measuring Tool' }
|
|
]
|
|
|
|
// Global-search domain toggles: keys follow search_<type>_enabled.
|
|
// Missing = enabled (default on).
|
|
export const searchDomains = [
|
|
{ key: 'application', label: 'Applications' },
|
|
{ key: 'knowledgebase', label: 'Knowledge Base' },
|
|
{ key: 'employee', label: 'Employees' },
|
|
{ key: 'machine', label: 'Machines' },
|
|
{ key: 'computer', label: 'PCs' },
|
|
{ key: 'printer', label: 'Printers' },
|
|
{ key: 'network_device', label: 'Network Devices' },
|
|
{ key: 'measuring_tool', label: 'Measuring Tools' },
|
|
{ key: 'notification', label: 'Notifications' },
|
|
{ key: 'subnet', label: 'Subnets' },
|
|
{ key: 'usb_device', label: 'USB Devices' },
|
|
{ key: 'printed_item', label: 'Printed Items' }
|
|
]
|
|
|
|
export function useSystemSettings() {
|
|
// Every settings key across all pages. A page binds only its own subset.
|
|
const settings = reactive({
|
|
// Zabbix
|
|
zabbix_enabled: false,
|
|
zabbix_url: '',
|
|
zabbix_token: '',
|
|
// Dell warranty
|
|
warranty_dell_enabled: false,
|
|
warranty_dell_clientid: '',
|
|
warranty_dell_clientsecret: '',
|
|
warranty_dell_tokenurl: '',
|
|
warranty_dell_apiurl: '',
|
|
// ServiceNow
|
|
servicenow_enabled: true,
|
|
servicenow_search_url: '',
|
|
servicenow_ticket_prefixes: '',
|
|
servicenow_incident_url: '',
|
|
servicenow_change_url: '',
|
|
// Branding
|
|
site_logo: '',
|
|
qr_logo: '',
|
|
badge_logo: '',
|
|
site_favicon: '',
|
|
brand_primary_color: '',
|
|
brand_primary_dark_color: '',
|
|
brand_accent_color: '',
|
|
brand_sidebar_color: '',
|
|
// Printing and labels
|
|
qr_target_printer: '',
|
|
qr_target_usb: '',
|
|
usb_label_style: 'barcode',
|
|
qr_target_machine: '',
|
|
qr_target_computer: '',
|
|
qr_target_network_device: '',
|
|
qr_target_measuring_tool: '',
|
|
label_default_style: 'card',
|
|
label_default_codetype: 'qr',
|
|
label_default_encodes_machine: 'assetnumber',
|
|
label_default_encodes_computer: 'assetpage',
|
|
label_default_encodes_printer: 'assetpage',
|
|
label_default_encodes_network_device: 'assetpage',
|
|
label_default_encodes_measuring_tool: 'location',
|
|
// Email
|
|
smtp_enabled: false,
|
|
smtp_host: '',
|
|
smtp_port: 587,
|
|
smtp_username: '',
|
|
smtp_password: '',
|
|
smtp_use_tls: true,
|
|
smtp_from_address: '',
|
|
smtp_from_name: 'ShopDB',
|
|
alert_recipients: '',
|
|
site_base_url: '',
|
|
alert_webhook_url: '',
|
|
alert_webhook_format: 'teams',
|
|
// Audit
|
|
audit_retention_days: 90,
|
|
// Floor map blueprint (per-facility)
|
|
map_blueprint_light: '',
|
|
map_blueprint_dark: '',
|
|
map_width: 3300,
|
|
map_height: 2550,
|
|
// SAML
|
|
saml_enabled: false,
|
|
saml_idp_metadata_url: '',
|
|
saml_entity_id: '',
|
|
saml_acs_url: '',
|
|
saml_allow_local_login: true,
|
|
saml_auto_create_users: true,
|
|
saml_admin_group: ''
|
|
})
|
|
|
|
const identifierMatrix = reactive({})
|
|
const searchMatrix = reactive({})
|
|
const pcTypeMappings = ref([]) // [{ pxetype, computertype }]
|
|
const computerTypes = ref([]) // ComputerType names for the dropdown
|
|
|
|
const loading = ref(true)
|
|
const saving = ref(false)
|
|
const mapUploading = ref(false)
|
|
const brandingUploading = ref(false)
|
|
const error = ref('')
|
|
const success = ref('')
|
|
|
|
function flashSaved() {
|
|
success.value = 'Setting saved'
|
|
setTimeout(() => { success.value = '' }, 2000)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
function searchValue(domainKey) {
|
|
const key = `search_${domainKey}_enabled`
|
|
return key in searchMatrix ? searchMatrix[key] : true
|
|
}
|
|
|
|
// Load all settings and fan them into settings / matrices / pctype rows.
|
|
async function loadSettings() {
|
|
try {
|
|
loading.value = true
|
|
const { data } = await settingsApi.list()
|
|
|
|
const pctypeRows = []
|
|
for (const setting of data.data) {
|
|
if (setting.key in settings) {
|
|
settings[setting.key] = setting.value
|
|
} else if (/^identifier_.+_(machine|computer|printer|network_device|measuring_tool)_enabled$/.test(setting.key)) {
|
|
identifierMatrix[setting.key] = setting.value !== false
|
|
} else if (/^search_.+_enabled$/.test(setting.key)) {
|
|
searchMatrix[setting.key] = setting.value !== false
|
|
} else if (setting.key.startsWith('pctypemap_')) {
|
|
pctypeRows.push({
|
|
pxetype: setting.key.slice('pctypemap_'.length),
|
|
computertype: setting.value
|
|
})
|
|
}
|
|
}
|
|
pcTypeMappings.value = pctypeRows.sort((a, b) => a.pxetype.localeCompare(b.pxetype))
|
|
} catch (e) {
|
|
error.value = 'Failed to load settings'
|
|
console.error(e)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// Computer type options for the pctype mapping dropdown (own request).
|
|
async function loadComputerTypes() {
|
|
try {
|
|
const typesResponse = await computersApi.types.list({ perpage: 100 })
|
|
computerTypes.value = (typesResponse.data.data || []).map(t => t.computertype)
|
|
} catch (typesError) {
|
|
console.error('Failed to load computer types', typesError)
|
|
}
|
|
}
|
|
|
|
async function saveSetting(key, value) {
|
|
try {
|
|
saving.value = true
|
|
error.value = ''
|
|
success.value = ''
|
|
await settingsApi.update(key, value)
|
|
settings[key] = value
|
|
flashSaved()
|
|
} catch (e) {
|
|
error.value = apiError(e, 'Failed to save setting')
|
|
console.error(e)
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function toggleSetting(key) {
|
|
await saveSetting(key, !settings[key])
|
|
}
|
|
|
|
// Toggle a per-type identifier flag. The key may not be seeded on older
|
|
// installs, so create 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
|
|
// Push into the shared composable so open asset views react without a
|
|
// full page reload (the composable otherwise fetches only once).
|
|
setIdentifierFlag(name, assettype, newValue)
|
|
flashSaved()
|
|
} catch (e) {
|
|
error.value = apiError(e, 'Failed to save setting')
|
|
console.error(e)
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function toggleSearchDomain(domainKey) {
|
|
const key = `search_${domainKey}_enabled`
|
|
const newValue = !searchValue(domainKey)
|
|
const label = searchDomains.find(d => d.key === domainKey)?.label || domainKey
|
|
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: 'search',
|
|
description: `Include ${label} in global search results`
|
|
})
|
|
} else {
|
|
throw e
|
|
}
|
|
}
|
|
searchMatrix[key] = newValue
|
|
flashSaved()
|
|
} catch (e) {
|
|
error.value = apiError(e, 'Failed to save setting')
|
|
console.error(e)
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function changePcTypeMapping(pxetype, computertype) {
|
|
const key = `pctypemap_${pxetype}`
|
|
try {
|
|
saving.value = true
|
|
error.value = ''
|
|
success.value = ''
|
|
await settingsApi.update(key, computertype)
|
|
const row = pcTypeMappings.value.find(r => r.pxetype === pxetype)
|
|
if (row) row.computertype = computertype
|
|
flashSaved()
|
|
} catch (e) {
|
|
error.value = apiError(e, 'Failed to save setting')
|
|
console.error(e)
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function uploadBlueprint(theme, event) {
|
|
const file = event.target.files[0]
|
|
if (!file) return
|
|
mapUploading.value = true
|
|
error.value = ''
|
|
success.value = ''
|
|
try {
|
|
const { data } = await settingsApi.uploadMapBlueprint(theme, file)
|
|
const url = data?.data?.value
|
|
if (theme === 'light') settings.map_blueprint_light = url
|
|
else settings.map_blueprint_dark = url
|
|
success.value = 'Blueprint uploaded'
|
|
setTimeout(() => { success.value = '' }, 2000)
|
|
} catch (e) {
|
|
error.value = apiError(e, 'Upload failed')
|
|
} finally {
|
|
mapUploading.value = false
|
|
event.target.value = ''
|
|
}
|
|
}
|
|
|
|
async function uploadLogo(kind, key, event) {
|
|
const file = event.target.files[0]
|
|
if (!file) return
|
|
brandingUploading.value = true
|
|
error.value = ''
|
|
success.value = ''
|
|
try {
|
|
const { data } = await settingsApi.uploadBrandingLogo(kind, file)
|
|
const url = data?.data?.value
|
|
if (url) settings[key] = url
|
|
success.value = 'Logo uploaded'
|
|
setTimeout(() => { success.value = '' }, 2000)
|
|
} catch (e) {
|
|
error.value = apiError(e, 'Upload failed')
|
|
} finally {
|
|
brandingUploading.value = false
|
|
event.target.value = ''
|
|
}
|
|
}
|
|
|
|
return {
|
|
settings,
|
|
identifierMatrix, searchMatrix, pcTypeMappings, computerTypes,
|
|
loading, saving, mapUploading, brandingUploading, error, success,
|
|
loadSettings, loadComputerTypes,
|
|
saveSetting, toggleSetting,
|
|
identifierKey, matrixValue, toggleIdentifier,
|
|
searchValue, toggleSearchDomain,
|
|
changePcTypeMapping,
|
|
uploadBlueprint, uploadLogo,
|
|
}
|
|
}
|