Initial import: shopdb-flask multi-site asset platform
Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin framework so sister GE Aerospace sites can adopt it. Product version 0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests. Key entry points: - docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook (own-site and subpath deployment methods) - docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify - docs/IMPORT-API.md - schema-agnostic legacy data import contract - scripts/site_imports/wjf/ - West Jefferson reference import loader - docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
42
frontend/src/composables/enabledPlugins.js
Normal file
42
frontend/src/composables/enabledPlugins.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// Enabled-plugin gating for the router. Fetches the enabled plugin-name list
|
||||
// ONCE (cached promise) so a disabled backend plugin's frontend routes become
|
||||
// unreachable by direct URL. Fail-open: any fetch error treats every plugin as
|
||||
// enabled, so an API blip can never brick navigation.
|
||||
import { pluginsApi } from '../api'
|
||||
|
||||
let enabledPromise = null
|
||||
let enabledSet = null // resolved Set<string>, or null while pending / on failure
|
||||
|
||||
// Kick off (or reuse) the single fetch. The endpoint is jwt-optional so this
|
||||
// works on unauthenticated kiosk routes (e.g. /tv) too.
|
||||
export function loadEnabledPlugins() {
|
||||
if (!enabledPromise) {
|
||||
enabledPromise = pluginsApi.enabled()
|
||||
.then(response => {
|
||||
const names = response?.data?.data
|
||||
// Only trust a real array; anything else -> fail open (null).
|
||||
enabledSet = Array.isArray(names) ? new Set(names) : null
|
||||
return enabledSet
|
||||
})
|
||||
.catch(() => {
|
||||
// Fail open: leave enabledSet null so isPluginEnabled returns true.
|
||||
enabledSet = null
|
||||
return null
|
||||
})
|
||||
}
|
||||
return enabledPromise
|
||||
}
|
||||
|
||||
// True if the plugin is enabled OR the list is unknown (fail-open). A null
|
||||
// enabledSet means we never got a trustworthy answer, so allow everything.
|
||||
export function isPluginEnabled(name) {
|
||||
if (!name) return true
|
||||
if (enabledSet === null) return true
|
||||
return enabledSet.has(name)
|
||||
}
|
||||
|
||||
// Test/hot-reload hook: forget the cached result so the next call refetches.
|
||||
export function resetEnabledPlugins() {
|
||||
enabledPromise = null
|
||||
enabledSet = null
|
||||
}
|
||||
71
frontend/src/composables/identifierSettings.js
Normal file
71
frontend/src/composables/identifierSettings.js
Normal file
@@ -0,0 +1,71 @@
|
||||
// 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'
|
||||
|
||||
// scope[name][assettype] = boolean. legacy[name] = boolean (old global flag).
|
||||
const state = reactive({
|
||||
scope: {},
|
||||
legacy: {},
|
||||
loaded: false
|
||||
})
|
||||
|
||||
let inflight = null
|
||||
|
||||
const KEY_RE = /^identifier_(.+?)(?:_(machine|computer|printer|network_device|measuring_tool))?_enabled$/
|
||||
|
||||
function applySetting(key, value) {
|
||||
const match = KEY_RE.exec(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] = value !== false
|
||||
} else {
|
||||
state.legacy[name] = value !== false
|
||||
}
|
||||
}
|
||||
|
||||
function fetchFlags() {
|
||||
inflight = settingsApi.list()
|
||||
.then(({ data }) => {
|
||||
;(data.data || []).forEach(s => applySetting(s.key, s.value))
|
||||
state.loaded = true
|
||||
})
|
||||
.catch(() => { state.loaded = true })
|
||||
.finally(() => { inflight = null })
|
||||
return inflight
|
||||
}
|
||||
|
||||
function loadFlags() {
|
||||
if (!state.loaded && !inflight) fetchFlags()
|
||||
}
|
||||
|
||||
// Re-read flags from the server. Call after an identifier setting changes so
|
||||
// other open views pick it up without a full page reload.
|
||||
export function reloadIdentifierFlags() {
|
||||
return fetchFlags()
|
||||
}
|
||||
|
||||
// Optimistically update one flag in the shared state (e.g. right after a
|
||||
// Settings toggle) so dependent views react immediately.
|
||||
export function setIdentifierFlag(name, assettype, enabled) {
|
||||
applySetting(`identifier_${name}_${assettype}_enabled`, enabled)
|
||||
}
|
||||
|
||||
// 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 }
|
||||
}
|
||||
102
frontend/src/composables/listQuery.js
Normal file
102
frontend/src/composables/listQuery.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
// Keep a list page's current page and search term in the URL query so the
|
||||
// browser Back button restores them. Without this a list keeps page in local
|
||||
// state and remounts at page 1 after visiting a detail page and going Back.
|
||||
//
|
||||
// Usage in a list view:
|
||||
// const { page, search, setPage, setSearch } = useListQuery({ onChange: loadRows })
|
||||
// goToPage(p) -> setPage(p); loadRows()
|
||||
// debouncedSearch -> setSearch(search.value); loadRows()
|
||||
// filter reset -> setPage(1); loadRows()
|
||||
//
|
||||
// Options:
|
||||
// onChange callback run when the query changes from outside (Back/Forward,
|
||||
// deep link) so the list reloads at the restored page.
|
||||
// extraKeys optional extra query keys to persist (e.g. ['typeid']); each
|
||||
// gets a ref exposed under the returned `extras` object.
|
||||
export function useListQuery(options = {}) {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const onChange = options.onChange || (() => {})
|
||||
const extraKeys = options.extraKeys || []
|
||||
|
||||
const page = ref(parseInt(route.query.page, 10) || 1)
|
||||
const search = ref(route.query.q || '')
|
||||
|
||||
const extras = {}
|
||||
for (const key of extraKeys) {
|
||||
extras[key] = ref(route.query[key] || '')
|
||||
}
|
||||
|
||||
// Build the next query. Keep unrelated keys intact. Write page only when > 1
|
||||
// and search only when non-empty so the clean state is a bare path.
|
||||
function buildQuery() {
|
||||
const query = { ...route.query }
|
||||
if (page.value > 1) query.page = String(page.value)
|
||||
else delete query.page
|
||||
if (search.value) query.q = search.value
|
||||
else delete query.q
|
||||
for (const key of extraKeys) {
|
||||
if (extras[key].value) query[key] = String(extras[key].value)
|
||||
else delete query[key]
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// replace (not push) so paging does not spam history; Back leaves the list.
|
||||
function syncUrl() {
|
||||
const query = buildQuery()
|
||||
const current = route.query
|
||||
const keys = new Set([...Object.keys(query), ...Object.keys(current)])
|
||||
let same = true
|
||||
for (const key of keys) {
|
||||
if (String(query[key] ?? '') !== String(current[key] ?? '')) {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!same) router.replace({ query })
|
||||
}
|
||||
|
||||
function setPage(newPage) {
|
||||
page.value = newPage
|
||||
syncUrl()
|
||||
}
|
||||
|
||||
// Changing the search resets to page 1.
|
||||
function setSearch(term) {
|
||||
search.value = term
|
||||
page.value = 1
|
||||
syncUrl()
|
||||
}
|
||||
|
||||
// Changing an extra filter resets to page 1.
|
||||
function setExtra(key, value) {
|
||||
extras[key].value = value
|
||||
page.value = 1
|
||||
syncUrl()
|
||||
}
|
||||
|
||||
// Re-sync refs when the query changes from outside (Back/Forward, deep link).
|
||||
// Fire onChange only when page/search/extras actually changed so a self
|
||||
// syncUrl() call does not trigger a redundant reload.
|
||||
watch(() => route.query, (newQuery) => {
|
||||
const newPage = parseInt(newQuery.page, 10) || 1
|
||||
const newSearch = newQuery.q || ''
|
||||
let changed = false
|
||||
if (newPage !== page.value) { page.value = newPage; changed = true }
|
||||
if (newSearch !== search.value) { search.value = newSearch; changed = true }
|
||||
for (const key of extraKeys) {
|
||||
const newValue = newQuery[key] || ''
|
||||
if (newValue !== String(extras[key].value || '')) {
|
||||
extras[key].value = newValue
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) onChange()
|
||||
})
|
||||
|
||||
return { page, search, extras, setPage, setSearch, setExtra }
|
||||
}
|
||||
69
frontend/src/composables/mapConfig.js
Normal file
69
frontend/src/composables/mapConfig.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// Facility floor-map blueprint config, read from the settings table so each
|
||||
// site instance (ADR-004) renders its own floor plan instead of a hardcoded
|
||||
// one. Keys: map_blueprint_light, map_blueprint_dark, map_width, map_height.
|
||||
// Missing keys fall back to the generic placeholder so a fresh or offline
|
||||
// install still renders; each site uploads its own blueprint in Settings.
|
||||
import { reactive } from 'vue'
|
||||
import { settingsApi } from '../api'
|
||||
import { withBase } from '../utils/basePath'
|
||||
|
||||
// Fallback defaults - match the seeded map_blueprint_* setting defaults.
|
||||
const DEFAULTS = {
|
||||
blueprintLight: '/static/images/floorplan-placeholder.svg',
|
||||
blueprintDark: '/static/images/floorplan-placeholder.svg',
|
||||
width: 3300,
|
||||
height: 2550
|
||||
}
|
||||
|
||||
// Shared reactive config. Import as `state` to read width/height/blueprint.
|
||||
export const state = reactive({ ...DEFAULTS, loaded: false })
|
||||
|
||||
let inflight = null
|
||||
|
||||
function applySetting(key, value) {
|
||||
if (value === null || value === undefined || value === '') return
|
||||
if (key === 'map_blueprint_light') state.blueprintLight = value
|
||||
else if (key === 'map_blueprint_dark') state.blueprintDark = value
|
||||
else if (key === 'map_width') {
|
||||
const n = parseInt(value, 10)
|
||||
if (!isNaN(n) && n > 0) state.width = n
|
||||
} else if (key === 'map_height') {
|
||||
const n = parseInt(value, 10)
|
||||
if (!isNaN(n) && n > 0) state.height = n
|
||||
}
|
||||
}
|
||||
|
||||
function fetchConfig() {
|
||||
inflight = settingsApi.list({ category: 'map' })
|
||||
.then(({ data }) => {
|
||||
;(data.data || []).forEach(s => applySetting(s.key, s.value))
|
||||
state.loaded = true
|
||||
})
|
||||
.catch(() => { state.loaded = true })
|
||||
.finally(() => { inflight = null })
|
||||
return inflight
|
||||
}
|
||||
|
||||
// Fetch the map config once (shared across all map components). Returns a
|
||||
// promise that resolves when state is populated, so a caller can await it
|
||||
// before initializing a Leaflet map that needs the dimensions.
|
||||
export function loadMapConfig() {
|
||||
if (state.loaded) return Promise.resolve()
|
||||
if (inflight) return inflight
|
||||
return fetchConfig()
|
||||
}
|
||||
|
||||
// Re-read config from the server after a map setting changes.
|
||||
export function reloadMapConfig() {
|
||||
return fetchConfig()
|
||||
}
|
||||
|
||||
// Blueprint image URL for the given theme ('light' | 'dark').
|
||||
export function blueprintUrlFor(theme) {
|
||||
return withBase(theme === 'light' ? state.blueprintLight : state.blueprintDark)
|
||||
}
|
||||
|
||||
export function useMapConfig() {
|
||||
loadMapConfig()
|
||||
return { state, blueprintUrlFor }
|
||||
}
|
||||
86
frontend/src/composables/settingsCatalog.js
Normal file
86
frontend/src/composables/settingsCatalog.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// Settings catalog: the static core groups (settingsNav.js) merged with the
|
||||
// plugin-contributed cards from the ADR-010 get_settings_cards hook, served by
|
||||
// GET /api/pluginui/settings-cards. Both SettingsLayout (rail) and SettingsIndex
|
||||
// (landing) read the merged catalog so plugins add settings cards with no core
|
||||
// edit. Fetched once into a module-level ref and shared across callers.
|
||||
import { ref } from 'vue'
|
||||
import { Ruler, Wrench, Cog, Package, Settings, Puzzle, SlidersHorizontal, Palette, Bell, Network, Printer, Droplets } from 'lucide-vue-next'
|
||||
import api from '../api'
|
||||
import { settingsGroups } from '../views/settings/settingsNav'
|
||||
|
||||
// Icon string keys (as returned by the hook) mapped to Lucide components,
|
||||
// same idea as the sidebar iconMap. Unknown keys fall back to a puzzle piece.
|
||||
const iconMap = {
|
||||
ruler: Ruler,
|
||||
wrench: Wrench,
|
||||
cog: Cog,
|
||||
package: Package,
|
||||
settings: Settings,
|
||||
puzzle: Puzzle,
|
||||
sliders: SlidersHorizontal,
|
||||
palette: Palette,
|
||||
bell: Bell,
|
||||
network: Network,
|
||||
printer: Printer,
|
||||
droplets: Droplets,
|
||||
}
|
||||
|
||||
function resolveIcon(key) {
|
||||
return iconMap[key] || Puzzle
|
||||
}
|
||||
|
||||
// Merge plugin cards into a fresh copy of the core groups. A card joins the
|
||||
// group whose title matches its `group`; a new group is appended at the end.
|
||||
function mergeCards(baseGroups, cards) {
|
||||
const merged = baseGroups.map(group => ({
|
||||
title: group.title,
|
||||
cards: [...group.cards],
|
||||
}))
|
||||
const byTitle = new Map(merged.map(group => [group.title, group]))
|
||||
|
||||
for (const card of cards) {
|
||||
const entry = {
|
||||
to: card.to,
|
||||
icon: resolveIcon(card.icon),
|
||||
title: card.title,
|
||||
description: card.description || '',
|
||||
position: card.position ?? 99,
|
||||
}
|
||||
let group = byTitle.get(card.group)
|
||||
if (!group) {
|
||||
group = { title: card.group, cards: [] }
|
||||
byTitle.set(card.group, group)
|
||||
merged.push(group)
|
||||
}
|
||||
group.cards.push(entry)
|
||||
}
|
||||
|
||||
// Order plugin cards within a group by position; core cards keep their order.
|
||||
for (const group of merged) {
|
||||
group.cards.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// Shared across component instances: seeded with the core groups, replaced with
|
||||
// the merged catalog once the hook endpoint answers.
|
||||
const groups = ref(settingsGroups)
|
||||
let loaded = false
|
||||
|
||||
async function loadCatalog() {
|
||||
try {
|
||||
const response = await api.get('/pluginui/settings-cards')
|
||||
groups.value = mergeCards(settingsGroups, response.data.data || [])
|
||||
} catch (error) {
|
||||
// Degrade to the core-only catalog; the rail still works without plugins.
|
||||
groups.value = settingsGroups
|
||||
}
|
||||
}
|
||||
|
||||
export function useSettingsCatalog() {
|
||||
if (!loaded) {
|
||||
loaded = true
|
||||
loadCatalog()
|
||||
}
|
||||
return { groups }
|
||||
}
|
||||
28
frontend/src/composables/setupState.js
Normal file
28
frontend/src/composables/setupState.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// Tracks whether first-run setup is complete, so the app can steer a fresh
|
||||
// admin into the /setup wizard. Defaults to "complete" so the wizard never
|
||||
// flashes before the real value loads.
|
||||
import { ref } from 'vue'
|
||||
import { settingsApi } from '../api'
|
||||
|
||||
export const setupComplete = ref(true)
|
||||
// Session-only: a fresh admin who clicks "Skip for now" is not nagged again
|
||||
// until the next login/reload.
|
||||
export const setupSkipped = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
export function isSetupLoaded() {
|
||||
return loaded.value
|
||||
}
|
||||
|
||||
export async function refreshSetupState() {
|
||||
try {
|
||||
const response = await settingsApi.get('setup_complete')
|
||||
const value = response.data?.data?.value
|
||||
setupComplete.value = value === true || value === 'true'
|
||||
} catch (err) {
|
||||
// If we cannot read it, assume complete so we do not trap the user.
|
||||
setupComplete.value = true
|
||||
}
|
||||
loaded.value = true
|
||||
return setupComplete.value
|
||||
}
|
||||
359
frontend/src/composables/systemSettings.js
Normal file
359
frontend/src/composables/systemSettings.js
Normal file
@@ -0,0 +1,359 @@
|
||||
// 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' }
|
||||
]
|
||||
|
||||
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: '',
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
35
frontend/src/composables/toast.js
Normal file
35
frontend/src/composables/toast.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// Global toast notifications. Import useToast() anywhere and call
|
||||
// toast.success('...') / toast.error('...') / toast.info('...'). A single
|
||||
// <ToastHost /> mounted in AppLayout renders the stack.
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const state = reactive({
|
||||
items: [],
|
||||
})
|
||||
|
||||
let nextId = 1
|
||||
|
||||
function dismiss(id) {
|
||||
const index = state.items.findIndex(t => t.id === id)
|
||||
if (index !== -1) state.items.splice(index, 1)
|
||||
}
|
||||
|
||||
function push(message, type = 'info', duration = 5000) {
|
||||
if (!message) return
|
||||
const id = nextId++
|
||||
state.items.push({ id, message, type })
|
||||
if (duration > 0) {
|
||||
setTimeout(() => dismiss(id), duration)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return {
|
||||
items: state.items,
|
||||
dismiss,
|
||||
success: (message, duration) => push(message, 'success', duration),
|
||||
error: (message, duration) => push(message, 'error', duration ?? 8000),
|
||||
info: (message, duration) => push(message, 'info', duration),
|
||||
}
|
||||
}
|
||||
44
frontend/src/composables/warrantyBadge.js
Normal file
44
frontend/src/composables/warrantyBadge.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// Shared warranty fetch + hero-badge derivation for asset detail pages.
|
||||
// Pass a getter for the asset id; get back the warranties list (to feed a
|
||||
// WarrantyPanel) and a heroWarranty for a compact status badge.
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { warrantyApi } from '../api'
|
||||
|
||||
const STATUS_RANK = { expired: 0, expiring: 1, active: 2, unknown: 3 }
|
||||
const STATUS_LABELS = {
|
||||
active: 'Under Warranty',
|
||||
expiring: 'Warranty Expiring',
|
||||
expired: 'Warranty Expired',
|
||||
unknown: 'Warranty',
|
||||
}
|
||||
|
||||
export function useWarrantyBadge(getAssetId) {
|
||||
const warranties = ref([])
|
||||
|
||||
// Worst-case warranty drives the badge: expired > expiring > active > unknown.
|
||||
const heroWarranty = computed(() => {
|
||||
if (!warranties.value.length) return null
|
||||
const worst = [...warranties.value]
|
||||
.sort((a, b) => (STATUS_RANK[a.status] ?? 9) - (STATUS_RANK[b.status] ?? 9))[0]
|
||||
return { ...worst, label: STATUS_LABELS[worst.status] || 'Warranty' }
|
||||
})
|
||||
|
||||
function warrantyDate(d) {
|
||||
return d ? new Date(d + 'T00:00:00').toLocaleDateString() : ''
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const assetid = getAssetId()
|
||||
if (!assetid) { warranties.value = []; return }
|
||||
try {
|
||||
const response = await warrantyApi.forAsset(assetid)
|
||||
warranties.value = response.data.data || []
|
||||
} catch (err) {
|
||||
warranties.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(getAssetId, load, { immediate: true })
|
||||
|
||||
return { warranties, heroWarranty, warrantyDate, reloadWarranties: load }
|
||||
}
|
||||
Reference in New Issue
Block a user