Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
This commit is contained in:
@@ -18,12 +18,12 @@ const api = axios.create({
|
||||
// page would be readable by anyone who could load the page, so it is per-device
|
||||
// and never shipped with the bundle.
|
||||
//
|
||||
// One entry per kiosk: the two are separate screens in separate rooms and a
|
||||
// shared token would mean provisioning one grants the other.
|
||||
export const KIOSK_TOKEN_KEYS = {
|
||||
'/printedparts/kiosk/': 'printedparts-kiosk-token',
|
||||
'/usb/kiosk/': 'usb-kiosk-token',
|
||||
}
|
||||
// One entry per kiosk, keyed by the URL prefix its routes live under: two
|
||||
// screens in two rooms must not share a token, or provisioning one grants the
|
||||
// other. Empty in this build - no bundled plugin serves a kiosk - and this is
|
||||
// the extension point a plugin that adds one registers its prefix in, together
|
||||
// with claimKioskToken below and the interceptor that sends the header.
|
||||
export const KIOSK_TOKEN_KEYS = {}
|
||||
|
||||
function kioskTokenFor(url) {
|
||||
const path = url || ''
|
||||
@@ -216,14 +216,113 @@ export const endpointsApi = {
|
||||
get(assetId) {
|
||||
return api.get(`/endpoints/${assetId}`)
|
||||
},
|
||||
enqueueCommand(assetId, action, target = null) {
|
||||
// opts.policyoverride + opts.overridereason drive the admin break-glass path.
|
||||
// Both are required together server-side: an override without a stated reason
|
||||
// is rejected, so the caller must collect the reason before sending.
|
||||
enqueueCommand(assetId, action, target = null, opts = {}) {
|
||||
const body = { action }
|
||||
if (target) body.target = target
|
||||
if (opts.policyoverride) {
|
||||
body.policyoverride = true
|
||||
body.overridereason = opts.overridereason
|
||||
}
|
||||
return api.post(`/endpoints/${assetId}/commands`, body)
|
||||
},
|
||||
// Pushes the published agent build to one endpoint. Separate from
|
||||
// enqueueCommand because the server prepares the delivery (packages the
|
||||
// build, seeds the upgrade script) before queueing it as a pinned
|
||||
// run_script; the caller has no script id to pass and must not invent one.
|
||||
enqueueAgentUpgrade(assetId, opts = {}) {
|
||||
const body = {}
|
||||
if (opts.policyoverride) {
|
||||
body.policyoverride = true
|
||||
body.overridereason = opts.overridereason
|
||||
}
|
||||
return api.post(`/endpoints/${assetId}/agentupgrade`, body)
|
||||
},
|
||||
// Per-PC credential administration (admin role, re-checked server-side).
|
||||
// blockreenroll keeps the PC from enrolling again until allowReenroll.
|
||||
revokeCredential(assetId, opts = {}) {
|
||||
const body = {}
|
||||
if (opts.blockreenroll) {
|
||||
body.blockreenroll = true
|
||||
if (opts.reason) body.reason = opts.reason
|
||||
}
|
||||
return api.post(`/endpoints/enrollment/assets/${assetId}/revoke`, body)
|
||||
},
|
||||
allowReenroll(assetId) {
|
||||
return api.post(`/endpoints/enrollment/assets/${assetId}/allow-reenroll`)
|
||||
},
|
||||
listCommands(assetId) {
|
||||
return api.get(`/endpoints/${assetId}/commands`)
|
||||
},
|
||||
// Recent run_script commands only, with the agent-reported detail the plain
|
||||
// command list omits. Feeds the run history under the Run Scripts tab.
|
||||
listScriptRuns(assetId, limit = 5) {
|
||||
return api.get(`/endpoints/${assetId}/scriptruns`, { params: { limit } })
|
||||
},
|
||||
// Collected-log history for one endpoint. Metadata only - the log bodies are
|
||||
// fetched one at a time, because a 20-row history of multi-hundred-KB logs
|
||||
// would make opening the tab far more expensive than reading the one log the
|
||||
// operator actually wants.
|
||||
listLogCollections(assetId) {
|
||||
return api.get(`/endpoints/${assetId}/logcollections`)
|
||||
},
|
||||
// One collected log including its content, for the scrollable pane.
|
||||
getLogCollection(collectionId) {
|
||||
return api.get(`/endpoints/logcollections/${collectionId}`)
|
||||
},
|
||||
// Blob, not JSON: the server sends this as a text/plain attachment, and the
|
||||
// caller turns it into a save dialog.
|
||||
downloadLogCollection(collectionId) {
|
||||
return api.get(`/endpoints/logcollections/${collectionId}/download`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
},
|
||||
// Active break-glass holds. Readable by any endpoints.view holder, since a
|
||||
// suppressed action is drift an operator must be able to explain.
|
||||
listPolicyHolds(assetId) {
|
||||
return api.get(`/endpoints/${assetId}/policyholds`)
|
||||
},
|
||||
// Desired-mode actions that a hold would actually affect, with hold state.
|
||||
listHoldableActions(assetId) {
|
||||
return api.get(`/endpoints/${assetId}/holdableactions`)
|
||||
},
|
||||
// Admin-only. Takes one action name or an array of them; the server applies
|
||||
// them atomically, so a bad name rejects the whole request rather than
|
||||
// leaving a partial set of holds behind.
|
||||
// minutes: null uses the server default window, 0 means indefinite.
|
||||
placePolicyHold(assetId, actions, reason, minutes = null) {
|
||||
const list = Array.isArray(actions) ? actions : [actions]
|
||||
const body = { actions: list, reason }
|
||||
// Explicit null check, NOT truthiness: 0 is a meaningful value here
|
||||
// (indefinite) and a falsy test would silently downgrade it to the
|
||||
// default window.
|
||||
if (minutes !== null && minutes !== undefined) body.minutes = minutes
|
||||
return api.post(`/endpoints/${assetId}/policyholds`, body)
|
||||
},
|
||||
releasePolicyHold(assetId, action) {
|
||||
return api.delete(`/endpoints/${assetId}/policyholds/${action}`)
|
||||
},
|
||||
// Endpoint lock: an admin quarantine refusing ACTIONS from non-admins on this
|
||||
// endpoint. Distinct from a policy hold - a hold suppresses reconciliation of
|
||||
// one action and expires on its own; a lock gates callers and stands until an
|
||||
// admin lifts it. Returns null when the endpoint is not locked.
|
||||
//
|
||||
// Readable by any endpoints.view holder on purpose: an operator whose action
|
||||
// was refused needs to see who locked it and why, or they file a ticket about
|
||||
// a machine that is working fine.
|
||||
getEndpointLock(assetId) {
|
||||
return api.get(`/endpoints/${assetId}/lock`)
|
||||
},
|
||||
// Admin-only. Reason is required and is quoted verbatim in the refusal shown
|
||||
// to blocked users, so it is the message, not paperwork.
|
||||
placeEndpointLock(assetId, reason) {
|
||||
return api.post(`/endpoints/${assetId}/lock`, { reason })
|
||||
},
|
||||
releaseEndpointLock(assetId) {
|
||||
return api.delete(`/endpoints/${assetId}/lock`)
|
||||
},
|
||||
telemetryMeta(assetId) {
|
||||
return api.get(`/endpoints/asset/${assetId}/telemetrymeta`)
|
||||
},
|
||||
@@ -711,8 +810,16 @@ export const applicationsApi = {
|
||||
update(id, data) {
|
||||
return api.put(`/applications/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/applications/${id}`)
|
||||
// Hard delete. The backend requires the operator's typed confirmation in the
|
||||
// body, so the caller must pass what the user actually typed - this is not
|
||||
// defaulted here, or the guard would be meaningless.
|
||||
delete(id, confirm) {
|
||||
return api.delete(`/applications/${id}`, { data: { confirm } })
|
||||
},
|
||||
// Strips policies, versions and uploaded executables but keeps the catalogue
|
||||
// row and its image. Same typed-confirmation contract as delete.
|
||||
revertToRecord(id, confirm) {
|
||||
return api.post(`/applications/${id}/revert`, { confirm })
|
||||
},
|
||||
// multipart image upload; backend sets image to the served URL
|
||||
uploadImage(id, file) {
|
||||
@@ -768,8 +875,11 @@ export const applicationsApi = {
|
||||
}
|
||||
|
||||
// ShopDB-hosted script library. Backs run_script delivery: the agent fetches a
|
||||
// script body by digest with its collector credential and runs it in the
|
||||
// script's configured run context (system or user).
|
||||
// script body by digest with its collector credential and runs it as SYSTEM.
|
||||
// The UI does not offer a run context - scripts exist to trigger an already
|
||||
// registered scheduled task, and the agent service has no user session to
|
||||
// borrow. Anything needing a user's own token must be that scheduled task's
|
||||
// problem, not this library's.
|
||||
export const scriptsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/scripts', { params })
|
||||
@@ -979,42 +1089,6 @@ export const notificationsApi = {
|
||||
}
|
||||
}
|
||||
|
||||
// USB Devices API
|
||||
export const usbApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/usb', { params })
|
||||
},
|
||||
// Every USB device, paged past the backend's 100-row cap. Batch label printing
|
||||
// and "pick any record" dropdowns must use this: list() with a large
|
||||
// perpage is clamped to 100 and still returns a success response, so
|
||||
// the tail simply goes missing.
|
||||
listAll(params = {}) {
|
||||
return fetchAllPages('/usb', params)
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/usb/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/usb', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/usb/${id}`, data)
|
||||
},
|
||||
checkout(id, data) {
|
||||
return api.post(`/usb/${id}/checkout`, data)
|
||||
},
|
||||
checkin(id, data = {}) {
|
||||
return api.post(`/usb/${id}/checkin`, data)
|
||||
},
|
||||
getHistory(id, params = {}) {
|
||||
return api.get(`/usb/${id}/history`, { params })
|
||||
},
|
||||
// check-out log rows for one badge; activeonly = only currently-held devices
|
||||
getUserCheckouts(badge, activeonly = true) {
|
||||
const path = activeonly ? '/usb/checkouts/active' : '/usb/checkouts'
|
||||
return api.get(path, { params: { badge } })
|
||||
}
|
||||
}
|
||||
|
||||
// Reports API
|
||||
export const reportsApi = {
|
||||
@@ -1046,7 +1120,8 @@ export const reportsApi = {
|
||||
}
|
||||
}
|
||||
|
||||
// Employees API (wjf_employees database)
|
||||
// Employees API. Core, not a plugin: the rows live in the site's external HR
|
||||
// database and this app only reads them (see shopdb/core/api/employees.py).
|
||||
export const employeesApi = {
|
||||
search(query, limit = 10) {
|
||||
return api.get('/employees/search', { params: { q: query, limit } })
|
||||
@@ -1056,33 +1131,6 @@ export const employeesApi = {
|
||||
},
|
||||
lookupMultiple(ssoList) {
|
||||
return api.get('/employees/lookup', { params: { sso: ssoList } })
|
||||
},
|
||||
// Self-hosted directory management (directory_mode=selfhosted)
|
||||
directory: {
|
||||
list() {
|
||||
return api.get('/employees/directory')
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/employees/directory', data)
|
||||
},
|
||||
update(sso, data) {
|
||||
return api.put(`/employees/directory/${sso}`, data)
|
||||
},
|
||||
remove(sso) {
|
||||
return api.delete(`/employees/directory/${sso}`)
|
||||
},
|
||||
importCsv(csv) {
|
||||
return api.post('/employees/directory/import', { csv })
|
||||
},
|
||||
// multipart photo upload; backend sets photofilename + returns photourl
|
||||
uploadPhoto(sso, file) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post(`/employees/${sso}/photo`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
},
|
||||
removePhoto(sso) {
|
||||
return api.delete(`/employees/${sso}/photo`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1549,115 +1597,4 @@ export const warrantyApi = {
|
||||
}
|
||||
}
|
||||
|
||||
// Measuring tools API (plugin)
|
||||
export const measuringtoolsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/measuringtools', { params })
|
||||
},
|
||||
// Every measuring tool, paged past the backend's 100-row cap. Batch label printing
|
||||
// and "pick any record" dropdowns must use this: list() with a large
|
||||
// perpage is clamped to 100 and still returns a success response, so
|
||||
// the tail simply goes missing.
|
||||
listAll(params = {}) {
|
||||
return fetchAllPages('/measuringtools', params)
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/measuringtools/${id}`)
|
||||
},
|
||||
getByAsset(assetid) {
|
||||
return api.get(`/measuringtools/by-asset/${assetid}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/measuringtools', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/measuringtools/${id}`, data)
|
||||
},
|
||||
remove(id) {
|
||||
return api.delete(`/measuringtools/${id}`)
|
||||
},
|
||||
calibrationReport() {
|
||||
return api.get('/measuringtools/report/calibration')
|
||||
},
|
||||
// Measuring-tool types
|
||||
types: {
|
||||
list(params = {}) {
|
||||
return api.get('/measuringtools/types', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/measuringtools/types/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/measuringtools/types', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/measuringtools/types/${id}`, data)
|
||||
},
|
||||
remove(id) {
|
||||
return api.delete(`/measuringtools/types/${id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3D printed parts (printedparts plugin)
|
||||
export const printedpartsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/printedparts/items', { params })
|
||||
},
|
||||
// Every printed item, paged past the backend's 100-row cap. The label sheet
|
||||
// must print the whole selection, not the first page of it.
|
||||
listAll(params = {}) {
|
||||
return fetchAllPages('/printedparts/items', params)
|
||||
},
|
||||
get(printeditemid) {
|
||||
return api.get(`/printedparts/items/${printeditemid}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/printedparts/items', data)
|
||||
},
|
||||
update(printeditemid, data) {
|
||||
return api.put(`/printedparts/items/${printeditemid}`, data)
|
||||
},
|
||||
remove(printeditemid) {
|
||||
return api.delete(`/printedparts/items/${printeditemid}`)
|
||||
},
|
||||
restore(printeditemid) {
|
||||
return api.post(`/printedparts/items/${printeditemid}/restore`)
|
||||
},
|
||||
uploadImage(printeditemid, file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return api.post(`/printedparts/items/${printeditemid}/image`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
deleteImage(printeditemid) {
|
||||
return api.delete(`/printedparts/items/${printeditemid}/image`)
|
||||
},
|
||||
restock(printeditemid, data) {
|
||||
return api.post(`/printedparts/items/${printeditemid}/restock`, data)
|
||||
},
|
||||
adjust(printeditemid, data) {
|
||||
return api.post(`/printedparts/items/${printeditemid}/adjust`, data)
|
||||
},
|
||||
kioskItem(itemcode) {
|
||||
return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`)
|
||||
},
|
||||
kioskTake(data) {
|
||||
return api.post('/printedparts/kiosk/take', data)
|
||||
},
|
||||
listFiles(printeditemid) {
|
||||
return api.get(`/printedparts/items/${printeditemid}/files`)
|
||||
},
|
||||
uploadFile(printeditemid, file, note) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (note) formData.append('note', note)
|
||||
return api.post(`/printedparts/items/${printeditemid}/files`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
removeFile(fileid) {
|
||||
return api.delete(`/printedparts/files/${fileid}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -922,6 +922,29 @@ input[type="radio"] {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Report a Bug sits directly above the theme toggle and matches it exactly.
|
||||
The sidebar is dark in BOTH themes, so these are fixed white alphas like the
|
||||
rules above rather than theme tokens - a var(--text-*) here would resolve to
|
||||
a dark colour on a dark sidebar in light mode and disappear. */
|
||||
.bug-report-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1.25rem;
|
||||
color: rgba(255,255,255,0.7);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bug-report-link:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* User menu */
|
||||
.user-menu {
|
||||
padding: 1rem 1.25rem;
|
||||
|
||||
@@ -205,7 +205,7 @@
|
||||
<script setup>
|
||||
import { assetRoute } from '@/utils/assetRoute'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { Cog, Monitor, Printer, Globe, Package, Ruler } from 'lucide-vue-next'
|
||||
import { Cog, Monitor, Printer, Globe, Package } from 'lucide-vue-next'
|
||||
import { assetsApi, relationshipTypesApi } from '../api'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
@@ -508,8 +508,7 @@ function getAssetIcon(assettype) {
|
||||
'machine': Cog,
|
||||
'computer': Monitor,
|
||||
'printer': Printer,
|
||||
'network_device': Globe,
|
||||
'measuring_tool': Ruler
|
||||
'network_device': Globe
|
||||
}
|
||||
return icons[assettype] || Package
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Actions that act on the THING, not on its record. A page fills this
|
||||
with its own verbs - restart a PC, calibrate a measuring tool - so the
|
||||
with its own verbs - restart a PC, reboot a printer - so the
|
||||
band stays generic while the button sits beside the state it changes
|
||||
and the machine it would take down with it. Record actions (edit,
|
||||
retire, print, back) stay in the page header where they belong. -->
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
<template>
|
||||
<div class="employee-search">
|
||||
<div class="employee-search-container">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="form-control"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@input="onSearch"
|
||||
@keydown.enter.prevent="addCustom"
|
||||
/>
|
||||
<div v-if="results.length" class="employee-dropdown">
|
||||
<div
|
||||
v-for="emp in results"
|
||||
:key="emp.SSO"
|
||||
class="employee-option"
|
||||
@click="selectEmployee(emp)"
|
||||
>
|
||||
<span class="emp-name">{{ emp.First_Name }} {{ emp.Last_Name }}</span>
|
||||
<span class="emp-sso">{{ emp.SSO }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small v-if="allowCustom" class="form-hint">Search for employees or press Enter to add a custom name</small>
|
||||
|
||||
<!-- Selected employee(s) display -->
|
||||
<div v-if="multiple && selectedList.length" class="selected-employees">
|
||||
<div
|
||||
v-for="(emp, idx) in selectedList"
|
||||
:key="idx"
|
||||
class="selected-employee"
|
||||
>
|
||||
<span>{{ emp.name }}</span>
|
||||
<span v-if="emp.sso && !emp.sso.startsWith('NAME:')" class="emp-sso-tag">{{ emp.sso }}</span>
|
||||
<button v-if="!disabled" type="button" class="btn-remove" @click="removeEmployee(idx)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!multiple && selected" class="selected-employee-display">
|
||||
<span>{{ selected.name }}</span>
|
||||
<span v-if="selected.sso" class="emp-sso-tag">{{ selected.sso }}</span>
|
||||
<button v-if="!disabled" type="button" class="btn-remove" @click="clearSelection">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { employeesApi } from '../api'
|
||||
import { resolveEmployeeName } from '../composables/employeeResolve'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Object, Array], default: null },
|
||||
multiple: { type: Boolean, default: false },
|
||||
allowCustom: { type: Boolean, default: false },
|
||||
placeholder: { type: String, default: 'Search by name...' },
|
||||
disabled: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const searchQuery = ref('')
|
||||
const results = ref([])
|
||||
const selected = ref(null)
|
||||
const selectedList = ref([])
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
// Initialize from modelValue
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (props.multiple) {
|
||||
selectedList.value = val || []
|
||||
} else {
|
||||
selected.value = val
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function onSearch() {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
|
||||
const query = searchQuery.value.trim()
|
||||
if (query.length < 2) {
|
||||
results.value = []
|
||||
return
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await employeesApi.search(query)
|
||||
results.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Employee search error:', err)
|
||||
results.value = []
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function selectEmployee(emp) {
|
||||
const employee = {
|
||||
sso: String(emp.SSO),
|
||||
name: `${emp.First_Name} ${emp.Last_Name}`.trim()
|
||||
}
|
||||
|
||||
if (props.multiple) {
|
||||
// Check if already selected
|
||||
if (selectedList.value.some(e => e.sso === employee.sso)) {
|
||||
return
|
||||
}
|
||||
selectedList.value.push(employee)
|
||||
emit('update:modelValue', selectedList.value)
|
||||
} else {
|
||||
selected.value = employee
|
||||
emit('update:modelValue', employee)
|
||||
}
|
||||
|
||||
searchQuery.value = ''
|
||||
results.value = []
|
||||
}
|
||||
|
||||
async function addCustom() {
|
||||
const name = searchQuery.value.trim()
|
||||
if (!name) return
|
||||
|
||||
// Look it up before deciding it is free text. Picking a suggestion gave a
|
||||
// real SSO; typing the same name in full and pressing Enter gave
|
||||
// `NAME:<text>`, linked to nothing - and the chip looked the same either
|
||||
// way. Nobody types a name in full expecting a different record.
|
||||
const { match, matches, failed } = await resolveEmployeeName(name)
|
||||
if (match) {
|
||||
selectEmployee(match)
|
||||
return
|
||||
}
|
||||
if (matches && matches.length) {
|
||||
// Ambiguous: show the candidates rather than guessing, which is exactly
|
||||
// when guessing wrong matters.
|
||||
results.value = matches
|
||||
return
|
||||
}
|
||||
if (failed) {
|
||||
// The directory is unreachable. Falling through to a custom label here
|
||||
// would quietly turn a real employee into free text.
|
||||
return
|
||||
}
|
||||
if (!props.allowCustom) return
|
||||
|
||||
const employee = {
|
||||
sso: `NAME:${name}`,
|
||||
name: name
|
||||
}
|
||||
|
||||
if (props.multiple) {
|
||||
selectedList.value.push(employee)
|
||||
emit('update:modelValue', selectedList.value)
|
||||
} else {
|
||||
selected.value = employee
|
||||
emit('update:modelValue', employee)
|
||||
}
|
||||
|
||||
searchQuery.value = ''
|
||||
results.value = []
|
||||
}
|
||||
|
||||
function removeEmployee(idx) {
|
||||
selectedList.value.splice(idx, 1)
|
||||
emit('update:modelValue', selectedList.value)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selected.value = null
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.employee-search-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.employee-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-card-solid, #1a1a1a);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.employee-option {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.employee-option:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.emp-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.emp-sso {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Multiple selection */
|
||||
.selected-employees {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.selected-employee {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Single selection */
|
||||
.selected-employee-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.emp-sso-tag {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
129
frontend/src/components/NetworkAllowlistPicker.vue
Normal file
129
frontend/src/components/NetworkAllowlistPicker.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="allowlist-picker">
|
||||
<div v-if="loading" class="picker-note">Loading networks...</div>
|
||||
|
||||
<div v-else-if="subnets.length" class="subnet-list">
|
||||
<label v-for="subnet in subnets" :key="subnet.subnetid" class="subnet-row">
|
||||
<input type="checkbox" :value="subnet.cidr"
|
||||
:checked="isSelected(subnet.cidr)"
|
||||
@change="toggle(subnet.cidr)" />
|
||||
<span class="subnet-cidr">{{ subnet.cidr }}</span>
|
||||
<span class="subnet-name">{{ subnet.name }}</span>
|
||||
<span v-if="subnet.vlannumber" class="subnet-vlan">
|
||||
VLAN {{ subnet.vlannumber }}<template v-if="subnet.vlanname"> - {{ subnet.vlanname }}</template>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-else class="picker-note">
|
||||
{{ unavailableMessage }}
|
||||
</p>
|
||||
|
||||
<label class="manual-label">Allowed networks (CIDR, comma separated)</label>
|
||||
<input :value="modelValue" type="text" class="form-control"
|
||||
placeholder="empty = device token only"
|
||||
@input="$emit('update:modelValue', $event.target.value)" />
|
||||
|
||||
<p v-if="extraEntries.length" class="picker-note">
|
||||
Not from the network inventory: {{ extraEntries.join(', ') }}
|
||||
</p>
|
||||
<p v-if="invalidEntries.length" class="picker-warn">
|
||||
Not valid CIDR and will never match: {{ invalidEntries.join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Picks networks for a kiosk allowlist from the network plugin's subnet
|
||||
// inventory, and stays usable without it.
|
||||
//
|
||||
// Stores resolved CIDR STRINGS, not subnet ids. The setting has to keep working
|
||||
// at a site that does not run the network plugin, and must not break when an
|
||||
// inventory row is edited or deleted. The trade is that a CIDR edited in the
|
||||
// inventory leaves a stale copy here - which is why entries that match no
|
||||
// subnet are listed back to the administrator rather than hidden.
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { fetchAllPages } from '@/api'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const subnets = ref([])
|
||||
const loading = ref(true)
|
||||
const unavailableMessage = ref('No subnets found. Enter networks by hand below.')
|
||||
|
||||
function entries (raw) {
|
||||
return String(raw || '').split(',').map(part => part.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
const selected = computed(() => entries(props.modelValue))
|
||||
|
||||
function isSelected (cidr) {
|
||||
return selected.value.includes(cidr)
|
||||
}
|
||||
|
||||
function toggle (cidr) {
|
||||
const current = selected.value
|
||||
const next = current.includes(cidr)
|
||||
? current.filter(entry => entry !== cidr)
|
||||
: [...current, cidr]
|
||||
emit('update:modelValue', next.join(', '))
|
||||
}
|
||||
|
||||
// Entries the administrator typed that no subnet record matches. Not an error -
|
||||
// a range may simply not be inventoried - but worth showing, because a stale
|
||||
// entry left behind by an inventory edit looks exactly like this.
|
||||
const extraEntries = computed(() => {
|
||||
const known = new Set(subnets.value.map(subnet => subnet.cidr))
|
||||
return selected.value.filter(entry => !known.has(entry))
|
||||
})
|
||||
|
||||
// Shape check only, mirroring the server: anything the server cannot parse is
|
||||
// skipped there, so it silently admits nobody. Better to say so here.
|
||||
const CIDR_SHAPE = /^\d{1,3}(\.\d{1,3}){3}\/\d{1,2}$/
|
||||
const invalidEntries = computed(
|
||||
() => selected.value.filter(entry => !CIDR_SHAPE.test(entry) && !entry.includes(':')))
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
subnets.value = await fetchAllPages('/network/subnets')
|
||||
} catch (error) {
|
||||
// The network plugin may be disabled at this site, or the signed-in user
|
||||
// may not hold network.view. Neither stops an administrator configuring
|
||||
// the allowlist by hand, so degrade instead of failing.
|
||||
subnets.value = []
|
||||
unavailableMessage.value =
|
||||
'Network inventory unavailable. Enter networks by hand below.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subnet-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color, #ccc);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.subnet-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
.subnet-cidr { font-family: monospace; min-width: 12rem; }
|
||||
.subnet-name { font-weight: 600; }
|
||||
.subnet-vlan { color: var(--text-light); font-size: 0.85rem; }
|
||||
.manual-label { display: block; font-weight: 600; margin-top: 0.5rem; }
|
||||
.picker-note { color: var(--text-light); font-size: 0.85rem; margin: 0.25rem 0; }
|
||||
.picker-warn { color: var(--warning-dark); font-size: 0.85rem; margin: 0.25rem 0; }
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@
|
||||
a panel as JSON (title, endpoint, render mode, field map); this renders it
|
||||
on any asset detail page, so a plugin adds detail-page UI with no Vue. -->
|
||||
<template v-for="panel in groupedPanels" :key="panel.id">
|
||||
<div v-if="panelVisible(panel)" class="section-card" :class="{ 'pap-span': panel.render === 'table' }">
|
||||
<div v-if="panelVisible(panel)" class="section-card" :class="{ 'pap-span': spansFullWidth(panel) }">
|
||||
<!-- collapsible panels get a clickable header with a caret and, when the
|
||||
panel declares a status rollup, a green/yellow/red dot for the worst
|
||||
row (a stopped service or a warning/critical event). --><h3
|
||||
@@ -52,7 +52,20 @@
|
||||
<div v-for="(item, index) in visibleRows(panel)" :key="index" class="pap-item">
|
||||
<div class="pap-item-top">
|
||||
<span class="pap-title">{{ mapTitle(panel, item) }}</span>
|
||||
<span v-if="mapBadge(panel, item)" class="status-badge"
|
||||
<!-- On a row carrying a body the severity badge IS the expander:
|
||||
it already says what the row is, and a second control beside
|
||||
it only repeated that. Rows with no body keep the plain
|
||||
non-interactive badge. -->
|
||||
<button
|
||||
v-if="mapBadge(panel, item) && mapBody(panel, item)"
|
||||
type="button"
|
||||
class="status-badge pap-badge-toggle"
|
||||
:style="colorStyle(mapBadge(panel, item).color)"
|
||||
:aria-expanded="isBodyOpen(panel, index) ? 'true' : 'false'"
|
||||
:title="isBodyOpen(panel, index) ? 'Hide details' : 'Show details'"
|
||||
@click="toggleBody(panel, index)"
|
||||
>{{ mapBadge(panel, item).label }}</button>
|
||||
<span v-else-if="mapBadge(panel, item)" class="status-badge"
|
||||
:style="colorStyle(mapBadge(panel, item).color)">
|
||||
{{ mapBadge(panel, item).label }}
|
||||
</span>
|
||||
@@ -68,6 +81,9 @@
|
||||
<span v-for="(m, mi) in mapMeta(panel, item)" :key="mi"
|
||||
:class="{ mono: m.mono }">{{ m.text }}</span>
|
||||
</div>
|
||||
<div v-if="isBodyOpen(panel, index) && mapBody(panel, item)" class="pap-body">
|
||||
{{ mapBody(panel, item) }}
|
||||
</div>
|
||||
</div>
|
||||
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
|
||||
{{ panel.manage.label || 'Manage' }}
|
||||
@@ -104,7 +120,42 @@
|
||||
@click="setTab(panel, si)"
|
||||
>{{ section.label }}</button>
|
||||
</div>
|
||||
<div class="pap-kv">
|
||||
<!-- A section carrying columns renders as a table; otherwise it is the
|
||||
original label/value grid. Both shapes coexist in one panel. -->
|
||||
<div
|
||||
v-if="((tabSections(panel)[activeTab(panel)] || {}).columns || []).length"
|
||||
class="pap-table-wrap"
|
||||
>
|
||||
<table
|
||||
v-if="((tabSections(panel)[activeTab(panel)] || {}).rows || []).length"
|
||||
class="pap-table"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
v-for="col in (tabSections(panel)[activeTab(panel)] || {}).columns"
|
||||
:key="col.key"
|
||||
>{{ col.label }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, ri) in (tabSections(panel)[activeTab(panel)] || {}).rows || []"
|
||||
:key="ri"
|
||||
>
|
||||
<td
|
||||
v-for="col in (tabSections(panel)[activeTab(panel)] || {}).columns"
|
||||
:key="col.key"
|
||||
>{{ cell(row, col) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
v-if="!((tabSections(panel)[activeTab(panel)] || {}).rows || []).length"
|
||||
class="pap-empty"
|
||||
>{{ (tabSections(panel)[activeTab(panel)] || {}).empty || 'Nothing reported.' }}</div>
|
||||
</div>
|
||||
<div v-else class="pap-kv">
|
||||
<div
|
||||
v-for="(field, fi) in (tabSections(panel)[activeTab(panel)] || {}).fields || []"
|
||||
:key="fi"
|
||||
@@ -126,7 +177,10 @@
|
||||
|
||||
<!-- table: columns declared by the panel (or inferred from row keys) -->
|
||||
<div v-else-if="panel.render === 'table'" class="pap-table-wrap">
|
||||
<table class="pap-table">
|
||||
<!-- Headers are suppressed when there are no rows. A bare header row
|
||||
over an empty body reads as a broken card; the declared empty
|
||||
message says which of "nothing here" or "nothing reported" it is. -->
|
||||
<table v-if="rows(panel).length" class="pap-table">
|
||||
<thead>
|
||||
<tr><th v-for="col in tableColumns(panel)" :key="col.key">{{ col.label }}</th></tr>
|
||||
</thead>
|
||||
@@ -136,6 +190,9 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="pap-empty">
|
||||
<span class="muted">{{ panel.empty || 'Nothing to show.' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- badge: a row of colored badges -->
|
||||
@@ -240,9 +297,9 @@ import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||
import api from '../api'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import {
|
||||
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
|
||||
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, mapBody, manageLink,
|
||||
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
|
||||
rowAction, facets, facetedRows, formatValue,
|
||||
rowAction, facets, facetedRows, formatValue, spansFullWidth,
|
||||
} from './pluginAssetPanels'
|
||||
|
||||
// Operator report screenshots are served by a JWT-gated route, so a bare
|
||||
@@ -252,8 +309,13 @@ import {
|
||||
// unmount so the blobs are not leaked.
|
||||
const reportImageUrls = ref({})
|
||||
|
||||
// Operator reports carry a full timestamp, and the time of day is the useful
|
||||
// part: several reports from one shift all landed on the same date, so a
|
||||
// date-only label made them indistinguishable and un-orderable. formatValue's
|
||||
// 'date' mode renders via toLocaleDateString and discards the clock, so this
|
||||
// asks for 'datetime'.
|
||||
function formatDate(value) {
|
||||
return formatValue(value, { format: 'date' })
|
||||
return formatValue(value, { format: 'datetime' })
|
||||
}
|
||||
|
||||
// Called from the template, so it runs on EVERY render. Without the seen-set it
|
||||
@@ -365,12 +427,39 @@ function visibleRows(panel) {
|
||||
return facetedRows(panel, facetSelection.value[panel.id])
|
||||
}
|
||||
|
||||
// Which row bodies are expanded, keyed by panel id and row index. Closed on
|
||||
// every panel refresh: the row at index 3 after a refresh is not necessarily
|
||||
// the row that was opened, so keeping it open would expand an unrelated event.
|
||||
const bodyOpen = ref({})
|
||||
|
||||
function bodyKey(panel, index) {
|
||||
return `${panel.id}:${index}`
|
||||
}
|
||||
|
||||
function isBodyOpen(panel, index) {
|
||||
return !!bodyOpen.value[bodyKey(panel, index)]
|
||||
}
|
||||
|
||||
function toggleBody(panel, index) {
|
||||
const key = bodyKey(panel, index)
|
||||
bodyOpen.value = { ...bodyOpen.value, [key]: !bodyOpen.value[key] }
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
assetid: { type: [Number, String], default: null },
|
||||
// Which detail-page tab to render. Null renders every panel, which is what
|
||||
// the detail pages that have not adopted tabs still do - so this stays
|
||||
// additive and no existing page changes behaviour.
|
||||
group: { type: String, default: null },
|
||||
// Render exactly these panel ids, ignoring group. For lifting one card out
|
||||
// of its tab and showing it somewhere else - Network Interfaces is pinned to
|
||||
// the top of Details while still belonging to monitors. Kept as a panel-id
|
||||
// filter rather than a new group because a group would need the four-file
|
||||
// vocabulary change AND would add a tab nobody asked for.
|
||||
only: { type: Array, default: null },
|
||||
// The other half of `only`: the group instance must drop what was lifted out
|
||||
// of it, or the card renders twice on two different tabs.
|
||||
exclude: { type: Array, default: null },
|
||||
})
|
||||
|
||||
const panels = ref([])
|
||||
@@ -386,14 +475,19 @@ const panels = ref([])
|
||||
// 'actions', this build had no such tab, and the card was simply gone with
|
||||
// nothing logged. The same applies to the 'metrics' to 'monitors' rename.
|
||||
// Fall back the same way the server does rather than drop it.
|
||||
const RENDERED_GROUPS = ['details', 'monitors', 'software', 'activity']
|
||||
const RENDERED_GROUPS = ['details', 'monitors', 'serviceprocess', 'reports', 'software', 'activity']
|
||||
function groupOf(panel) {
|
||||
return RENDERED_GROUPS.includes(panel.group) ? panel.group : 'details'
|
||||
}
|
||||
|
||||
const groupedPanels = computed(() =>
|
||||
props.group ? panels.value.filter((p) => groupOf(p) === props.group) : panels.value
|
||||
)
|
||||
const groupedPanels = computed(() => {
|
||||
if (props.only) return panels.value.filter((p) => props.only.includes(p.id))
|
||||
let list = props.group
|
||||
? panels.value.filter((p) => groupOf(p) === props.group)
|
||||
: panels.value
|
||||
if (props.exclude) list = list.filter((p) => !props.exclude.includes(p.id))
|
||||
return list
|
||||
})
|
||||
|
||||
// Which groups actually have a panel with data, so a parent can hide a tab
|
||||
// rather than offering an empty one.
|
||||
@@ -525,6 +619,7 @@ async function load() {
|
||||
panels.value = []
|
||||
revokeReportImageUrls()
|
||||
closeReport()
|
||||
bodyOpen.value = {}
|
||||
if (!props.assetid) return
|
||||
let declared
|
||||
try {
|
||||
@@ -562,6 +657,31 @@ defineExpose({ refresh: load, populatedGroups })
|
||||
.pap-title { font-weight: 600; color: var(--text); }
|
||||
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
|
||||
.pap-meta { margin-top: 0.35rem; display: flex; flex-wrap: wrap; gap: 0.75rem; font-size: 0.82rem; color: var(--text-light); }
|
||||
/* The badge doubles as the expander, so it needs to look pressable without
|
||||
becoming a second style of badge: same pill, just a pointer and a hover
|
||||
lift. margin-left:auto pins it right even on a row that also has an
|
||||
action button, where space-between alone would centre it. */
|
||||
.pap-badge-toggle {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
.pap-badge-toggle:hover { filter: brightness(1.1); }
|
||||
.pap-badge-toggle:focus-visible { outline: 2px solid var(--primary, #4181ff); outline-offset: 2px; }
|
||||
/* pre-wrap, not normal: the message keeps the line breaks the event was
|
||||
written with, which is what makes a multi-line fault readable. */
|
||||
.pap-body {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: var(--surface-2, rgba(127, 127, 127, 0.08));
|
||||
border-radius: 4px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-light);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.pap-empty { display: flex; align-items: center; gap: 0.6rem; }
|
||||
|
||||
.pap-facets {
|
||||
|
||||
@@ -167,9 +167,7 @@ const assetTypeColorsMap = {
|
||||
'computer': '#2196F3', // Blue
|
||||
'printer': '#4CAF50', // Green
|
||||
'network device': '#FF9800', // Orange
|
||||
'network_device': '#FF9800', // Orange (alternate key)
|
||||
'measuring_tool': '#9C27B0', // Purple
|
||||
'measuring tool': '#9C27B0' // Purple (normalized key)
|
||||
'network_device': '#FF9800' // Orange (alternate key)
|
||||
}
|
||||
|
||||
// Get asset type color with case-insensitive lookup
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from './dashboardCards'
|
||||
|
||||
const failuresCard = {
|
||||
id: 'geenforce-failures',
|
||||
id: 'machines-failures',
|
||||
render: 'exceptions',
|
||||
severity: 'critical',
|
||||
position: 10,
|
||||
@@ -23,12 +23,12 @@ const failuresCard = {
|
||||
|
||||
describe('endpoint paths', () => {
|
||||
it('strips the api prefix the instance already carries', () => {
|
||||
expect(toApiPath('/api/geenforce/dashboard/failures'))
|
||||
.toBe('/geenforce/dashboard/failures')
|
||||
expect(toApiPath('/api/machines/dashboard/failures'))
|
||||
.toBe('/machines/dashboard/failures')
|
||||
})
|
||||
|
||||
it('leaves a relative endpoint alone', () => {
|
||||
expect(toApiPath('/geenforce/x')).toBe('/geenforce/x')
|
||||
expect(toApiPath('/machines/x')).toBe('/machines/x')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -151,7 +151,7 @@ describe('gating', () => {
|
||||
const has = (name) => name === 'printers.view'
|
||||
const kept = permittedCards([
|
||||
{ id: 'toner', permission: 'printers.view' },
|
||||
{ id: 'enforce', permission: 'geenforce.manage' },
|
||||
{ id: 'machines', permission: 'machines.manage' },
|
||||
{ id: 'open', permission: null },
|
||||
], has)
|
||||
expect(kept.map((c) => c.id)).toEqual(['toner', 'open'])
|
||||
@@ -243,8 +243,8 @@ const critA = {
|
||||
_data: [{ hostname: 'PC-CMM-TEST' }, { hostname: 'HOST-7' }],
|
||||
}
|
||||
const critB = {
|
||||
id: 'geenforce-failures', title: 'Enforcement failures', render: 'exceptions',
|
||||
severity: 'critical', position: 10, viewall: '/geenforce',
|
||||
id: 'machines-failures', title: 'Machine failures', render: 'exceptions',
|
||||
severity: 'critical', position: 10, viewall: '/machines',
|
||||
map: { title: 'hostname' },
|
||||
_data: [{ hostname: 'WJPC0615' }],
|
||||
}
|
||||
@@ -269,16 +269,16 @@ describe('queueRows', () => {
|
||||
})
|
||||
|
||||
it('orders by declared position within a severity', () => {
|
||||
// geenforce is position 10, endpoints 20: a failure outranks a crash.
|
||||
// machines is position 10, endpoints 20: a failure outranks a crash.
|
||||
const queue = queueRows([critA, critB])
|
||||
expect(queue[0].title).toBe('WJPC0615')
|
||||
})
|
||||
|
||||
it('keeps the source on every row, so a merged row can still say why', () => {
|
||||
const queue = queueRows([critB])
|
||||
expect(queue[0].source).toBe('Enforcement failures')
|
||||
expect(queue[0].sourceid).toBe('geenforce-failures')
|
||||
expect(queue[0].viewall).toBe('/geenforce')
|
||||
expect(queue[0].source).toBe('Machine failures')
|
||||
expect(queue[0].sourceid).toBe('machines-failures')
|
||||
expect(queue[0].viewall).toBe('/machines')
|
||||
})
|
||||
|
||||
it('leaves info cards out - they are context, not work', () => {
|
||||
@@ -440,7 +440,7 @@ describe('sourceFacets', () => {
|
||||
it('is one chip per contributing check, worst first', () => {
|
||||
const facets = sourceFacets([warnA, critA, critB])
|
||||
expect(facets.map((f) => f.key)).toEqual(
|
||||
['geenforce-failures', 'endpoints-stability', 'computers-quiet'])
|
||||
['machines-failures', 'endpoints-stability', 'computers-quiet'])
|
||||
})
|
||||
|
||||
it('labels with the shortname, since the title is a heading', () => {
|
||||
|
||||
51
frontend/src/components/emptyTablePanel.spec.js
Normal file
51
frontend/src/components/emptyTablePanel.spec.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// A `table` panel with no rows used to render a bare header row over an empty
|
||||
// body, which reads as a broken card rather than as an answer. `list` had
|
||||
// honoured panel.empty for a long time; `table` had not, for no reason anybody
|
||||
// wrote down. Found on a live page where Network Interfaces and Listening
|
||||
// Ports both showed column headers and nothing else.
|
||||
//
|
||||
// Asserted against the SOURCE because the condition lives in the template, not
|
||||
// in an exported helper - the same reason panelGroups.spec.js reads source.
|
||||
|
||||
const source = readFileSync(
|
||||
resolve(__dirname, 'PluginAssetPanels.vue'), 'utf8',
|
||||
)
|
||||
|
||||
function tableBlock() {
|
||||
const start = source.indexOf("panel.render === 'table'")
|
||||
expect(start).toBeGreaterThan(-1)
|
||||
// Up to the next render branch, so we only look at the table path.
|
||||
const end = source.indexOf("panel.render === 'badge'", start)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
describe('an empty table panel', () => {
|
||||
it('does not render the table element when there are no rows', () => {
|
||||
expect(tableBlock()).toMatch(/<table[^>]*v-if="rows\(panel\)\.length"/)
|
||||
})
|
||||
|
||||
it('falls back to the panel-declared empty message', () => {
|
||||
expect(tableBlock()).toContain('panel.empty')
|
||||
})
|
||||
|
||||
it('keeps a default message for a panel that declares none', () => {
|
||||
// An empty card with no words in it is indistinguishable from a card that
|
||||
// failed to load.
|
||||
expect(tableBlock()).toMatch(/panel\.empty \|\| '[^']+'/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('an empty table SECTION inside a tabs panel', () => {
|
||||
it('also suppresses its header row', () => {
|
||||
// The section path was added with the Storage merge and had the same bug.
|
||||
const start = source.indexOf("panel.render === 'tabs'")
|
||||
const end = source.indexOf("panel.render === 'keyvalue'", start)
|
||||
const block = source.slice(start, end)
|
||||
expect(block).toMatch(/<table\s+v-if="\(\(tabSections\(panel\)/)
|
||||
})
|
||||
})
|
||||
121
frontend/src/components/panelGroups.spec.js
Normal file
121
frontend/src/components/panelGroups.spec.js
Normal file
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// The group vocabulary is split across four files and there is no runtime that
|
||||
// checks they agree. When they disagree the failure is SILENT: a panel in a
|
||||
// group nothing renders is fetched, held, and shown on no tab, with nothing
|
||||
// logged. That has eaten cards twice - tracked services moving out of
|
||||
// 'actions', and the 'metrics' to 'monitors' rename.
|
||||
//
|
||||
// So this reads the sources and asserts the vocabularies match. It is a source
|
||||
// check rather than a behaviour check because RENDERED_GROUPS is a module-local
|
||||
// constant in the .vue file with no export to assert against.
|
||||
|
||||
function read(relative) {
|
||||
return readFileSync(fileURLToPath(new URL(relative, import.meta.url)), 'utf8')
|
||||
}
|
||||
|
||||
function renderedGroups() {
|
||||
const source = read('./PluginAssetPanels.vue')
|
||||
const match = source.match(/const RENDERED_GROUPS = \[([^\]]*)\]/)
|
||||
if (!match) throw new Error('RENDERED_GROUPS not found in PluginAssetPanels.vue')
|
||||
return match[1].split(',')
|
||||
.map((entry) => entry.trim().replace(/^'|'$/g, ''))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function pcDetailSource() {
|
||||
return read('../../../plugins/computers/frontend/views/PCDetail.vue')
|
||||
}
|
||||
|
||||
// The tabs that actually HOST panels - those where PCDetail mounts a
|
||||
// PluginAssetPanels for the group. A tab is only bound by the vocabulary if a
|
||||
// panel can land in it. Corporate Tools is the first tab that hosts none: it is
|
||||
// permission-gated and renders a fixed action list, so RENDERED_GROUPS has
|
||||
// nothing to say about it and requiring it there would make 'corptools' a legal
|
||||
// panel group with no panel host - fetched, held, rendered nowhere, which is
|
||||
// the very failure this file exists to catch.
|
||||
function panelHostedGroups(source) {
|
||||
return [...source.matchAll(/group="([a-z]+)"/g)].map((match) => match[1])
|
||||
}
|
||||
|
||||
describe('panel group vocabulary', () => {
|
||||
it('renders a group for every panel-backed tab PCDetail offers', () => {
|
||||
const groups = renderedGroups()
|
||||
const source = pcDetailSource()
|
||||
const hosted = panelHostedGroups(source)
|
||||
const tabKeys = [...source.matchAll(/tabs\.push\(\{\s*key:\s*'([a-z]+)'/g)]
|
||||
.map((match) => match[1])
|
||||
.filter((key) => hosted.includes(key))
|
||||
|
||||
expect(tabKeys.length).toBeGreaterThan(0)
|
||||
for (const key of tabKeys) {
|
||||
expect(groups).toContain(key)
|
||||
}
|
||||
})
|
||||
|
||||
it('never hosts panels in a group this build does not render', () => {
|
||||
// The mirror of that exemption, so opting a tab out of panels cannot become
|
||||
// a loophole: the moment a tab mounts a PluginAssetPanels, its group MUST
|
||||
// be rendered, or the panels are fetched and dropped with nothing logged.
|
||||
const groups = renderedGroups()
|
||||
const hosted = panelHostedGroups(pcDetailSource())
|
||||
|
||||
expect(hosted.length).toBeGreaterThan(0)
|
||||
for (const group of hosted) {
|
||||
expect(groups).toContain(group)
|
||||
}
|
||||
})
|
||||
|
||||
it('gives every rendered group somewhere to actually show', () => {
|
||||
// A group in RENDERED_GROUPS with no v-show block is the mirror failure:
|
||||
// the panel is kept rather than dropped, but still appears nowhere.
|
||||
const source = pcDetailSource()
|
||||
for (const group of renderedGroups()) {
|
||||
expect(source).toContain(`activegroup === '${group}'`)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the service/process split wired end to end', () => {
|
||||
// Pins the specific split rather than only the general rule, so removing
|
||||
// the tab is a deliberate edit to this test and not a silent regression.
|
||||
const source = pcDetailSource()
|
||||
|
||||
expect(renderedGroups()).toContain('serviceprocess')
|
||||
expect(source).toContain("key: 'serviceprocess'")
|
||||
expect(source).toContain("group=\"serviceprocess\"")
|
||||
})
|
||||
|
||||
it('still renders the system monitors group after the rename', () => {
|
||||
// The rename is label-only; the group key must not drift with it.
|
||||
expect(renderedGroups()).toContain('monitors')
|
||||
expect(pcDetailSource()).toContain("label: 'System Monitors'")
|
||||
})
|
||||
|
||||
it('agrees with the server vocabulary that resolves groups first', () => {
|
||||
// The fourth file this comment block claims to cover, and the one that was
|
||||
// NOT actually being checked. asset_panels() rewrites any group missing
|
||||
// from ASSET_PANEL_GROUPS to 'details' before the client sees it, so the
|
||||
// frontend can be perfectly consistent with itself and still show nothing:
|
||||
// that is exactly how the Service/Process tab went missing after Phase 3.
|
||||
const source = read('../../../shopdb/core/api/pluginui.py')
|
||||
const match = source.match(/ASSET_PANEL_GROUPS = \(([\s\S]*?)\)/)
|
||||
if (!match) throw new Error('ASSET_PANEL_GROUPS not found in pluginui.py')
|
||||
const serverGroups = match[1].split(',')
|
||||
.map((entry) => entry.trim().replace(/^'|'$/g, ''))
|
||||
.filter(Boolean)
|
||||
|
||||
for (const group of renderedGroups()) {
|
||||
expect(serverGroups).toContain(group)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps operator reports in a tab of its own', () => {
|
||||
const source = pcDetailSource()
|
||||
|
||||
expect(renderedGroups()).toContain('reports')
|
||||
expect(source).toContain("key: 'reports'")
|
||||
expect(source).toContain('group="reports"')
|
||||
})
|
||||
})
|
||||
208
frontend/src/components/pinnedPanels.spec.js
Normal file
208
frontend/src/components/pinnedPanels.spec.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// Network Interfaces is DECLARED in the monitors group but RENDERED at the top
|
||||
// of the Details tab. That is done with a panel-id filter (`only`) rather than
|
||||
// a new group, because a new group would need the four-file vocabulary change
|
||||
// and would add a tab nobody asked for.
|
||||
//
|
||||
// The pair is the fragile part: `only` lifts the panel out, and `exclude` must
|
||||
// drop it from the group instance it was lifted from. Miss the second half and
|
||||
// the same card renders on two tabs, which looks like duplicated data rather
|
||||
// than a layout bug.
|
||||
|
||||
function read(relative) {
|
||||
return readFileSync(fileURLToPath(new URL(relative, import.meta.url)), 'utf8')
|
||||
}
|
||||
|
||||
const panelsSource = () => read('./PluginAssetPanels.vue')
|
||||
const pcDetailSource = () => read('../../../plugins/computers/frontend/views/PCDetail.vue')
|
||||
|
||||
describe('lifting one panel out of its group', () => {
|
||||
it('accepts an only prop', () => {
|
||||
expect(panelsSource()).toMatch(/only:\s*\{\s*type:\s*Array/)
|
||||
})
|
||||
|
||||
it('accepts an exclude prop', () => {
|
||||
expect(panelsSource()).toMatch(/exclude:\s*\{\s*type:\s*Array/)
|
||||
})
|
||||
|
||||
it('lets only bypass the group filter entirely', () => {
|
||||
// Details renders a panel belonging to monitors, so `only` must NOT be
|
||||
// and-ed with the group or it would match nothing.
|
||||
const source = panelsSource()
|
||||
const start = source.indexOf('const groupedPanels')
|
||||
const block = source.slice(start, start + 400)
|
||||
expect(block).toMatch(/if\s*\(props\.only\)/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the pinned Network Interfaces card', () => {
|
||||
it('renders at the top of Details by panel id', () => {
|
||||
expect(pcDetailSource()).toContain("only=\"['endpoints-interfaces']\"")
|
||||
})
|
||||
|
||||
it('is excluded from System Monitors so it cannot render twice', () => {
|
||||
// Scoped to the monitors instance and matched on membership, not on the
|
||||
// exact array text: more than one panel is lifted now.
|
||||
const source = pcDetailSource()
|
||||
const start = source.indexOf('group="monitors"')
|
||||
const block = source.slice(start, start + 200)
|
||||
expect(block).toContain('endpoints-interfaces')
|
||||
})
|
||||
|
||||
it('is still declared in monitors, not moved to a new group', () => {
|
||||
// One declaration, no vocabulary change. If somebody "fixes" this by
|
||||
// adding a reports-style group, the four-file dance applies again.
|
||||
// Bounded by the NEXT panel's id: the columns list contains '},' so
|
||||
// slicing to that would cut the block off before the group key.
|
||||
const plugin = read('../../../plugins/endpoints/plugin.py')
|
||||
const start = plugin.indexOf("'id': 'endpoints-interfaces'")
|
||||
const next = plugin.indexOf("'id': '", start + 10)
|
||||
const block = plugin.slice(start, next === -1 ? undefined : next)
|
||||
expect(block).toContain("'group': 'monitors'")
|
||||
})
|
||||
})
|
||||
|
||||
describe('the header cloud identity block', () => {
|
||||
it('shows the block on template presence, not on this PC having an id', () => {
|
||||
// A site with no Intune/Entra at all must not see a permanent request for
|
||||
// an id it will never have.
|
||||
const source = pcDetailSource()
|
||||
const start = source.indexOf('const hasCloudIdentity')
|
||||
const block = source.slice(start, start + 260)
|
||||
expect(block).toContain('entraUrlTemplate.value')
|
||||
expect(block).toContain('intuneUrlTemplate.value')
|
||||
})
|
||||
|
||||
it('waits for the portal templates before rendering', () => {
|
||||
// The templates come from an async settings fetch. Rendering the ids
|
||||
// first and adding the buttons a moment later looked like the buttons
|
||||
// were missing until the page was manually refreshed.
|
||||
const source = pcDetailSource()
|
||||
const start = source.indexOf('const hasCloudIdentity')
|
||||
const block = source.slice(start, start + 260)
|
||||
expect(block).toContain('portalTemplatesLoaded.value')
|
||||
})
|
||||
|
||||
it('marks the templates settled even when the fetch fails', () => {
|
||||
// Otherwise a down settings backend hides the block permanently.
|
||||
// Bounded by the function's own closing brace rather than a character
|
||||
// count: a fixed window silently excluded the finally block as soon as
|
||||
// the retry logic grew, which reported as "the finally is missing" when
|
||||
// the real code was correct. Slice to the next top-level declaration.
|
||||
const source = pcDetailSource()
|
||||
const start = source.indexOf('async function loadPortalTemplates')
|
||||
const next = source.indexOf('\nonUnmounted(', start)
|
||||
const block = source.slice(start, next === -1 ? undefined : next)
|
||||
expect(block).toContain('finally')
|
||||
expect(block).toContain('portalTemplatesLoaded.value = true')
|
||||
})
|
||||
|
||||
it('names which id is missing rather than rendering an empty block', () => {
|
||||
const source = pcDetailSource()
|
||||
expect(source).toContain('Please provide DeviceID')
|
||||
expect(source).toContain('Please provide ObjectID')
|
||||
})
|
||||
|
||||
it('no longer carries the old Entra Device Info card', () => {
|
||||
expect(pcDetailSource()).not.toContain('<h3 class="section-title">Entra Device Info</h3>')
|
||||
})
|
||||
|
||||
it('no longer carries the hand-written Network card', () => {
|
||||
// Its two halves are the IP History and IP Configuration panels now.
|
||||
expect(pcDetailSource()).not.toContain('<h3 class="section-title">Network</h3>')
|
||||
})
|
||||
|
||||
it('keeps the full ids on screen so they can be copied', () => {
|
||||
// The move to the header was a MOVE, not a redesign. Rendering only an
|
||||
// "Open device" link loses the id itself, which gets pasted into tickets
|
||||
// and Graph queries. Both ids must still be printed in full.
|
||||
const source = pcDetailSource()
|
||||
expect(source).toContain('computer.computer.azuredeviceid }}')
|
||||
expect(source).toContain('computer.computer.azureobjectid }}')
|
||||
})
|
||||
|
||||
it('offers a copy button for each id', () => {
|
||||
// Clicking to select still leaves the operator to press ctrl+c; these ids
|
||||
// get pasted into tickets and Graph queries constantly.
|
||||
const source = pcDetailSource()
|
||||
expect(source).toContain("copyId(computer.computer.azuredeviceid, 'Device ID')")
|
||||
expect(source).toContain("copyId(computer.computer.azureobjectid, 'Object ID')")
|
||||
})
|
||||
|
||||
it('reports a failed copy instead of a false success', () => {
|
||||
// clipboard.writeText is undefined over plain HTTP and rejects when the
|
||||
// tab is unfocused. Claiming success there would be a lie.
|
||||
const source = pcDetailSource()
|
||||
const start = source.indexOf('async function copyId')
|
||||
const block = source.slice(start, start + 420)
|
||||
expect(block).toContain('catch')
|
||||
expect(block).toContain('toast.error')
|
||||
})
|
||||
|
||||
it('keeps the id selectable as a clipboard fallback', () => {
|
||||
const source = pcDetailSource()
|
||||
const start = source.indexOf('.hero-portal-id {')
|
||||
const block = source.slice(start, start + 260)
|
||||
expect(block).toContain('user-select: all')
|
||||
})
|
||||
|
||||
it('does not truncate an id with an ellipsis', () => {
|
||||
// A half-copied GUID is worse than no GUID: it looks complete.
|
||||
const source = pcDetailSource()
|
||||
const start = source.indexOf('.hero-portal-id {')
|
||||
const block = source.slice(start, start + 260)
|
||||
expect(block).not.toContain('text-overflow: ellipsis')
|
||||
expect(block).toContain('user-select: all')
|
||||
})
|
||||
|
||||
it('keeps the original Intune and Entra buttons', () => {
|
||||
// These carry a race-condition fix. Relabelling them to "Open device" /
|
||||
// "Open object" threw that away once already.
|
||||
const source = pcDetailSource()
|
||||
expect(source).toContain('>Intune</a>')
|
||||
expect(source).toContain('>Entra</a>')
|
||||
})
|
||||
|
||||
it('lays the portal block out horizontally, not as a narrow rail', () => {
|
||||
// A fixed-width right rail wrapped 36-character GUIDs into a tall ragged
|
||||
// stack that grew past the identity block beside it.
|
||||
const source = pcDetailSource()
|
||||
const start = source.indexOf('.hero-portals {')
|
||||
const block = source.slice(start, start + 260)
|
||||
expect(block).toContain('flex-wrap: wrap')
|
||||
expect(block).not.toContain('width: 300px')
|
||||
})
|
||||
|
||||
it('does not restyle the header classes other assets share', () => {
|
||||
// Machines, Printers and friends share .hero-details/.hero-detail-* from
|
||||
// global style.css. A PC-only header must not redefine them.
|
||||
const source = pcDetailSource()
|
||||
const style = source.slice(source.indexOf('<style scoped>'))
|
||||
expect(style).not.toContain('.hero-details {')
|
||||
expect(style).not.toContain('.hero-detail-label {')
|
||||
expect(style).not.toContain('.hero-detail-value {')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the lifted Mapped drives card', () => {
|
||||
it('renders on Details', () => {
|
||||
expect(pcDetailSource()).toContain("['endpoints-mappeddrives']")
|
||||
})
|
||||
|
||||
it('is excluded from System Monitors so it renders once', () => {
|
||||
const source = pcDetailSource()
|
||||
const start = source.indexOf('group="monitors"')
|
||||
const block = source.slice(start, start + 200)
|
||||
expect(block).toContain('endpoints-mappeddrives')
|
||||
})
|
||||
|
||||
it('is still declared in monitors, not moved to a new group', () => {
|
||||
const plugin = read('../../../plugins/endpoints/plugin.py')
|
||||
const start = plugin.indexOf("'id': 'endpoints-mappeddrives'")
|
||||
const block = plugin.slice(start, start + 1200)
|
||||
expect(block).toContain("'group': 'monitors'")
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
// Pure render helpers for PluginAssetPanels.vue (ADR-010 get_asset_panels, Path
|
||||
// A). Kept separate from the .vue so the mapping logic - which turns a plugin's
|
||||
// JSON panel declaration + its endpoint data into rendered rows - is unit
|
||||
// tested directly (same pattern as geenforce/entryForm.js).
|
||||
// tested directly (the same pattern a plugin's own entry-form module uses).
|
||||
|
||||
export function subst(text, assetid) {
|
||||
return String(text).replace('{assetid}', assetid)
|
||||
@@ -19,6 +19,15 @@ export function formatValue(value, spec = {}) {
|
||||
const raw = String(value)
|
||||
return new Date(raw + (raw.length === 10 ? 'T00:00:00' : '')).toLocaleDateString()
|
||||
}
|
||||
// Timestamps arrive as UTC ISO ("2026-09-04T16:13:29Z") because the wire
|
||||
// format is UTC by contract. Render in the viewer's local zone: an operator
|
||||
// comparing "last reported" against the clock on the wall cannot do the
|
||||
// offset in their head, and the Z suffix invites reading it as local.
|
||||
if (spec.format === 'datetime') {
|
||||
const parsed = new Date(String(value))
|
||||
if (isNaN(parsed)) return value
|
||||
return parsed.toLocaleString()
|
||||
}
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||
return value
|
||||
}
|
||||
@@ -138,6 +147,14 @@ export function mapMeta(panel, item) {
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function mapBody(panel, item) {
|
||||
const key = panel.map && panel.map.body
|
||||
if (!key) return null
|
||||
const value = item[key]
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function manageLink(panel, assetid) {
|
||||
return panel.manage ? subst(panel.manage.to, assetid) : null
|
||||
}
|
||||
@@ -210,17 +227,38 @@ export function rollupStatus(panel) {
|
||||
// tabs: [{ label, fields: [{label, value, mono}] }]. One section is shown at a
|
||||
// time, so a panel with 30 values stays the height of its largest section
|
||||
// instead of dragging one multicol column far past the other.
|
||||
//
|
||||
// A section may instead carry { columns, rows, empty } and render as a table.
|
||||
// This exists so two tables that belong side by side for the reader but share
|
||||
// NO key can sit in one card - Storage is per volume, Disk health is per
|
||||
// physical device. The sections are independent tables by design: nothing here
|
||||
// reconciles rows across them, unlike the mapped-drive merge where two sources
|
||||
// described the same drive.
|
||||
export function tabSections(panel) {
|
||||
const data = panel._data
|
||||
const sections = (data && Array.isArray(data.sections)) ? data.sections : []
|
||||
return sections
|
||||
.map((s) => ({
|
||||
label: s.label,
|
||||
fields: (s.fields || []).map((f) => ({
|
||||
label: f.label, value: formatValue(f.value, f), mono: !!f.mono,
|
||||
})),
|
||||
}))
|
||||
.filter((s) => s.fields.length > 0)
|
||||
.map((s) => {
|
||||
if (Array.isArray(s.columns) && s.columns.length) {
|
||||
return {
|
||||
label: s.label,
|
||||
columns: s.columns.map((c) => ({ key: c.key, label: c.label || humanize(c.key) })),
|
||||
rows: Array.isArray(s.rows) ? s.rows : [],
|
||||
empty: s.empty || '',
|
||||
fields: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: s.label,
|
||||
fields: (s.fields || []).map((f) => ({
|
||||
label: f.label, value: formatValue(f.value, f), mono: !!f.mono,
|
||||
})),
|
||||
}
|
||||
})
|
||||
// A table section declares its columns up front, so it survives having no
|
||||
// rows and shows its own empty message. Dropping it would take the tab with
|
||||
// it and read as "this machine has no disks" rather than "nothing reported".
|
||||
.filter((s) => s.fields.length > 0 || (s.columns && s.columns.length > 0))
|
||||
}
|
||||
|
||||
// Inline row action for a list panel. A monitored services/processes row may
|
||||
@@ -247,6 +285,16 @@ export function rowAction(panel, item) {
|
||||
}
|
||||
}
|
||||
|
||||
// A table needs the full card width. `table` panels get it via panel.render,
|
||||
// but a `tabs` panel only needs it when one of its sections is a table, and
|
||||
// that is not knowable from the declaration - it depends on fetched data.
|
||||
export function spansFullWidth(panel) {
|
||||
if (!panel) return false
|
||||
if (panel.render === 'table') return true
|
||||
if (panel.render !== 'tabs') return false
|
||||
return tabSections(panel).some((s) => (s.columns || []).length > 0)
|
||||
}
|
||||
|
||||
// A panel declaring `empty` is asking to stay visible with nothing in it and
|
||||
// say so - "No installed software reported." is an answer, where an absent
|
||||
// card is a question. `list` honoured that and `table` did not, for no reason
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink,
|
||||
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, mapBody, manageLink,
|
||||
keyvalueFields, tableColumns, badges, panelVisible, tabSections, rollupStatus,
|
||||
rowAction, facets, facetedRows,
|
||||
} from './pluginAssetPanels'
|
||||
@@ -64,7 +64,6 @@ describe('list render mapping (warranty)', () => {
|
||||
it('builds the manage link with the assetid', () => {
|
||||
expect(manageLink(warrantyPanel, 7)).toBe('/warranties?addfor=7')
|
||||
})
|
||||
|
||||
it('is visible when it has rows or an empty message', () => {
|
||||
expect(panelVisible(warrantyPanel)).toBe(true)
|
||||
expect(panelVisible({ ...warrantyPanel, _data: [] })).toBe(true) // has empty
|
||||
@@ -72,6 +71,24 @@ describe('list render mapping (warranty)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('expandable row body (activity feed)', () => {
|
||||
const activityPanel = {
|
||||
id: 'endpoints-activity', render: 'list',
|
||||
map: { title: 'title', body: 'body' },
|
||||
}
|
||||
|
||||
it('returns the body only for rows that carry one', () => {
|
||||
expect(mapBody(activityPanel, { title: 'Crash', body: 'Faulting module' }))
|
||||
.toBe('Faulting module')
|
||||
expect(mapBody(activityPanel, { title: 'Crash' })).toBeNull()
|
||||
expect(mapBody(activityPanel, { title: 'Crash', body: '' })).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the panel declares no body key', () => {
|
||||
expect(mapBody(warrantyPanel, { body: 'ignored' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('list row filtering (tracked services vs processes)', () => {
|
||||
const feedPanel = {
|
||||
id: 'endpoints-tracked-services', render: 'list',
|
||||
|
||||
99
frontend/src/components/tabSectionsTable.spec.js
Normal file
99
frontend/src/components/tabSectionsTable.spec.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { tabSections, panelVisible, spansFullWidth } from './pluginAssetPanels'
|
||||
|
||||
// The tabs render type carried label/value sections only. It now also carries
|
||||
// table sections, so that two tables which belong together for the reader but
|
||||
// share no key can sit in one card. This is the shared renderer every plugin
|
||||
// uses, so the label/value path is pinned here alongside the new one.
|
||||
|
||||
function tabsPanel(sections) {
|
||||
return { id: 'p', render: 'tabs', _data: { sections } }
|
||||
}
|
||||
|
||||
const FIELD_SECTION = { label: 'Fields', fields: [{ label: 'A', value: '1' }] }
|
||||
const TABLE_SECTION = {
|
||||
label: 'Volumes',
|
||||
columns: [{ key: 'drive', label: 'Drive' }],
|
||||
rows: [{ drive: 'C:' }],
|
||||
empty: 'No disks reported.',
|
||||
}
|
||||
|
||||
describe('tabSections with table sections', () => {
|
||||
it('keeps rendering label/value sections as before', () => {
|
||||
const sections = tabSections(tabsPanel([FIELD_SECTION]))
|
||||
expect(sections).toHaveLength(1)
|
||||
expect(sections[0].fields).toEqual([{ label: 'A', value: '1', mono: false }])
|
||||
expect(sections[0].columns).toBeUndefined()
|
||||
})
|
||||
|
||||
it('carries columns and rows through for a table section', () => {
|
||||
const sections = tabSections(tabsPanel([TABLE_SECTION]))
|
||||
expect(sections[0].columns).toEqual([{ key: 'drive', label: 'Drive' }])
|
||||
expect(sections[0].rows).toEqual([{ drive: 'C:' }])
|
||||
})
|
||||
|
||||
it('lets one panel mix a table section and a field section', () => {
|
||||
const sections = tabSections(tabsPanel([TABLE_SECTION, FIELD_SECTION]))
|
||||
expect(sections.map((s) => s.label)).toEqual(['Volumes', 'Fields'])
|
||||
})
|
||||
|
||||
// The reason the empty-section filter needed a table branch at all. A card
|
||||
// whose second section is dropped loses that tab, and "no disks" is a very
|
||||
// different claim from "the collector reported nothing".
|
||||
it('keeps an empty table section so its tab and message survive', () => {
|
||||
const sections = tabSections(tabsPanel([{ ...TABLE_SECTION, rows: [] }]))
|
||||
expect(sections).toHaveLength(1)
|
||||
expect(sections[0].empty).toBe('No disks reported.')
|
||||
})
|
||||
|
||||
it('still drops a label/value section with no fields', () => {
|
||||
const sections = tabSections(tabsPanel([{ label: 'Empty', fields: [] }]))
|
||||
expect(sections).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('derives a column label from the key when none is given', () => {
|
||||
const sections = tabSections(tabsPanel([
|
||||
{ label: 'V', columns: [{ key: 'drive' }], rows: [] },
|
||||
]))
|
||||
expect(sections[0].columns[0].label).toBe('Drive')
|
||||
})
|
||||
|
||||
it('treats a missing rows array as no rows rather than throwing', () => {
|
||||
const sections = tabSections(tabsPanel([{ label: 'V', columns: [{ key: 'drive' }] }]))
|
||||
expect(sections[0].rows).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('panelVisible for a tabs panel of tables', () => {
|
||||
it('shows a panel whose only section is an empty table', () => {
|
||||
expect(panelVisible(tabsPanel([{ ...TABLE_SECTION, rows: [] }]))).toBe(true)
|
||||
})
|
||||
|
||||
it('still hides a tabs panel with no sections at all', () => {
|
||||
expect(panelVisible(tabsPanel([]))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('spansFullWidth', () => {
|
||||
// A six-column table squeezed into one half of a two-column grid is the
|
||||
// failure this prevents.
|
||||
it('spans a tabs panel that contains a table section', () => {
|
||||
expect(spansFullWidth(tabsPanel([TABLE_SECTION]))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not span a tabs panel of label/value sections', () => {
|
||||
expect(spansFullWidth(tabsPanel([FIELD_SECTION]))).toBe(false)
|
||||
})
|
||||
|
||||
it('still spans a plain table panel', () => {
|
||||
expect(spansFullWidth({ render: 'table' })).toBe(true)
|
||||
})
|
||||
|
||||
it('does not span a keyvalue panel', () => {
|
||||
expect(spansFullWidth({ render: 'keyvalue' })).toBe(false)
|
||||
})
|
||||
|
||||
it('tolerates a null panel', () => {
|
||||
expect(spansFullWidth(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
// Turning what somebody typed into an employee, before giving up on it.
|
||||
//
|
||||
// Both employee pickers had the same shape: click a suggestion and you get the
|
||||
// Every employee picker has the same shape: click a suggestion and you get the
|
||||
// person, with their SSO; type the whole name and press Enter and you get a
|
||||
// free-text label (`NAME:Jane Smith`) that is linked to nothing. It looks
|
||||
// identical on screen - the chip says "Jane Smith" either way - and the
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('loadEnabledPlugins', () => {
|
||||
await loadEnabledPlugins()
|
||||
|
||||
expect(isPluginEnabled('machines')).toBe(true)
|
||||
expect(isPluginEnabled('usb')).toBe(false)
|
||||
expect(isPluginEnabled('warranty')).toBe(false)
|
||||
})
|
||||
|
||||
it('fails open on error', async () => {
|
||||
@@ -31,7 +31,7 @@ describe('loadEnabledPlugins', () => {
|
||||
|
||||
await loadEnabledPlugins()
|
||||
|
||||
expect(isPluginEnabled('usb')).toBe(true)
|
||||
expect(isPluginEnabled('warranty')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not hang the router when the request never settles', async () => {
|
||||
@@ -43,7 +43,7 @@ describe('loadEnabledPlugins', () => {
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
|
||||
await expect(pending).resolves.toBeNull()
|
||||
expect(isPluginEnabled('usb')).toBe(true)
|
||||
expect(isPluginEnabled('warranty')).toBe(true)
|
||||
})
|
||||
|
||||
it('retries on the next navigation after a hung request', async () => {
|
||||
@@ -60,7 +60,7 @@ describe('loadEnabledPlugins', () => {
|
||||
|
||||
expect(enabled).toHaveBeenCalledTimes(2)
|
||||
expect(isPluginEnabled('machines')).toBe(true)
|
||||
expect(isPluginEnabled('usb')).toBe(false)
|
||||
expect(isPluginEnabled('warranty')).toBe(false)
|
||||
})
|
||||
|
||||
it('fetches once while the request is healthy', async () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ const state = reactive({
|
||||
|
||||
let inflight = null
|
||||
|
||||
const KEY_RE = /^identifier_(.+?)(?:_(machine|computer|printer|network_device|measuring_tool))?_enabled$/
|
||||
const KEY_RE = /^identifier_(.+?)(?:_(machine|computer|printer|network_device))?_enabled$/
|
||||
|
||||
function applySetting(key, value) {
|
||||
const match = KEY_RE.exec(key)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// (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, computed } from 'vue'
|
||||
import { Ruler, Wrench, Cog, Package, Settings, Puzzle, SlidersHorizontal, Palette, Bell, Network, Printer, Droplets } from 'lucide-vue-next'
|
||||
import { Ruler, Wrench, Cog, Package, Settings, Puzzle, SlidersHorizontal, Palette, Bell, Network, Printer, Droplets, Terminal, KeyRound, ShieldCheck, BadgeCheck } from 'lucide-vue-next'
|
||||
import api from '../api'
|
||||
import router from '../router'
|
||||
import { settingsGroups } from '../views/settings/settingsNav'
|
||||
@@ -24,6 +24,13 @@ const iconMap = {
|
||||
network: Network,
|
||||
printer: Printer,
|
||||
droplets: Droplets,
|
||||
terminal: Terminal,
|
||||
// The endpoints agent-key card has asked for 'key' since it shipped and has
|
||||
// been silently drawing a puzzle piece the whole time.
|
||||
key: KeyRound,
|
||||
// Server Certificate asked for 'shield-check' and drew the puzzle fallback.
|
||||
'shield-check': ShieldCheck,
|
||||
'badge-check': BadgeCheck,
|
||||
}
|
||||
|
||||
function resolveIcon(key) {
|
||||
|
||||
@@ -36,8 +36,7 @@ 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' }
|
||||
{ key: 'network_device', label: 'Network' }
|
||||
]
|
||||
|
||||
// Global-search domain toggles: keys follow search_<type>_enabled.
|
||||
@@ -51,11 +50,8 @@ export const searchDomains = [
|
||||
{ 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' }
|
||||
{ key: 'subnet', label: 'Subnets' }
|
||||
]
|
||||
|
||||
export function useSystemSettings() {
|
||||
@@ -88,19 +84,15 @@ export function useSystemSettings() {
|
||||
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: '',
|
||||
@@ -172,7 +164,7 @@ export function useSystemSettings() {
|
||||
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)) {
|
||||
} else if (/^identifier_.+_(machine|computer|printer|network_device)_enabled$/.test(setting.key)) {
|
||||
identifierMatrix[setting.key] = setting.value !== false
|
||||
} else if (/^search_.+_enabled$/.test(setting.key)) {
|
||||
searchMatrix[setting.key] = setting.value !== false
|
||||
|
||||
@@ -9,12 +9,24 @@ import '@fontsource-variable/inter'
|
||||
// Initialize theme on app load
|
||||
import './stores/theme'
|
||||
import { applyBranding } from './utils/siteSettings'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
// Validate stored token on app startup. If a token exists in localStorage but
|
||||
// is expired or signed with a stale secret (backend restarted), this catches
|
||||
// it immediately rather than letting the user navigate and then get kicked on
|
||||
// the first API request. fetchUser() logs out on 401.
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.isAuthenticated) {
|
||||
authStore.fetchUser().catch(() => {
|
||||
// fetchUser handles the logout on 401; ignore the promise rejection
|
||||
})
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// Apply per-site favicon + brand color once settings load (no-op on defaults).
|
||||
|
||||
@@ -96,7 +96,7 @@ const routes = [
|
||||
},
|
||||
// Shared asset label/code generator for any asset type (public, like the
|
||||
// other /print/* routes). assettype = machine|computer|printer|
|
||||
// network_device|measuring_tool; id = the asset's plugin id.
|
||||
// network_device; id = the asset's plugin id.
|
||||
{
|
||||
path: '/print/asset-label/:assettype/:id',
|
||||
name: 'print-asset-label',
|
||||
@@ -126,15 +126,9 @@ const router = createRouter({
|
||||
// route name with spellings for the odd ones.
|
||||
const TITLE_SPELLINGS = {
|
||||
'pcs': 'PCs',
|
||||
'usb': 'USB Devices',
|
||||
'geenforce': 'GE-Enforce',
|
||||
'knowledgebase': 'Knowledge Base',
|
||||
'printedparts': '3D Printed Parts',
|
||||
'parts-kiosk': 'Parts Kiosk',
|
||||
'usb-kiosk': 'USB Kiosk',
|
||||
'tv': 'TV Slideshow',
|
||||
'shopfloor': 'Shopfloor Dashboard',
|
||||
'measuringtools': 'Measuring Tools',
|
||||
'networkdevices': 'Network',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,225 +1,288 @@
|
||||
/**
|
||||
* Core routes - Dashboard, Search, Map, Reports, Settings, Employees
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: '',
|
||||
name: 'dashboard',
|
||||
component: () => import('../../views/Dashboard.vue')
|
||||
},
|
||||
{
|
||||
path: 'search',
|
||||
name: 'search',
|
||||
component: () => import('../../views/SearchResults.vue')
|
||||
},
|
||||
{
|
||||
path: 'map',
|
||||
name: 'map',
|
||||
component: () => import('../../views/MapView.vue')
|
||||
},
|
||||
{
|
||||
path: 'map/editor',
|
||||
name: 'map-editor',
|
||||
component: () => import('../../views/MapEditor.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'reports',
|
||||
name: 'reports',
|
||||
component: () => import('../../views/reports/ReportsIndex.vue')
|
||||
},
|
||||
// Settings
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('../../views/settings/SettingsIndex.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/vendors',
|
||||
name: 'vendors',
|
||||
component: () => import('../../views/vendors/VendorsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/modeltypes',
|
||||
name: 'modeltypes',
|
||||
component: () => import('../../views/settings/ModelTypesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/locations',
|
||||
name: 'locations',
|
||||
component: () => import('../../views/settings/LocationsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/statuses',
|
||||
name: 'statuses',
|
||||
component: () => import('../../views/settings/StatusesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/models',
|
||||
name: 'models',
|
||||
component: () => import('../../views/settings/ModelsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/businessunits',
|
||||
name: 'businessunits',
|
||||
component: () => import('../../views/settings/BusinessUnitsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/supportteams',
|
||||
name: 'supportteams',
|
||||
component: () => import('../../views/settings/SupportTeamsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/dashboarddefaults',
|
||||
name: 'dashboarddefaults',
|
||||
component: () => import('../../views/settings/DashboardDefaultsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/site',
|
||||
name: 'site-settings',
|
||||
component: () => import('../../views/settings/SiteSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/relationshiptypes',
|
||||
name: 'relationship-types',
|
||||
component: () => import('../../views/settings/RelationshipTypesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/locationtypes',
|
||||
name: 'location-types',
|
||||
component: () => import('../../views/settings/LocationTypesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/assettypes',
|
||||
name: 'asset-types',
|
||||
component: () => import('../../views/settings/AssetTypesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/customfields',
|
||||
name: 'custom-fields',
|
||||
component: () => import('../../views/settings/CustomFieldsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
// The old monolithic System Settings tab page is split into one routed page
|
||||
// per section (below). This redirect keeps old bookmarks working: bare
|
||||
// /settings/system and every /settings/system?tab=<key> land on the right page.
|
||||
{
|
||||
path: 'settings/system',
|
||||
name: 'system-settings',
|
||||
redirect: to => {
|
||||
const tabRedirects = {
|
||||
integrations: '/settings/servicenow',
|
||||
branding: '/settings/branding',
|
||||
printing: '/settings/printing',
|
||||
email: '/settings/email',
|
||||
audit: '/settings/audit',
|
||||
auth: '/settings/authentication',
|
||||
identifiers: '/settings/assetidentifiers',
|
||||
search: '/settings/globalsearch',
|
||||
map: '/settings/floormap',
|
||||
pctype: '/settings/pctypemapping'
|
||||
}
|
||||
// vue-router carries the original ?tab= query onto the target; harmless
|
||||
// since the new pages ignore it, and the path is what routes the view.
|
||||
return tabRedirects[to.query.tab] || '/settings/servicenow'
|
||||
},
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/servicenow',
|
||||
name: 'servicenow-settings',
|
||||
component: () => import('../../views/settings/ServiceNowSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/branding',
|
||||
name: 'branding-settings',
|
||||
component: () => import('../../views/settings/BrandingSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/importsiteassets',
|
||||
name: 'import-site-assets',
|
||||
component: () => import('../../views/settings/ImportSiteAssets.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/printing',
|
||||
name: 'printing-settings',
|
||||
component: () => import('../../views/settings/PrintingSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/email',
|
||||
name: 'email-settings',
|
||||
component: () => import('../../views/settings/EmailSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/audit',
|
||||
name: 'audit-settings',
|
||||
component: () => import('../../views/settings/AuditSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/authentication',
|
||||
name: 'authentication-settings',
|
||||
component: () => import('../../views/settings/AuthenticationSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/apitokens',
|
||||
name: 'apitokens-settings',
|
||||
component: () => import('../../views/settings/ApiTokensList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/assetidentifiers',
|
||||
name: 'asset-identifiers-settings',
|
||||
component: () => import('../../views/settings/AssetIdentifiersSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/globalsearch',
|
||||
name: 'global-search-settings',
|
||||
component: () => import('../../views/settings/GlobalSearchSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/floormap',
|
||||
name: 'floor-map-settings',
|
||||
component: () => import('../../views/settings/FloorMapSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/plugins',
|
||||
name: 'plugins',
|
||||
component: () => import('../../views/settings/PluginsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/auditlogs',
|
||||
name: 'audit-logs',
|
||||
component: () => import('../../views/settings/AuditLogs.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/users',
|
||||
name: 'users',
|
||||
component: () => import('../../views/settings/UsersList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
}
|
||||
]
|
||||
/**
|
||||
* Core routes - Dashboard, Search, Map, Reports, Settings
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: '',
|
||||
name: 'dashboard',
|
||||
component: () => import('../../views/Dashboard.vue')
|
||||
},
|
||||
{
|
||||
path: 'search',
|
||||
name: 'search',
|
||||
component: () => import('../../views/SearchResults.vue')
|
||||
},
|
||||
{
|
||||
path: 'map',
|
||||
name: 'map',
|
||||
component: () => import('../../views/MapView.vue')
|
||||
},
|
||||
{
|
||||
path: 'map/editor',
|
||||
name: 'map-editor',
|
||||
component: () => import('../../views/MapEditor.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'reports',
|
||||
name: 'reports',
|
||||
component: () => import('../../views/reports/ReportsIndex.vue')
|
||||
},
|
||||
// Settings
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('../../views/settings/SettingsIndex.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/catalog',
|
||||
name: 'model-catalog',
|
||||
component: () => import('../../views/settings/ModelCatalogHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'vendors',
|
||||
name: 'model-catalog-vendors',
|
||||
component: () => import('../../views/vendors/VendorsList.vue')
|
||||
},
|
||||
{
|
||||
path: 'types',
|
||||
name: 'model-catalog-types',
|
||||
component: () => import('../../views/settings/ModelTypesList.vue')
|
||||
},
|
||||
{
|
||||
path: 'models',
|
||||
name: 'model-catalog-models',
|
||||
component: () => import('../../views/settings/ModelsList.vue')
|
||||
}
|
||||
// Plugin settings routes (e.g., Operating Systems from computers plugin) are added
|
||||
// dynamically by ModelCatalogHub via router.addRoute() from /api/plugins/settings-routes.
|
||||
// This allows plugins to extend settings hubs without breaking lean builds.
|
||||
// This hub is the ONLY one that does it: no other hub page below fetches
|
||||
// or registers plugin routes, so a plugin declaring their hub id gets a
|
||||
// tab nobody draws. The server knows the same list
|
||||
// (SETTINGS_HUBS_WITH_PLUGIN_TABS in shopdb/plugins/base.py) and drops
|
||||
// anything outside it; adding a hub means writing the code into that
|
||||
// hub's .vue file AND adding it there. See ADR-019.
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings/assetconfig',
|
||||
name: 'asset-config',
|
||||
component: () => import('../../views/settings/AssetConfigHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'statuses',
|
||||
name: 'asset-config-statuses',
|
||||
component: () => import('../../views/settings/StatusesList.vue')
|
||||
},
|
||||
{
|
||||
path: 'relationships',
|
||||
name: 'asset-config-relationships',
|
||||
component: () => import('../../views/settings/RelationshipTypesList.vue')
|
||||
},
|
||||
{
|
||||
path: 'fields',
|
||||
name: 'asset-config-fields',
|
||||
component: () => import('../../views/settings/CustomFieldsList.vue')
|
||||
},
|
||||
{
|
||||
path: 'identifiers',
|
||||
name: 'asset-config-identifiers',
|
||||
component: () => import('../../views/settings/AssetIdentifiersSettings.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings/security',
|
||||
name: 'security',
|
||||
component: () => import('../../views/settings/SecurityHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'authentication',
|
||||
name: 'security-authentication',
|
||||
component: () => import('../../views/settings/AuthenticationSettings.vue')
|
||||
},
|
||||
{
|
||||
path: 'tokens',
|
||||
name: 'security-tokens',
|
||||
component: () => import('../../views/settings/ApiTokensList.vue')
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'security-users',
|
||||
component: () => import('../../views/settings/UsersList.vue')
|
||||
},
|
||||
{
|
||||
path: 'audit',
|
||||
name: 'security-audit',
|
||||
component: () => import('../../views/settings/AuditSettings.vue')
|
||||
},
|
||||
{
|
||||
path: 'auditlogs',
|
||||
name: 'security-auditlogs',
|
||||
component: () => import('../../views/settings/AuditLogs.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings/siteconfig',
|
||||
name: 'site-config',
|
||||
component: () => import('../../views/settings/SiteConfigHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'site',
|
||||
name: 'site-config-site',
|
||||
component: () => import('../../views/settings/SiteSettings.vue')
|
||||
},
|
||||
{
|
||||
path: 'floormap',
|
||||
name: 'site-config-floormap',
|
||||
component: () => import('../../views/settings/FloorMapSettings.vue')
|
||||
},
|
||||
{
|
||||
path: 'branding',
|
||||
name: 'site-config-branding',
|
||||
component: () => import('../../views/settings/BrandingSettings.vue')
|
||||
},
|
||||
{
|
||||
path: 'imports',
|
||||
name: 'site-config-imports',
|
||||
component: () => import('../../views/settings/ImportSiteAssets.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings/integrations',
|
||||
name: 'integrations',
|
||||
component: () => import('../../views/settings/IntegrationsHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/servicenow',
|
||||
name: 'servicenow-settings',
|
||||
component: () => import('../../views/settings/ServiceNowSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/locations',
|
||||
name: 'locations-hub',
|
||||
component: () => import('../../views/settings/LocationsHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
name: 'locations-hub-locations',
|
||||
component: () => import('../../views/settings/LocationsList.vue')
|
||||
},
|
||||
{
|
||||
path: 'businessunits',
|
||||
name: 'locations-hub-businessunits',
|
||||
component: () => import('../../views/settings/BusinessUnitsList.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings/network',
|
||||
name: 'network-hub',
|
||||
component: () => import('../../views/settings/NetworkHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: []
|
||||
},
|
||||
{
|
||||
path: 'settings/supportteams',
|
||||
name: 'supportteams',
|
||||
component: () => import('../../views/settings/SupportTeamsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/dashboarddefaults',
|
||||
name: 'dashboarddefaults',
|
||||
component: () => import('../../views/settings/DashboardDefaultsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/locationtypes',
|
||||
name: 'location-types',
|
||||
component: () => import('../../views/settings/LocationTypesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/assettypes',
|
||||
component: () => import('../../views/settings/AssetTypeHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'asset-types-hub',
|
||||
component: () => import('../../views/settings/asset-types/TypesTabWrapper.vue'),
|
||||
props: route => ({ assettypeid: route.params.assettypeid || route.params.assettypeid })
|
||||
},
|
||||
{
|
||||
path: ':assettypeid/subtypes',
|
||||
name: 'asset-types-hub-subtypes',
|
||||
component: () => import('../../views/settings/asset-types/TypesTabWrapper.vue'),
|
||||
props: route => ({ assettypeid: route.params.assettypeid })
|
||||
},
|
||||
]
|
||||
},
|
||||
// The old monolithic System Settings tab page is split into one routed page
|
||||
// per section (below). This redirect keeps old bookmarks working: bare
|
||||
// /settings/system and every /settings/system?tab=<key> land on the right page.
|
||||
{
|
||||
path: 'settings/system',
|
||||
name: 'system-settings',
|
||||
redirect: to => {
|
||||
const tabRedirects = {
|
||||
integrations: '/settings/servicenow',
|
||||
branding: '/settings/branding',
|
||||
printing: '/settings/printing',
|
||||
email: '/settings/email',
|
||||
audit: '/settings/audit',
|
||||
auth: '/settings/authentication',
|
||||
identifiers: '/settings/assetidentifiers',
|
||||
search: '/settings/globalsearch',
|
||||
map: '/settings/floormap',
|
||||
pctype: '/settings/pctypemapping'
|
||||
}
|
||||
// vue-router carries the original ?tab= query onto the target; harmless
|
||||
// since the new pages ignore it, and the path is what routes the view.
|
||||
return tabRedirects[to.query.tab] || '/settings/servicenow'
|
||||
},
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/printing',
|
||||
name: 'printing-settings',
|
||||
component: () => import('../../views/settings/PrintingSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/email',
|
||||
name: 'email-settings',
|
||||
component: () => import('../../views/settings/EmailSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/globalsearch',
|
||||
name: 'global-search-settings',
|
||||
component: () => import('../../views/settings/GlobalSearchSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/plugins',
|
||||
name: 'plugins',
|
||||
component: () => import('../../views/settings/PluginsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/shopdbbackups',
|
||||
name: 'shopdbbackups',
|
||||
component: () => import('../../views/settings/ShopdbBackupsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -98,8 +98,8 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
|
||||
// Pull the logged-in user's full name + photo from the employee directory
|
||||
// by their SSO (= username). Best-effort: skipped/ignored when the employees
|
||||
// plugin is off, the SSO is not in the directory, or the lookup fails.
|
||||
// by their SSO (= username). Best-effort: ignored when the site has no HR
|
||||
// directory configured, the SSO is not in it, or the lookup fails.
|
||||
async enrichFromDirectory() {
|
||||
const sso = this.user?.username
|
||||
if (sso && /^\d+$/.test(sso)) {
|
||||
|
||||
@@ -22,9 +22,7 @@ const ROUTE_BY_TYPE = {
|
||||
computer: '/pcs',
|
||||
printer: '/printers',
|
||||
network_device: '/network',
|
||||
'network device': '/network',
|
||||
measuring_tool: '/measuringtools',
|
||||
'measuring tool': '/measuringtools'
|
||||
'network device': '/network'
|
||||
}
|
||||
|
||||
export function assetRoute(asset) {
|
||||
|
||||
@@ -48,7 +48,6 @@ describe('assetRoute', () => {
|
||||
|
||||
it('handles both spellings of the multiword type names', () => {
|
||||
expect(assetRoute({ assettypename: 'network device', pluginid: 2 })).toBe('/network/2')
|
||||
expect(assetRoute({ assettypename: 'measuring_tool', pluginid: 3 })).toBe('/measuringtools/3')
|
||||
expect(assetRoute({ assettypename: 'measuring tool', pluginid: 3 })).toBe('/measuringtools/3')
|
||||
expect(assetRoute({ assettypename: 'network_device', pluginid: 2 })).toBe('/network/2')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,6 @@ const ASSET_TYPE_LABELS = {
|
||||
'printer': 'Printers',
|
||||
'network_device': 'Network Devices',
|
||||
'network device': 'Network Devices',
|
||||
'measuring_tool': 'Measuring Tools',
|
||||
}
|
||||
|
||||
const ASSET_TYPE_ROUTES = {
|
||||
@@ -18,7 +17,6 @@ const ASSET_TYPE_ROUTES = {
|
||||
'printer': '/printers',
|
||||
'network_device': '/network',
|
||||
'network device': '/network',
|
||||
'measuring_tool': '/measuringtools',
|
||||
}
|
||||
|
||||
// Plugin-specific id field inside asset.typedata for each asset type.
|
||||
@@ -28,7 +26,6 @@ const ASSET_TYPE_ID_KEYS = {
|
||||
'printer': 'printerid',
|
||||
'network_device': 'networkdeviceid',
|
||||
'network device': 'networkdeviceid',
|
||||
'measuring_tool': 'measuringtoolid',
|
||||
}
|
||||
|
||||
function titleCase(text) {
|
||||
|
||||
@@ -41,6 +41,7 @@ export const LABEL_PRESETS = [
|
||||
|
||||
// CODE128 is what the scanners on the floor are configured for, and a label
|
||||
// carries its text separately, so the barcode never renders its own.
|
||||
// Code 39 available as an alternative for compatibility with legacy systems.
|
||||
export const BARCODE_DEFAULTS = {
|
||||
format: 'CODE128',
|
||||
displayValue: false,
|
||||
@@ -130,7 +131,12 @@ export function barcodeInto(element, text, options = {}) {
|
||||
return element
|
||||
}
|
||||
|
||||
/** A barcode as an SVG data URI, for pages that print an <img>. */
|
||||
/**
|
||||
* A barcode as an SVG data URI, for pages that print an <img>.
|
||||
*
|
||||
* @param {string} text - Content to encode
|
||||
* @param {object} options - JsBarcode options (format, width, height, etc.)
|
||||
*/
|
||||
export function barcodeSvgDataUri(text, options = {}) {
|
||||
const element = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
barcodeInto(element, text, options)
|
||||
|
||||
@@ -13,9 +13,7 @@ export const assetTypeColorsMap = {
|
||||
computer: '#1976d2', // Blue
|
||||
printer: '#2e7d32', // Green
|
||||
'network device': '#e65100', // Orange
|
||||
network_device: '#e65100', // Orange (alternate key)
|
||||
measuring_tool: '#9c4dcc', // Purple
|
||||
'measuring tool': '#9c4dcc' // Purple (normalized key)
|
||||
network_device: '#e65100' // Orange (alternate key)
|
||||
}
|
||||
|
||||
// Fallback for an asset with no subtype. Reads as neutral without vanishing:
|
||||
@@ -49,7 +47,6 @@ export function getSubtypeId(asset) {
|
||||
if (typeLower === 'computer') return asset.typedata.computertypeid
|
||||
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
|
||||
if (typeLower === 'printer') return asset.typedata.printertypeid
|
||||
if (typeLower === 'measuring tool') return asset.typedata.measuringtooltypeid
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ const cases = [
|
||||
['Network Device', { networkdevicetypeid: 3 }],
|
||||
['network_device', { networkdevicetypeid: 3 }],
|
||||
['Printer', { printertypeid: 3 }],
|
||||
['Measuring Tool', { measuringtooltypeid: 3 }],
|
||||
]
|
||||
|
||||
describe('getSubtypeId', () => {
|
||||
@@ -77,10 +76,10 @@ describe('marker legibility', () => {
|
||||
})
|
||||
|
||||
describe('resolveMarkerColor', () => {
|
||||
it('colors a measuring tool by its subtype when a type is selected', () => {
|
||||
const asset = { assettype: 'Measuring Tool', typedata: { measuringtooltypeid: 7 } }
|
||||
it('colors a printer by its subtype when a type is selected', () => {
|
||||
const asset = { assettype: 'Printer', typedata: { printertypeid: 7 } }
|
||||
const color = resolveMarkerColor(asset, {
|
||||
selectedType: 'Measuring Tool',
|
||||
selectedType: 'Printer',
|
||||
subtypeColors: { 7: '#00BCD4' },
|
||||
})
|
||||
expect(color).toBe('#00BCD4')
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('emailTableFrom', () => {
|
||||
it('uses the declared columns of a {columns, rows} report', () => {
|
||||
const table = emailTableFrom({
|
||||
columns: ['timestamp', 'action', 'device_id'],
|
||||
rows: [{ timestamp: 't', action: 'check-out', device_id: 'USB-3' }],
|
||||
rows: [{ timestamp: 't', action: 'check-out', device_id: 'DEV-3' }],
|
||||
})
|
||||
expect(table.columns).toEqual([
|
||||
{ key: 'timestamp', label: 'Timestamp' },
|
||||
|
||||
@@ -5,19 +5,46 @@ import { settingsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
|
||||
let settingsCache = null
|
||||
let inFlight = null
|
||||
let retryAfter = 0
|
||||
|
||||
// Whether the last resolved load actually produced settings. A caller cannot
|
||||
// tell that from the value alone: a failed load and a site that simply has not
|
||||
// configured a key both read as empty, and those want opposite handling -
|
||||
// hide the feature vs try again.
|
||||
export function settingsLoadFailed() {
|
||||
return settingsCache === null
|
||||
}
|
||||
|
||||
// A failed load is cached briefly, not for the page session. A transient blip
|
||||
// used to blank every settings-driven link until the operator reloaded, and
|
||||
// they have no reason to know that would fix it. A short window still bounds a
|
||||
// hard-down backend to two requests a minute per client.
|
||||
const FAILURE_RETRY_MS = 30000
|
||||
|
||||
// The in-flight promise is cached alongside the result. Callers that arrive
|
||||
// during the first load share one request instead of each firing their own;
|
||||
// applyBranding() alone awaits five settings in sequence at bootstrap.
|
||||
async function loadSettings() {
|
||||
if (settingsCache) return settingsCache
|
||||
try {
|
||||
const response = await settingsApi.list()
|
||||
const items = response.data?.data || response.data || []
|
||||
settingsCache = {}
|
||||
for (const s of items) settingsCache[s.key] = s.value
|
||||
} catch (err) {
|
||||
console.error('Error loading site settings:', err)
|
||||
settingsCache = {}
|
||||
}
|
||||
return settingsCache
|
||||
if (inFlight) return inFlight
|
||||
if (retryAfter && Date.now() < retryAfter) return {}
|
||||
inFlight = settingsApi.list()
|
||||
.then((response) => {
|
||||
const items = response.data?.data || response.data || []
|
||||
const loaded = {}
|
||||
for (const s of items) loaded[s.key] = s.value
|
||||
settingsCache = loaded
|
||||
retryAfter = 0
|
||||
return loaded
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error loading site settings:', error)
|
||||
retryAfter = Date.now() + FAILURE_RETRY_MS
|
||||
return {}
|
||||
})
|
||||
.finally(() => { inFlight = null })
|
||||
return inFlight
|
||||
}
|
||||
|
||||
export async function getSetting(key, fallback = '') {
|
||||
@@ -132,10 +159,14 @@ export async function getServicenowUrls() {
|
||||
// setting instead of waiting for a release.
|
||||
//
|
||||
// Empty is the default and means "no link" - the id still renders as text.
|
||||
//
|
||||
// `loaded` distinguishes a real answer from a failed fetch. Both return empty
|
||||
// strings, but only one of them means "this site has no portal links".
|
||||
export async function getEntraDeviceUrls() {
|
||||
const entraUrl = await getSetting('entra_device_url', '')
|
||||
const intuneUrl = await getSetting('intune_device_url', '')
|
||||
return { entraUrl, intuneUrl }
|
||||
const lapsUrl = await getSetting('laps_device_url', '')
|
||||
return { entraUrl, intuneUrl, lapsUrl, loaded: !settingsLoadFailed() }
|
||||
}
|
||||
|
||||
// Printer hostname template. {ip} is replaced with the dash-separated IP.
|
||||
|
||||
119
frontend/src/utils/siteSettings.spec.js
Normal file
119
frontend/src/utils/siteSettings.spec.js
Normal file
@@ -0,0 +1,119 @@
|
||||
// Site settings load once, and a blip does not blank the page for good.
|
||||
//
|
||||
// Two bugs motivated these. The cache used to hold only the RESOLVED object, so
|
||||
// every caller arriving before the first response fired its own request -
|
||||
// applyBranding() alone awaits five settings in sequence at bootstrap. And a
|
||||
// failed load cached {} for the page session, so one bad GET silently blanked
|
||||
// every settings-driven link (the Intune/Entra buttons) until a manual reload.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
|
||||
const list = vi.fn()
|
||||
|
||||
vi.mock('@/api', () => ({ settingsApi: { list: () => list() } }))
|
||||
|
||||
// The module caches in module scope, so each test needs a fresh copy.
|
||||
async function freshModule() {
|
||||
vi.resetModules()
|
||||
return import('./siteSettings')
|
||||
}
|
||||
|
||||
function respond(items) {
|
||||
return { data: { data: items } }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
list.mockReset()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('loading', () => {
|
||||
it('fetches once for concurrent callers rather than once per caller', async () => {
|
||||
let release
|
||||
list.mockReturnValue(new Promise((resolve) => { release = resolve }))
|
||||
const settings = await freshModule()
|
||||
|
||||
const waiting = Promise.all([
|
||||
settings.getSetting('entra_device_url'),
|
||||
settings.getSetting('intune_device_url'),
|
||||
settings.getFacilityName(),
|
||||
])
|
||||
release(respond([{ key: 'entra_device_url', value: 'https://entra/{id}' }]))
|
||||
const [entra, intune, facility] = await waiting
|
||||
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
expect(entra).toBe('https://entra/{id}')
|
||||
expect(intune).toBe('')
|
||||
expect(facility).toBe('ShopDB')
|
||||
})
|
||||
|
||||
it('serves later callers from cache without refetching', async () => {
|
||||
list.mockResolvedValue(respond([{ key: 'facility_name', value: 'Wilmington' }]))
|
||||
const settings = await freshModule()
|
||||
|
||||
expect(await settings.getFacilityName()).toBe('Wilmington')
|
||||
expect(await settings.getFacilityName()).toBe('Wilmington')
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('gives both portal templates to a single caller from one fetch', async () => {
|
||||
list.mockResolvedValue(respond([
|
||||
{ key: 'entra_device_url', value: 'https://entra/{id}' },
|
||||
{ key: 'intune_device_url', value: 'https://intune/{id}' },
|
||||
]))
|
||||
const settings = await freshModule()
|
||||
|
||||
expect(await settings.getEntraDeviceUrls()).toEqual({
|
||||
entraUrl: 'https://entra/{id}',
|
||||
intuneUrl: 'https://intune/{id}',
|
||||
lapsUrl: '',
|
||||
loaded: true,
|
||||
})
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('failure', () => {
|
||||
it('falls back without throwing so a caller still renders', async () => {
|
||||
list.mockRejectedValue(new Error('network down'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const settings = await freshModule()
|
||||
|
||||
expect(await settings.getFacilityName()).toBe('ShopDB')
|
||||
// loaded:false is the point - a failed fetch and an unconfigured site both
|
||||
// read as empty strings, and the caller needs to tell them apart.
|
||||
expect(await settings.getEntraDeviceUrls()).toEqual({
|
||||
entraUrl: '', intuneUrl: '', lapsUrl: '', loaded: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not hammer a backend that is down', async () => {
|
||||
list.mockRejectedValue(new Error('network down'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const settings = await freshModule()
|
||||
|
||||
await settings.getFacilityName()
|
||||
await settings.getFacilityName()
|
||||
await settings.getFacilityName()
|
||||
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('recovers after the retry window instead of staying blank for the session', async () => {
|
||||
list.mockRejectedValueOnce(new Error('network down'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const settings = await freshModule()
|
||||
|
||||
expect(await settings.getSetting('entra_device_url')).toBe('')
|
||||
|
||||
vi.setSystemTime(Date.now() + 31000)
|
||||
list.mockResolvedValue(respond([{ key: 'entra_device_url', value: 'https://entra/{id}' }]))
|
||||
|
||||
expect(await settings.getSetting('entra_device_url')).toBe('https://entra/{id}')
|
||||
expect(list).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -40,15 +40,17 @@
|
||||
<div v-if="showDisplays" class="nav-section">Displays</div>
|
||||
<a v-if="showShopfloor" :href="withBase('/shopfloor')" target="_blank" class="external-link">Shopfloor Dashboard</a>
|
||||
<a v-if="hasRoute('/tv')" :href="withBase('/tv')" target="_blank" class="external-link">TV Slideshow</a>
|
||||
<a v-if="hasRoute('/parts-kiosk')" :href="withBase('/parts-kiosk')"
|
||||
target="_blank" class="external-link">Parts Kiosk</a>
|
||||
<a v-if="hasRoute('/usb-kiosk')" :href="withBase('/usb-kiosk')"
|
||||
target="_blank" class="external-link">USB Kiosk</a>
|
||||
|
||||
<router-link v-if="authStore.isAdmin" to="/settings">Settings</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<!-- Temporary: points at the team's SharePoint bug list while the wilm
|
||||
branch is in pre-production. Swap for a real intake before rollout. -->
|
||||
<a :href="BUG_REPORT_URL" target="_blank" rel="noopener noreferrer" class="bug-report-link">
|
||||
<Bug :size="14" /> Report a Bug
|
||||
</a>
|
||||
|
||||
<button class="theme-toggle" @click="toggleTheme" :title="currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'">
|
||||
<span v-if="currentTheme === 'dark'"><Sun :size="14" /> Light</span>
|
||||
<span v-else><Moon :size="14" /> Dark</span>
|
||||
@@ -118,15 +120,23 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import ToastHost from '../components/ToastHost.vue'
|
||||
import {
|
||||
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
|
||||
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
|
||||
Box, KeyRound, LogOut, Wrench, Activity
|
||||
Printer, Globe, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck,
|
||||
Box, KeyRound, LogOut, Wrench, Activity, Bug
|
||||
} from 'lucide-vue-next'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { currentTheme, toggleTheme } from '../stores/theme'
|
||||
|
||||
// Temporary destination for the sidebar "Report a Bug" link: the team's
|
||||
// SharePoint bug list, used while the wilm branch is in pre-production. This is
|
||||
// a development placeholder, not a shipped integration - replace it with a real
|
||||
// intake (or a configurable setting) before this reaches other sites.
|
||||
const BUG_REPORT_URL = 'https://ge.sharepoint.us/:l:/s/ShopDB/JAAAYPxXpvFMTZOj6ndg3nXlAXJtE7G2BHG1nu5tCtWrDxM?nav=YTU5YzAzZDMtNmY5MC00N2Q3LWI2NDItNjMwM2MxNDU2Y2Rk'
|
||||
|
||||
import { dashboardApi, notificationsApi } from '../api'
|
||||
import { fiscalWeek } from '@/utils/fiscalWeek'
|
||||
import { getFacilityName, getSiteLogo, getServicenowUrls, getEnvLabel } from '../utils/siteSettings'
|
||||
import { withBase } from '../utils/basePath'
|
||||
import { loadEnabledPlugins } from '../composables/enabledPlugins'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -134,19 +144,24 @@ const authStore = useAuthStore()
|
||||
|
||||
// The Displays links are hardcoded (not plugin-driven nav), so gate each by
|
||||
// whether its route was actually staged into THIS build. A lean site without
|
||||
// slides / printedparts must not show a TV Slideshow / Parts Kiosk link that
|
||||
// dead-ends on a blank page. Route existence is the true "is it in this build"
|
||||
// test - more reliable than plugin-enabled state, which a copied registry can
|
||||
// report for a plugin that was never staged.
|
||||
// slides must not show a TV Slideshow link that dead-ends on a blank page.
|
||||
// Route existence is the true "is it in this build" test - more reliable than
|
||||
// plugin-enabled state, which a copied registry can report for a plugin that
|
||||
// was never staged.
|
||||
const stagedRoutePaths = new Set(router.getRoutes().map(r => r.path))
|
||||
const hasRoute = (path) => stagedRoutePaths.has(path)
|
||||
|
||||
// Shopfloor Dashboard is a core view but runs entirely on the notifications
|
||||
// plugin (notificationsApi.getShopfloor + the calendar); without it the display
|
||||
// is empty, so gate the link on the notifications plugin being in this build.
|
||||
const showShopfloor = hasRoute('/shopfloor')
|
||||
&& (hasRoute('/notifications') || hasRoute('/calendar'))
|
||||
const showDisplays = showShopfloor || hasRoute('/tv') || hasRoute('/parts-kiosk')
|
||||
|| hasRoute('/usb-kiosk')
|
||||
const showShopfloor = computed(() =>
|
||||
hasRoute('/shopfloor') && (hasRoute('/notifications') || hasRoute('/calendar'))
|
||||
)
|
||||
|
||||
|
||||
const showDisplays = computed(() =>
|
||||
showShopfloor.value || hasRoute('/tv')
|
||||
)
|
||||
const routeViewKey = computed(() =>
|
||||
route.path.startsWith('/settings') ? '/settings' : route.path
|
||||
)
|
||||
@@ -184,14 +199,12 @@ const iconMap = {
|
||||
'desktop': Monitor,
|
||||
'printer': Printer,
|
||||
'network-wired': Globe,
|
||||
'usb': Usb,
|
||||
'bell': Bell,
|
||||
'app-window': AppWindow,
|
||||
'book-open': BookOpen,
|
||||
'bar-chart-3': BarChart3,
|
||||
'image': Image,
|
||||
'shield': ShieldCheck,
|
||||
'ruler': Ruler,
|
||||
'box': Box,
|
||||
'wrench': Wrench,
|
||||
'activity': Activity,
|
||||
@@ -207,7 +220,6 @@ const defaultNav = [
|
||||
{ name: 'PCs', icon: 'desktop', route: '/pcs', position: 15, section: 'assets', permission: 'computers.view' },
|
||||
{ name: 'Network', icon: 'network-wired', route: '/network', position: 18, section: 'assets', permission: 'network.view' },
|
||||
{ name: 'Printers', icon: 'printer', route: '/printers', position: 20, section: 'assets', permission: 'printers.view' },
|
||||
{ name: 'USB Devices', icon: 'usb', route: '/usb', position: 25, section: 'assets', permission: 'usb.view' },
|
||||
{ name: 'Applications', icon: 'app-window', route: '/applications', position: 30, section: 'information', permission: 'applications.view' },
|
||||
{ name: 'Knowledge Base', icon: 'book-open', route: '/knowledgebase', position: 35, section: 'information', permission: 'kb.view' },
|
||||
{ name: 'Reports', icon: 'bar-chart-3', route: '/reports', position: 40, section: 'information', permission: 'reports.view' },
|
||||
@@ -218,10 +230,9 @@ const defaultNav = [
|
||||
//
|
||||
// Sections used to be derived from the position number - 10-29 became Assets,
|
||||
// 30-49 became Information - so a plugin landed in whichever bucket its number
|
||||
// happened to fall in. That is how USB Devices, an asset, came to sit under
|
||||
// Information next to GE-Enforce and Tech Tools, and why two plugins picking
|
||||
// the same number silently reordered each other. A number is for ORDER; it was
|
||||
// never a statement about meaning.
|
||||
// happened to fall in. An asset page could come to sit under Information, and
|
||||
// two plugins picking the same number silently reordered each other. A number
|
||||
// is for ORDER; it was never a statement about meaning.
|
||||
//
|
||||
// An item with no section (or an unknown one) stays in the unlabelled group at
|
||||
// the top, which is the safe default: it appears, it is just not filed.
|
||||
@@ -241,7 +252,7 @@ function buildNavItems(items) {
|
||||
// link bounces them to the login page, which reads as a broken site rather
|
||||
// than as a closed door.
|
||||
// A nav item may name one permission or several; holding ANY of them is
|
||||
// enough. GE-Enforce and Slides need that: the page is reachable by a
|
||||
// enough. Slides and Tech Tools need that: the page is reachable by a
|
||||
// manager and by a plain viewer, and they hold different strings.
|
||||
const holds = item => {
|
||||
if (!item.permission) return true
|
||||
@@ -285,6 +296,10 @@ onMounted(async () => {
|
||||
// granted mid-session stays invisible until the person logs out and back in.
|
||||
if (authStore.isAuthenticated) authStore.fetchUser()
|
||||
|
||||
// Load the enabled-plugin list early: plugin-driven nav and panels read the
|
||||
// shared cache this fills, and an empty cache reads as "plugin absent".
|
||||
await loadEnabledPlugins()
|
||||
|
||||
// Half-hourly: cheap, and it crosses midnight into the new week without a
|
||||
// reload on a screen nobody touches.
|
||||
fiscalWeekTimer = setInterval(() => { currentFiscalWeek.value = fiscalWeek() }, 30 * 60 * 1000)
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
also counted STATUS rather than type without saying so.
|
||||
|
||||
EVERY counted type must appear here. The endpoint's grand total
|
||||
is machines + PCs + network + printers + measuring tools, and
|
||||
omitting one leaves a total nothing on screen explains. -->
|
||||
is machines + PCs + network + printers, and omitting one leaves
|
||||
a total nothing on screen explains. -->
|
||||
<div class="assets-row">
|
||||
<router-link to="/machines" class="assets-k">Machines</router-link>
|
||||
<span class="assets-v">{{ stats.totalmachines || 0 }}</span>
|
||||
@@ -53,16 +53,6 @@
|
||||
<router-link to="/network" class="assets-k">Network devices</router-link>
|
||||
<span class="assets-v">{{ stats.totalnetwork || 0 }}</span>
|
||||
</div>
|
||||
<!-- Measuring tools are IN the grand total the endpoint returns, so
|
||||
they have to be visible here. Left out, the total silently
|
||||
stopped matching the rows above it - 610 of rows over a stated
|
||||
729 on an instance with gages - which is the exact fault this
|
||||
panel was built to remove. Dev has none, so it looked correct
|
||||
here and was wrong everywhere the import had run. -->
|
||||
<div class="assets-row">
|
||||
<router-link to="/measuringtools" class="assets-k">Measuring tools</router-link>
|
||||
<span class="assets-v">{{ stats.totalmeasuringtool || 0 }}</span>
|
||||
</div>
|
||||
|
||||
<div class="assets-row assets-total">
|
||||
<span class="assets-k">All assets</span>
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<option value="computer">Computers</option>
|
||||
<option value="printer">Printers</option>
|
||||
<option value="network_device">Network Devices</option>
|
||||
<option value="measuring_tool">Measuring Tools</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -225,7 +224,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { Cog, Monitor, Printer, Globe, Ruler, Package, MapPin } from 'lucide-vue-next'
|
||||
import { Cog, Monitor, Printer, Globe, Package, MapPin } from 'lucide-vue-next'
|
||||
import ShopFloorMap from '../components/ShopFloorMap.vue'
|
||||
import { assetsApi, mapPositionsApi } from '../api'
|
||||
import { loadMapConfig, levelOptions, levelName, setCurrentLevel,
|
||||
@@ -404,8 +403,7 @@ function getTypeIcon(assettype) {
|
||||
'machine': Cog,
|
||||
'computer': Monitor,
|
||||
'printer': Printer,
|
||||
'network_device': Globe,
|
||||
'measuring_tool': Ruler
|
||||
'network_device': Globe
|
||||
}
|
||||
return icons[assettype] || Package
|
||||
}
|
||||
|
||||
@@ -182,8 +182,7 @@ const subtypeLabel = computed(() => {
|
||||
'machine': 'All Machine Types',
|
||||
'computer': 'All Computer Types',
|
||||
'network device': 'All Device Types',
|
||||
'printer': 'All Printer Types',
|
||||
'measuring tool': 'All Tool Types'
|
||||
'printer': 'All Printer Types'
|
||||
}
|
||||
return labels[selectedType.value.toLowerCase().replace(/_/g, ' ')] || 'All Subtypes'
|
||||
})
|
||||
@@ -233,7 +232,7 @@ const filteredAssets = computed(() => {
|
||||
|
||||
// Filter by subtype. Uses the shared getSubtypeId helper so this stays in
|
||||
// step with the marker coloring - a local copy of the per-type id lookup went
|
||||
// stale and silently dropped every measuring tool.
|
||||
// stale and silently dropped a whole asset type.
|
||||
if (selectedSubtype.value) {
|
||||
const subtypeId = parseInt(selectedSubtype.value)
|
||||
result = result.filter(a => getSubtypeId(a) === subtypeId)
|
||||
|
||||
@@ -139,12 +139,9 @@ const typeLabels = {
|
||||
guide: 'Guide',
|
||||
printer: 'Printer',
|
||||
network_device: 'Network',
|
||||
measuring_tool: 'Measuring Tool',
|
||||
employee: 'Employee',
|
||||
notification: 'Notice',
|
||||
subnet: 'Subnet',
|
||||
usb_device: 'USB',
|
||||
printed_item: 'Printed Part'
|
||||
subnet: 'Subnet'
|
||||
}
|
||||
|
||||
const filterTypeMap = {
|
||||
@@ -153,14 +150,11 @@ const filterTypeMap = {
|
||||
computers: ['computer'],
|
||||
printers: ['printer'],
|
||||
network: ['network_device', 'subnet'],
|
||||
measuringtools: ['measuring_tool'],
|
||||
applications: ['application'],
|
||||
knowledgebase: ['knowledgebase'],
|
||||
guides: ['guide'],
|
||||
notifications: ['notification'],
|
||||
employees: ['employee'],
|
||||
usb: ['usb_device'],
|
||||
printedparts: ['printed_item']
|
||||
employees: ['employee']
|
||||
}
|
||||
|
||||
const filterList = [
|
||||
@@ -169,14 +163,11 @@ const filterList = [
|
||||
{ key: 'computers', label: 'PCs' },
|
||||
{ key: 'printers', label: 'Printers' },
|
||||
{ key: 'network', label: 'Network' },
|
||||
{ key: 'measuringtools', label: 'Measuring Tools' },
|
||||
{ key: 'applications', label: 'Apps' },
|
||||
{ key: 'knowledgebase', label: 'KB' },
|
||||
{ key: 'guides', label: 'Guides' },
|
||||
{ key: 'notifications', label: 'Notices' },
|
||||
{ key: 'employees', label: 'Employees' },
|
||||
{ key: 'usb', label: 'USB' },
|
||||
{ key: 'printedparts', label: 'Printed Parts' }
|
||||
{ key: 'employees', label: 'Employees' }
|
||||
]
|
||||
|
||||
function typeLabel(type) {
|
||||
@@ -451,7 +442,6 @@ watch(results, () => {
|
||||
--rt-guide-bg: #e8f0fe; --rt-guide-fg: #1a56b8;
|
||||
--rt-printer-bg: #fce4ec; --rt-printer-fg: #c2185b;
|
||||
--rt-network-bg: #fff8e1; --rt-network-fg: #f57f17;
|
||||
--rt-measuring-bg: #e0f7fa; --rt-measuring-fg: #00838f;
|
||||
--rt-employee-bg: #e0f2f1; --rt-employee-fg: #00695c;
|
||||
--rt-notification-bg: #e8eaf6; --rt-notification-fg: #283593;
|
||||
--rt-subnet-bg: #fbe9e7; --rt-subnet-fg: #bf360c;
|
||||
@@ -465,7 +455,6 @@ watch(results, () => {
|
||||
.result-type.guide { background: var(--rt-guide-bg); color: var(--rt-guide-fg); }
|
||||
.result-type.printer { background: var(--rt-printer-bg); color: var(--rt-printer-fg); }
|
||||
.result-type.network_device { background: var(--rt-network-bg); color: var(--rt-network-fg); }
|
||||
.result-type.measuring_tool { background: var(--rt-measuring-bg); color: var(--rt-measuring-fg); }
|
||||
.result-type.employee { background: var(--rt-employee-bg); color: var(--rt-employee-fg); }
|
||||
.result-type.notification { background: var(--rt-notification-bg); color: var(--rt-notification-fg); }
|
||||
.result-type.subnet { background: var(--rt-subnet-bg); color: var(--rt-subnet-fg); }
|
||||
@@ -527,7 +516,6 @@ watch(results, () => {
|
||||
--rt-knowledgebase-bg: rgba(123, 31, 162, 0.2); --rt-knowledgebase-fg: #ce93d8;
|
||||
--rt-printer-bg: rgba(194, 24, 91, 0.2); --rt-printer-fg: #f48fb1;
|
||||
--rt-network-bg: rgba(245, 127, 23, 0.2); --rt-network-fg: #ffd54f;
|
||||
--rt-measuring-bg: rgba(0, 131, 143, 0.2); --rt-measuring-fg: #80deea;
|
||||
--rt-employee-bg: rgba(0, 105, 92, 0.2); --rt-employee-fg: #80cbc4;
|
||||
--rt-notification-bg: rgba(40, 53, 147, 0.2); --rt-notification-fg: #9fa8da;
|
||||
--rt-subnet-bg: rgba(191, 54, 12, 0.2); --rt-subnet-fg: #ffab91;
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
<div class="form-group">
|
||||
<label>Site logo <span class="hint">(optional)</span></label>
|
||||
<input type="file" accept="image/*" @change="uploadSiteLogo($event)" :disabled="logoUploading" />
|
||||
<img v-if="siteLogo" :src="siteLogo" class="wizard-map-thumb" alt="site logo" />
|
||||
<input v-model="logoFilename" type="text" class="form-control" placeholder="No file selected" readonly />
|
||||
<img v-if="siteLogo" :src="withBase(siteLogo)" class="wizard-map-thumb" alt="site logo" />
|
||||
<small class="hint">Skip to keep the shipped GE branding. You can change this later under Settings > Branding.</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,15 +93,17 @@
|
||||
<!-- Floor map -->
|
||||
<div v-else-if="current.key === 'map'">
|
||||
<h2>Floor map</h2>
|
||||
<p class="hint">Upload your facility blueprint (light and dark), and set its native pixel size.</p>
|
||||
<p class="hint">Upload your facility blueprint (light and dark) for the default level, and set its native pixel size. Additional levels can be added later in Settings.</p>
|
||||
<div class="form-group">
|
||||
<label>Blueprint image (light theme)</label>
|
||||
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" />
|
||||
<input v-model="blueprintLightFilename" type="text" class="form-control" placeholder="No file selected" readonly />
|
||||
<img v-if="blueprintLight" :src="withBase(blueprintLight)" class="wizard-map-thumb" alt="light blueprint" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Blueprint image (dark theme)</label>
|
||||
<input type="file" accept="image/*" @change="uploadBlueprint('dark', $event)" :disabled="mapUploading" />
|
||||
<input v-model="blueprintDarkFilename" type="text" class="form-control" placeholder="No file selected" readonly />
|
||||
<img v-if="blueprintDark" :src="withBase(blueprintDark)" class="wizard-map-thumb dark" alt="dark blueprint" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
@@ -141,12 +144,6 @@
|
||||
<div v-else-if="current.key === 'finish'">
|
||||
<h2>All set</h2>
|
||||
<p class="hint">You can change any of this later under Settings. Finish to go to the dashboard.</p>
|
||||
<div v-if="geenforceEnabled" class="finish-note">
|
||||
<strong>GE-Enforce is on.</strong> Two more steps before the fleet
|
||||
uses it: create a service token with the geenforce scopes under
|
||||
Settings > API Tokens, and set the on-share export root on the
|
||||
GE-Enforce page.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -203,14 +200,24 @@ const blueprintLight = ref('')
|
||||
const blueprintDark = ref('')
|
||||
const logoUploading = ref(false)
|
||||
const siteLogo = ref('')
|
||||
// A file input shows the chosen name in the browser chrome, which the kiosk-
|
||||
// style full-screen wizard hides, so the operator had no confirmation that the
|
||||
// file they picked was the one being uploaded.
|
||||
const logoFilename = ref('')
|
||||
const blueprintLightFilename = ref('')
|
||||
const blueprintDarkFilename = ref('')
|
||||
|
||||
async function uploadSiteLogo(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
logoFilename.value = file.name
|
||||
logoUploading.value = true
|
||||
try {
|
||||
const { data } = await settingsApi.uploadBrandingLogo('site', file)
|
||||
siteLogo.value = data?.data?.value || ''
|
||||
// Same path every time, so without a cache-buster the preview shows the
|
||||
// PREVIOUS logo and the upload reads as having silently failed. Display
|
||||
// only - nothing persists this value.
|
||||
siteLogo.value = data?.data?.value ? `${data.data.value}?t=${Date.now()}` : ''
|
||||
toast.success('Logo uploaded')
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Upload failed'))
|
||||
@@ -223,10 +230,12 @@ async function uploadSiteLogo(event) {
|
||||
async function uploadBlueprint(theme, event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
if (theme === 'light') blueprintLightFilename.value = file.name
|
||||
else blueprintDarkFilename.value = file.name
|
||||
mapUploading.value = true
|
||||
try {
|
||||
const { data } = await settingsApi.uploadMapBlueprint(theme, file)
|
||||
const url = data?.data?.value
|
||||
const url = data?.data?.value ? `${data.data.value}?t=${Date.now()}` : ''
|
||||
if (theme === 'light') blueprintLight.value = url
|
||||
else blueprintDark.value = url
|
||||
toast.success('Blueprint uploaded')
|
||||
@@ -243,11 +252,6 @@ function modeOf(plugin) {
|
||||
return key ? (pluginModes.value[key] || 'selfhosted') : null
|
||||
}
|
||||
|
||||
// GE-Enforce needs post-setup operational config (service token + share root)
|
||||
// the wizard does not collect; the Finish step points there when it is on.
|
||||
const geenforceEnabled = computed(() =>
|
||||
plugins.value.some(p => p.name === 'geenforce' && p.enabled))
|
||||
|
||||
// Enabled plugins whose external connection config should be collected right
|
||||
// now: those in external mode (or with config but no mode concept).
|
||||
const configurablePlugins = computed(() =>
|
||||
@@ -307,6 +311,17 @@ onMounted(async () => {
|
||||
} catch (err) { /* not set yet */ }
|
||||
}
|
||||
}
|
||||
// Show whatever logo the site is already on, so re-running the wizard does
|
||||
// not present an empty slot as if branding had never been set. Stored as a
|
||||
// root-absolute path; the template applies withBase, so do NOT prefix here or
|
||||
// a subpath mount gets it twice (/shopdb/shopdb/...).
|
||||
const SHIPPED_LOGO = '/ge-aerospace-logo.svg'
|
||||
try {
|
||||
const response = await settingsApi.get('site_logo')
|
||||
siteLogo.value = response.data?.data?.value || SHIPPED_LOGO
|
||||
} catch (err) {
|
||||
siteLogo.value = SHIPPED_LOGO
|
||||
}
|
||||
})
|
||||
|
||||
async function saveStep() {
|
||||
@@ -440,7 +455,6 @@ async function finish() {
|
||||
.mode-opt input { margin-right: 0.4rem; }
|
||||
.plugin-mode .provision-note, .plugin-mode .config-block { margin-left: 0; }
|
||||
.provision-note { margin: 0.3rem 0 0.2rem 1.9rem; padding: 0.6rem 0.75rem; background: var(--bg); border-left: 3px solid var(--warning); border-radius: 4px; font-size: 0.82rem; }
|
||||
.finish-note { margin: 0.75rem 0 0; padding: 0.6rem 0.75rem; background: var(--bg); border-left: 3px solid var(--primary); border-radius: 4px; font-size: 0.85rem; }
|
||||
.provision-note p { margin: 0 0 0.35rem; }
|
||||
.provision-note p:last-child { margin-bottom: 0; }
|
||||
.provision-tables code, .provision-docs code { background: var(--bg-card); border: 1px solid var(--border); border-radius: 3px; padding: 0 0.3rem; margin-right: 0.3rem; font-size: 0.78rem; }
|
||||
|
||||
@@ -25,13 +25,13 @@
|
||||
<option value="assetpage">Asset page (link)</option>
|
||||
<option value="assetnumber">Asset number</option>
|
||||
<option value="serialnumber">Serial number</option>
|
||||
<option v-if="hasLocation" value="location">Inspection location code</option>
|
||||
<option v-if="hasLocation" value="location">Location code</option>
|
||||
<option value="custom">Custom target (settings template)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="encodes === 'location' && !asset.locationcode" class="control-note">
|
||||
This tool has no location; the label falls back to the asset page.
|
||||
This asset has no location; the label falls back to the asset page.
|
||||
</p>
|
||||
<div class="control-row">
|
||||
<label>Output
|
||||
@@ -164,7 +164,6 @@ const cardTitle = computed(() => {
|
||||
if (!asset.value) return ''
|
||||
const ext = extension.value || {}
|
||||
return ext.modelname
|
||||
|| ext.measuringtooltypename
|
||||
|| asset.value.assettypename
|
||||
|| (config ? config.label : 'Asset')
|
||||
})
|
||||
@@ -192,7 +191,7 @@ const caption = computed(() => captionFor(asset.value, encodes.value))
|
||||
// message.
|
||||
const ENCODE_LABELS = {
|
||||
assetnumber: 'asset number', serialnumber: 'serial number',
|
||||
location: 'inspection location', assetpage: 'page link', custom: 'target',
|
||||
location: 'location code', assetpage: 'page link', custom: 'target',
|
||||
}
|
||||
const encodeLabel = computed(() =>
|
||||
ENCODE_LABELS[effEncodes(encodes.value, asset.value)] || 'value')
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<option value="assetpage">Asset page (link)</option>
|
||||
<option value="assetnumber">Asset number</option>
|
||||
<option value="serialnumber">Serial number</option>
|
||||
<option v-if="hasLocation" value="location">Inspection location code</option>
|
||||
<option v-if="hasLocation" value="location">Location code</option>
|
||||
<option value="custom">Custom target (settings template)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// AssetLabelBatch.vue): per-type api/route config plus the "what does the code
|
||||
// encode" resolution so both surfaces behave identically.
|
||||
import {
|
||||
machinesApi, computersApi, printersApi, networkApi, measuringtoolsApi,
|
||||
machinesApi, computersApi, printersApi, networkApi,
|
||||
} from '../../api'
|
||||
import { fillUrlTemplate } from '@/utils/qrTarget'
|
||||
import { getSetting, getSiteBaseUrl } from '@/utils/siteSettings'
|
||||
@@ -26,18 +26,19 @@ export const TYPE_CONFIG = {
|
||||
api: networkApi, extkey: 'network_device', targetKey: 'qr_target_network_device',
|
||||
label: 'Network Device', detailPath: id => `/network/${id}`, listPath: '/network',
|
||||
},
|
||||
measuring_tool: {
|
||||
api: measuringtoolsApi, extkey: 'measuringtool', targetKey: 'qr_target_measuring_tool',
|
||||
label: 'Measuring Tool', detailPath: id => `/measuringtools/${id}`, listPath: '/measuringtools',
|
||||
},
|
||||
}
|
||||
|
||||
// Hardcoded fallback default per type when the site setting is unset. Machines
|
||||
// encode their machine number, measuring tools their inspection location.
|
||||
const DEFAULT_ENCODES = { machine: 'assetnumber', measuring_tool: 'location' }
|
||||
// encode their machine number; everything else links to its page.
|
||||
const DEFAULT_ENCODES = { machine: 'assetnumber' }
|
||||
|
||||
// Whether a type's label may encode a LOCATION code rather than the asset
|
||||
// itself, which is right for something read off a shelf and wrong for something
|
||||
// bolted down. Driven by a per-type flag rather than a hardcoded type name, so
|
||||
// adding a type that wants it is one key in TYPE_CONFIG. No bundled type sets
|
||||
// it today; the encode mode itself stays supported end to end.
|
||||
export function hasLocationType(assettype) {
|
||||
return assettype === 'measuring_tool'
|
||||
return Boolean(TYPE_CONFIG[assettype]?.hasLocation)
|
||||
}
|
||||
|
||||
// The default encode mode for a type: the label_default_encodes_<type> site
|
||||
|
||||
@@ -227,10 +227,9 @@
|
||||
<!--
|
||||
Any report that returns {columns, rows} renders as a table. Every core
|
||||
report above has a hand-written block; a plugin report had none, so it
|
||||
fell through to a raw JSON dump - which is what the printedparts reports
|
||||
have been showing since they were added. The shape is already the
|
||||
convention on the server side, so honouring it here means a plugin gets
|
||||
a real report with no core code at all.
|
||||
fell through to a raw JSON dump. The shape is already the convention on
|
||||
the server side, so honouring it here means a plugin gets a real report
|
||||
with no core code at all.
|
||||
-->
|
||||
<div v-else-if="genericRows" class="report-content">
|
||||
<p v-if="reportData.summary" class="report-summary">
|
||||
@@ -297,12 +296,7 @@ const reportFilterFields = {
|
||||
'assets-by-status': ['assettype', 'businessunit'],
|
||||
'asset-inventory': ['businessunit', 'location'],
|
||||
'kb-popularity': ['limit'],
|
||||
'software-compliance': ['application'],
|
||||
// Time-windowed reports. The endpoints already accepted a window; there was
|
||||
// simply no way to choose one from the page.
|
||||
'usb-audit': ['period'],
|
||||
'printedparts-consumption': ['period'],
|
||||
'printedparts-by-person': ['period']
|
||||
'software-compliance': ['application']
|
||||
}
|
||||
|
||||
// Named windows rather than a day count: the question is "this shift" or "this
|
||||
@@ -490,9 +484,8 @@ async function runReport(report) {
|
||||
// This used to be a switch over five hardcoded core ids with
|
||||
// `default: console.error('Unknown report')`. A plugin could register a
|
||||
// report, have it appear in the catalogue, and then render nothing at all
|
||||
// when opened - the three printedparts reports were in exactly that state.
|
||||
// The endpoint is already on the contract; using it means a plugin never
|
||||
// needs a line of core code to be runnable.
|
||||
// when opened. The endpoint is already on the contract; using it means a
|
||||
// plugin never needs a line of core code to be runnable.
|
||||
const url = report.endpoint || `/api/reports/${report.id}`
|
||||
const response = await api.get(stripApiPrefix(url), { params })
|
||||
reportData.value = response.data.data
|
||||
|
||||
@@ -327,6 +327,11 @@ async function loadData() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading API tokens:', err)
|
||||
if (err.response?.data?.error) {
|
||||
toast.error(err.response.data.error)
|
||||
} else {
|
||||
toast.error('Failed to load tokens or permissions. Please refresh the page.')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
111
frontend/src/views/settings/AssetConfigHub.vue
Normal file
111
frontend/src/views/settings/AssetConfigHub.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="asset-config-hub">
|
||||
<div class="hub-header">
|
||||
<h2>Asset Configuration</h2>
|
||||
<p class="description">Configure statuses, relationships, custom fields, and identifiers</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<router-link
|
||||
:to="{ name: 'asset-config-statuses' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'asset-config-statuses' }"
|
||||
>
|
||||
Statuses
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'asset-config-relationships' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'asset-config-relationships' }"
|
||||
>
|
||||
Relationship Types
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'asset-config-fields' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'asset-config-fields' }"
|
||||
>
|
||||
Custom Fields
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'asset-config-identifiers' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'asset-config-identifiers' }"
|
||||
>
|
||||
Identifiers
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
if (route.name === 'asset-config') {
|
||||
router.replace({ name: 'asset-config-statuses' })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.asset-config-hub {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hub-header h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
/* Content styled by child components */
|
||||
}
|
||||
</style>
|
||||
306
frontend/src/views/settings/AssetTypeHub.vue
Normal file
306
frontend/src/views/settings/AssetTypeHub.vue
Normal file
@@ -0,0 +1,306 @@
|
||||
<template>
|
||||
<div class="asset-type-hub">
|
||||
<div class="hub-rail">
|
||||
<div class="rail-header">
|
||||
<h3>Types</h3>
|
||||
</div>
|
||||
<div v-if="loading" class="rail-loading">Loading...</div>
|
||||
<div v-else class="rail-list">
|
||||
<!-- Asset Types Section -->
|
||||
<div v-if="assetTypeItems.length > 0" class="rail-section">
|
||||
<div class="section-header">Asset Types</div>
|
||||
<router-link
|
||||
v-for="item in assetTypeItems"
|
||||
:key="item.id"
|
||||
:to="{ name: 'asset-types-hub-subtypes', params: { assettypeid: item.id } }"
|
||||
class="rail-item"
|
||||
:class="{ active: item.id === currentTypeId }"
|
||||
>
|
||||
<span class="rail-label">{{ item.label }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Other Types Section -->
|
||||
<div v-if="otherTypeItems.length > 0" class="rail-section">
|
||||
<div class="section-header">Other Types</div>
|
||||
<router-link
|
||||
v-for="item in otherTypeItems"
|
||||
:key="item.id"
|
||||
:to="{ name: 'asset-types-hub-subtypes', params: { assettypeid: item.id } }"
|
||||
class="rail-item"
|
||||
:class="{ active: item.id === currentTypeId }"
|
||||
>
|
||||
<span class="rail-label">{{ item.label }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hub-content">
|
||||
<div v-if="currentDescriptor" class="content-header">
|
||||
<h2>{{ currentDescriptor.type_name_plural || capitalize(currentDescriptor.assettype) }}</h2>
|
||||
<p v-if="currentDescriptor.description" class="description">
|
||||
{{ currentDescriptor.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="currentDescriptor" class="tabs">
|
||||
<router-link
|
||||
:to="{ name: 'asset-types-hub-subtypes', params: { assettypeid: currentTypeId } }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'asset-types-hub-subtypes' }"
|
||||
>
|
||||
{{ currentDescriptor.type_name_plural || 'Subtypes' }}
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-for="tab in optionalTabs"
|
||||
:key="tab"
|
||||
:to="{ name: `asset-types-hub-${tab}`, params: { assettypeid: currentTypeId } }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === `asset-types-hub-${tab}` }"
|
||||
>
|
||||
{{ capitalize(tab) }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<router-view :key="$route.fullPath" :descriptor="currentDescriptor" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import api from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const assetTypes = ref([])
|
||||
const allDescriptors = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const assetTypeItems = computed(() => {
|
||||
const items = []
|
||||
for (const at of assetTypes.value) {
|
||||
const desc = allDescriptors.value.find(d => d.assettype === at.assettype)
|
||||
if (desc) {
|
||||
items.push({
|
||||
id: at.assettypeid,
|
||||
label: capitalize(at.assettype),
|
||||
descriptor: desc
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const otherTypeItems = computed(() => {
|
||||
return allDescriptors.value
|
||||
.filter(d => d.group === 'other')
|
||||
.map((d, index) => ({
|
||||
id: `other-${index}`,
|
||||
label: d.type_name_plural || d.category,
|
||||
descriptor: d
|
||||
}))
|
||||
})
|
||||
|
||||
const currentTypeId = computed(() => route.params.assettypeid || null)
|
||||
|
||||
const currentDescriptor = computed(() => {
|
||||
const typeId = currentTypeId.value
|
||||
if (!typeId) return null
|
||||
|
||||
if (typeId.toString().startsWith('other-')) {
|
||||
const index = parseInt(typeId.replace('other-', ''))
|
||||
return allDescriptors.value.filter(d => d.group === 'other')[index]
|
||||
} else {
|
||||
const at = assetTypes.value.find(t => t.assettypeid === parseInt(typeId))
|
||||
if (!at) return null
|
||||
return allDescriptors.value.find(d => d.assettype === at.assettype)
|
||||
}
|
||||
})
|
||||
|
||||
const optionalTabs = computed(() => currentDescriptor.value?.optional_tabs || [])
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
if (!currentTypeId.value && assetTypeItems.value.length > 0) {
|
||||
router.replace({
|
||||
name: 'asset-types-hub-subtypes',
|
||||
params: { assettypeid: assetTypeItems.value[0].id }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => route.params.assettypeid, (newId) => {
|
||||
if (!newId && assetTypeItems.value.length > 0) {
|
||||
router.replace({
|
||||
name: 'asset-types-hub-subtypes',
|
||||
params: { assettypeid: assetTypeItems.value[0].id }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [typesRes, assetDescRes, coreDescRes] = await Promise.all([
|
||||
api.get('/assets/types', { params: { perpage: 100, active: true } }),
|
||||
api.get('/pluginui/asset-type-descriptors'),
|
||||
api.get('/pluginui/core-type-descriptors')
|
||||
])
|
||||
assetTypes.value = typesRes.data.data || []
|
||||
const assetDescs = assetDescRes.data.data || []
|
||||
const coreDescs = coreDescRes.data.data || []
|
||||
allDescriptors.value = [...assetDescs, ...coreDescs]
|
||||
} catch (err) {
|
||||
console.error('Error loading type hub data:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function capitalize(str) {
|
||||
if (!str) return ''
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.asset-type-hub {
|
||||
display: flex;
|
||||
height: calc(100vh - 120px);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.hub-rail {
|
||||
width: 240px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.rail-header {
|
||||
padding: 1.25rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.rail-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rail-loading {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.rail-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.rail-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-light);
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.rail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.rail-item:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.rail-item.active {
|
||||
background: var(--bg-primary);
|
||||
border-left-color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rail-label {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.hub-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.content-header h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0 2rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.25rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -133,6 +133,59 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Collector API credentials. This page is where a site manages who
|
||||
may authenticate, and the shared collector key is a credential like
|
||||
any other - it just belongs to scripts rather than people. It is
|
||||
rendered here because there is no generic key/value settings editor
|
||||
in this UI: without a control the seeded row can only be flipped by
|
||||
editing the database by hand, which on an air-gapped prod box means
|
||||
it is not a setting at all. -->
|
||||
<div class="setting-group">
|
||||
<h3>Collector API</h3>
|
||||
<p class="setting-description">
|
||||
The four legacy collector routes (<code>/api/collector/pc</code>,
|
||||
<code>/apps</code>, <code>/heartbeat</code>, <code>/bulk</code>) name
|
||||
the PC they write in the request body, so the shared collector key is
|
||||
an any-host write key on them: whoever holds it can rewrite the
|
||||
logged-in user, last boot time, serial number and installed apps of
|
||||
any hostname. A collector-scoped managed token is unaffected.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="toggle-label">
|
||||
<span>Refuse the shared key on legacy collector routes</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: legacySharedKeyRefused }"
|
||||
@click="toggleLegacySharedKey"
|
||||
:disabled="legacySharedKeySaving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
<small class="input-hint toggle-hint">
|
||||
Off by default, which is what every site does today. Turning it on
|
||||
stops <code>Report-AssetToShopDB.ps1</code> and
|
||||
<code>Report-PrintersToShopDB.ps1</code> reporting through those
|
||||
routes until they are moved to per-PC credentials, along with
|
||||
anything else still calling them with the shared key. Every
|
||||
shared-key call is logged as "Legacy collector route used with the
|
||||
shared key" whether this is on or off - read those warnings for a
|
||||
while first, or turning it on is a guess. On the shipped IIS deploy
|
||||
that line names the route but not the caller: the X-Forwarded-For
|
||||
rewrite rule in web.config ships disabled, so every request reaches
|
||||
the app from 127.0.0.1. Count the routes, not the addresses, unless
|
||||
that rule is on. The shipped reporters that post to
|
||||
<code>/api/collector/computers</code> and
|
||||
<code>/api/collector/printers</code> are not affected. This should
|
||||
follow the move to per-PC credentials, not lead it.
|
||||
</small>
|
||||
</div>
|
||||
<div v-if="legacySharedKeyError" class="error-message">
|
||||
{{ legacySharedKeyError }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<h3>User Management</h3>
|
||||
<p class="setting-description">
|
||||
@@ -151,14 +204,66 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { onMounted, computed, ref } from 'vue'
|
||||
import { useSystemSettings } from '../../composables/systemSettings'
|
||||
import { settingsApi } from '../../api'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const {
|
||||
settings, saving, error, success,
|
||||
loadSettings, saveSetting, toggleSetting,
|
||||
} = useSystemSettings()
|
||||
|
||||
// collector.legacysharedkeyrefused is read and written here directly rather
|
||||
// than through useSystemSettings: that composable only keeps keys listed in
|
||||
// its own reactive map, and this one is core-owned but belongs to no page it
|
||||
// already serves. Default false = today's behaviour, so anything but an
|
||||
// explicit true renders off.
|
||||
const LEGACY_SHARED_KEY_SETTING = 'collector.legacysharedkeyrefused'
|
||||
const legacySharedKeyRefused = ref(false)
|
||||
const legacySharedKeySaving = ref(false)
|
||||
const legacySharedKeyError = ref('')
|
||||
|
||||
function isTruthySetting(value) {
|
||||
// A row seeded boolean comes back as a real true; one created by hand
|
||||
// through the settings API before the key was declared can be the string.
|
||||
return value === true || value === 'true'
|
||||
}
|
||||
|
||||
async function loadLegacySharedKey() {
|
||||
try {
|
||||
const { data } = await settingsApi.get(LEGACY_SHARED_KEY_SETTING)
|
||||
legacySharedKeyRefused.value = isTruthySetting(data.data?.value)
|
||||
} catch (e) {
|
||||
// 404 = never seeded on this install. The backend default is off.
|
||||
if (e.response?.status !== 404) console.error(e)
|
||||
legacySharedKeyRefused.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleLegacySharedKey() {
|
||||
const next = !legacySharedKeyRefused.value
|
||||
if (next && !window.confirm(
|
||||
'Refuse the shared collector key on the legacy routes?\n\n'
|
||||
+ 'Report-AssetToShopDB.ps1, Report-PrintersToShopDB.ps1 and anything '
|
||||
+ 'else still using the shared key on /pc, /apps, /heartbeat or /bulk '
|
||||
+ 'stop reporting until they move to per-PC credentials.')) {
|
||||
return
|
||||
}
|
||||
legacySharedKeySaving.value = true
|
||||
legacySharedKeyError.value = ''
|
||||
try {
|
||||
// PUT upserts, so this also works where the row was never seeded.
|
||||
await settingsApi.update(LEGACY_SHARED_KEY_SETTING, next)
|
||||
legacySharedKeyRefused.value = next
|
||||
} catch (e) {
|
||||
legacySharedKeyError.value = apiError(e, 'Failed to save setting')
|
||||
console.error(e)
|
||||
} finally {
|
||||
legacySharedKeySaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Per-page connection status (stays with the page, not the composable).
|
||||
const samlStatus = computed(() => {
|
||||
if (!settings.saml_enabled) return 'inactive'
|
||||
@@ -178,5 +283,8 @@ const samlMessage = computed(() => {
|
||||
return 'Settings saved - waiting on SAML support, local accounts still in use'
|
||||
})
|
||||
|
||||
onMounted(loadSettings)
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
loadLegacySharedKey()
|
||||
})
|
||||
</script>
|
||||
|
||||
112
frontend/src/views/settings/IntegrationsHub.vue
Normal file
112
frontend/src/views/settings/IntegrationsHub.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="integrations-hub">
|
||||
<div class="hub-header">
|
||||
<h2>Integrations</h2>
|
||||
<p class="description">Configure external service connections and APIs</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<router-link
|
||||
:to="{ name: 'servicenow-settings' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'servicenow-settings' }"
|
||||
>
|
||||
ServiceNow
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'zabbix-settings' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'zabbix-settings' }"
|
||||
>
|
||||
Zabbix Supplies
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'dell-warranty-settings' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'dell-warranty-settings' }"
|
||||
>
|
||||
Dell Warranty
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="hub-notice">
|
||||
<p>Click a tab above to configure integrations</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
// Auto-redirect to first tab if on hub landing page
|
||||
if (route.name === 'integrations') {
|
||||
router.replace({ name: 'servicenow-settings' })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.integrations-hub {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hub-header h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.hub-notice {
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.hub-notice p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
</style>
|
||||
90
frontend/src/views/settings/IntegrationsLayout.vue
Normal file
90
frontend/src/views/settings/IntegrationsLayout.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="integrations-layout">
|
||||
<div class="hub-header">
|
||||
<h2>Integrations</h2>
|
||||
<p class="description">Configure external service connections and APIs</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<router-link
|
||||
:to="{ name: 'servicenow-settings' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'servicenow-settings' }"
|
||||
>
|
||||
ServiceNow
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'zabbix-settings' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'zabbix-settings' }"
|
||||
>
|
||||
Zabbix Supplies
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'dell-warranty-settings' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'dell-warranty-settings' }"
|
||||
>
|
||||
Dell Warranty
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.integrations-layout {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hub-header h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.content {
|
||||
/* Content styled by child pages */
|
||||
}
|
||||
</style>
|
||||
104
frontend/src/views/settings/LocationsHub.vue
Normal file
104
frontend/src/views/settings/LocationsHub.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="locations-hub">
|
||||
<div class="hub-header">
|
||||
<h2>Locations & Organization</h2>
|
||||
<p class="description">Manage physical locations, business units, and employee directory</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<router-link
|
||||
:to="{ name: 'locations-hub-locations' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'locations-hub-locations' }"
|
||||
>
|
||||
Locations
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'locations-hub-businessunits' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'locations-hub-businessunits' }"
|
||||
>
|
||||
Business Units
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'employee-directory' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'employee-directory' || $route.name === 'locations-hub-employees' }"
|
||||
>
|
||||
Employee Directory
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
if (route.name === 'locations-hub') {
|
||||
router.replace({ name: 'locations-hub-locations' })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.locations-hub {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hub-header h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
/* Content styled by child components */
|
||||
}
|
||||
</style>
|
||||
152
frontend/src/views/settings/ModelCatalogHub.vue
Normal file
152
frontend/src/views/settings/ModelCatalogHub.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="model-catalog-hub">
|
||||
<div class="hub-header">
|
||||
<h2>Model Catalog</h2>
|
||||
<p class="description">Manage vendors, model types, equipment models, and operating systems</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<router-link
|
||||
:to="{ name: 'model-catalog-vendors' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'model-catalog-vendors' }"
|
||||
>
|
||||
Vendors
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'model-catalog-types' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'model-catalog-types' }"
|
||||
>
|
||||
Model Types
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'model-catalog-models' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'model-catalog-models' }"
|
||||
>
|
||||
Models
|
||||
</router-link>
|
||||
<!-- Plugin-contributed tabs loaded dynamically -->
|
||||
<router-link
|
||||
v-for="tab in pluginTabs"
|
||||
:key="tab.name"
|
||||
:to="{ name: tab.name }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === tab.name }"
|
||||
>
|
||||
{{ tab.tab }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
// The shared api instance, not raw axios: this route is gated on settings.view
|
||||
// now, and only this client attaches the bearer token and the mount-path base.
|
||||
import api from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const pluginTabs = ref([])
|
||||
|
||||
// Plugin tabs (ADR-019). Two things this does not do, so nobody plans around
|
||||
// them. The routes exist only after this hub has mounted, so a plugin tab is
|
||||
// not deep-linkable and does not survive a refresh. And the import below has no
|
||||
// static file extension, so vite:dynamic-import-vars refuses to rewrite it (it
|
||||
// says so on every build) and the built chunk asks for a .vue file that dist
|
||||
// does not ship: the tab loads under `npm run dev` and 404s in production. The
|
||||
// pages behind these tabs are therefore expected to carry a static route of
|
||||
// their own in the plugin's routes.js, which is what a site actually uses.
|
||||
// Copying this block into another hub is what makes that hub support plugin
|
||||
// tabs - and it must then be added to SETTINGS_HUBS_WITH_PLUGIN_TABS in
|
||||
// shopdb/plugins/base.py, which is what the server filters on.
|
||||
onMounted(async () => {
|
||||
// Fetch plugin-contributed settings routes
|
||||
try {
|
||||
const response = await api.get('/plugins/settings-routes')
|
||||
if (response.data && response.data.data) {
|
||||
// Filter for model-catalog hub routes
|
||||
pluginTabs.value = response.data.data.routes.filter(r => r.hub === 'model-catalog')
|
||||
|
||||
// Dynamically register routes for plugin tabs
|
||||
pluginTabs.value.forEach(tab => {
|
||||
if (!router.hasRoute(tab.name)) {
|
||||
router.addRoute('model-catalog', {
|
||||
path: tab.path,
|
||||
name: tab.name,
|
||||
component: () => import(`../../${tab.component}`)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load plugin settings routes:', error)
|
||||
}
|
||||
|
||||
// Redirect to vendors tab if no child route
|
||||
if (route.name === 'model-catalog') {
|
||||
router.replace({ name: 'model-catalog-vendors' })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.model-catalog-hub {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hub-header h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
/* Content is styled by child components */
|
||||
}
|
||||
</style>
|
||||
104
frontend/src/views/settings/NetworkHub.vue
Normal file
104
frontend/src/views/settings/NetworkHub.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="network-hub">
|
||||
<div class="hub-header">
|
||||
<h2>Network Infrastructure</h2>
|
||||
<p class="description">Manage VLANs and IP subnets</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<router-link
|
||||
:to="{ name: 'vlans' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'vlans' }"
|
||||
>
|
||||
VLANs
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'subnets' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'subnets' }"
|
||||
>
|
||||
Subnets
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="hub-notice">
|
||||
<p>Click a tab above to manage network infrastructure</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
if (route.name === 'network-hub') {
|
||||
router.replace({ name: 'vlans' })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.network-hub {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hub-header h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.hub-notice {
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.hub-notice p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
</style>
|
||||
@@ -26,20 +26,6 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>USB label QR target</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.qr_target_usb"
|
||||
placeholder="(blank = USB device page)"
|
||||
@blur="saveSetting('qr_target_usb', settings.qr_target_usb)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Placeholders: {id}, {serialnumber}, {alias}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Machine label target</span>
|
||||
@@ -82,20 +68,6 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Measuring tool label target</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.qr_target_measuring_tool"
|
||||
placeholder="(blank = measuring tool page)"
|
||||
@blur="saveSetting('qr_target_measuring_tool', settings.qr_target_measuring_tool)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}, {locationcode}, {locationname}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Default label style</span>
|
||||
@@ -148,20 +120,6 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>USB label style</span>
|
||||
<select
|
||||
v-model="settings.usb_label_style"
|
||||
@change="saveSetting('usb_label_style', settings.usb_label_style)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<option value="barcode">Barcode (CODE128 of the serial number)</option>
|
||||
<option value="qr">QR code (links to the USB label QR target)</option>
|
||||
</select>
|
||||
<small class="input-hint">Applies to the batch USB mini-label print sheet.</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -185,7 +143,6 @@ const encodesRows = [
|
||||
{ key: 'label_default_encodes_computer', label: 'Computer' },
|
||||
{ key: 'label_default_encodes_printer', label: 'Printer' },
|
||||
{ key: 'label_default_encodes_network_device', label: 'Network device' },
|
||||
{ key: 'label_default_encodes_measuring_tool', label: 'Measuring tool', hasLocation: true },
|
||||
]
|
||||
|
||||
onMounted(loadSettings)
|
||||
|
||||
118
frontend/src/views/settings/SecurityHub.vue
Normal file
118
frontend/src/views/settings/SecurityHub.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="security-hub">
|
||||
<div class="hub-header">
|
||||
<h2>Access & Security</h2>
|
||||
<p class="description">Manage authentication, API tokens, users, roles, and audit logs</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<router-link
|
||||
:to="{ name: 'security-authentication' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'security-authentication' }"
|
||||
>
|
||||
Authentication
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'security-tokens' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'security-tokens' }"
|
||||
>
|
||||
API Tokens
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'security-users' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'security-users' }"
|
||||
>
|
||||
Users & Roles
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'security-audit' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'security-audit' }"
|
||||
>
|
||||
Audit Settings
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'security-auditlogs' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'security-auditlogs' }"
|
||||
>
|
||||
Audit Logs
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
if (route.name === 'security') {
|
||||
router.replace({ name: 'security-authentication' })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.security-hub {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hub-header h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
/* Content styled by child components */
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,32 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>ServiceNow</h2>
|
||||
<h2>Integrations</h2>
|
||||
<p class="header-description">Configure external service connections and APIs</p>
|
||||
</div>
|
||||
|
||||
<div class="integration-tabs">
|
||||
<router-link
|
||||
:to="{ name: 'servicenow-settings' }"
|
||||
class="integration-tab"
|
||||
:class="{ active: $route.name === 'servicenow-settings' }"
|
||||
>
|
||||
ServiceNow
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'zabbix-settings' }"
|
||||
class="integration-tab"
|
||||
:class="{ active: $route.name === 'zabbix-settings' }"
|
||||
>
|
||||
Zabbix Supplies
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'dell-warranty-settings' }"
|
||||
class="integration-tab"
|
||||
:class="{ active: $route.name === 'dell-warranty-settings' }"
|
||||
>
|
||||
Dell Warranty
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
@@ -103,3 +128,39 @@ const {
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-description {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.integration-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.integration-tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.integration-tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.integration-tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -31,8 +31,9 @@ import { useSettingsCatalog } from '../../composables/settingsCatalog'
|
||||
// Core settings groups merged with plugin-contributed cards (ADR-010).
|
||||
const { groups } = useSettingsCatalog()
|
||||
|
||||
// Drop empty groups (e.g. the Measuring Tools placeholder when its plugin is
|
||||
// disabled) so a bare section heading never renders.
|
||||
// Drop empty groups - a group can be a placeholder a plugin's cards land in,
|
||||
// and an absent plugin leaves it empty - so a bare section heading never
|
||||
// renders.
|
||||
const visibleGroups = computed(() => groups.value.filter(g => g.cards.length))
|
||||
</script>
|
||||
|
||||
|
||||
@@ -58,8 +58,8 @@ const search = ref('')
|
||||
// Filter the rail by title/description; drop groups that end up empty.
|
||||
const visibleGroups = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
// Drop empty groups (e.g. the Measuring Tools placeholder when its plugin is
|
||||
// disabled) so a bare heading never renders.
|
||||
// Drop empty groups - a group can be a placeholder a plugin's cards land in -
|
||||
// so a bare heading never renders.
|
||||
if (!term) return groups.value.filter(g => g.cards.length)
|
||||
return groups.value
|
||||
.map(g => ({
|
||||
|
||||
864
frontend/src/views/settings/ShopdbBackupsList.vue
Normal file
864
frontend/src/views/settings/ShopdbBackupsList.vue
Normal file
@@ -0,0 +1,864 @@
|
||||
<template>
|
||||
<div class="backups-page">
|
||||
<div class="page-header">
|
||||
<h2>Backups</h2>
|
||||
</div>
|
||||
|
||||
<!-- Confidence banner: the single most reassuring thing an admin wants to
|
||||
see - is there a recent, verified backup? Everything else is secondary. -->
|
||||
<div class="confidence-banner" :class="bannerClass">
|
||||
<span class="banner-icon">
|
||||
<component :is="bannerIcon" :size="22" />
|
||||
</span>
|
||||
<div class="banner-text">
|
||||
<strong>{{ bannerHeadline }}</strong>
|
||||
<span class="banner-sub">{{ bannerSub }}</span>
|
||||
</div>
|
||||
<button class="btn-primary" :disabled="isBusy" @click="runBackup">
|
||||
{{ isBusy ? 'Working...' : 'Back up now' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Light explanation, details tucked away. Leads the UI-simplification
|
||||
charge: one plain sentence up top, the depth behind a disclosure. -->
|
||||
<details class="explain">
|
||||
<summary>What is backed up, and how restore works</summary>
|
||||
<div class="explain-body">
|
||||
<p>
|
||||
Each backup is one recoverable set: the full database (users,
|
||||
settings, and every asset), the uploaded files, and a manifest that
|
||||
records the exact code version so a restore can never land on the
|
||||
wrong schema.
|
||||
</p>
|
||||
<p>
|
||||
Backups keep <strong>any day in the last 5</strong>, plus weekly and
|
||||
monthly copies. Restoring your <em>data</em> is separate from rolling
|
||||
back <em>code</em>: code is handled in git. If a restore needs a newer
|
||||
code version than you are running, this screen tells you before it
|
||||
lets you proceed.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Destination configuration: where sets ship and the credentials to get
|
||||
there. SMB and AWS are wired but not yet armed (banner says so)
|
||||
because their client libraries wait on the security review. -->
|
||||
<details class="explain destination" :open="settingsOpen">
|
||||
<summary @click.prevent="settingsOpen = !settingsOpen">
|
||||
Backup destination
|
||||
<span class="dest-chip">{{ destinationLabel(settings.destinationkind) }}</span>
|
||||
</summary>
|
||||
<div class="explain-body">
|
||||
<div v-if="settingsLoading" class="muted">Loading destination...</div>
|
||||
<template v-else>
|
||||
<label class="field">
|
||||
<span class="field-label">Ship backups to</span>
|
||||
<select v-model="settings.destinationkind" class="input">
|
||||
<option
|
||||
v-for="kind in settings.destinationkinds"
|
||||
:key="kind"
|
||||
:value="kind"
|
||||
>
|
||||
{{ destinationLabel(kind) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<p v-if="settings.destinationkind === 'local'" class="muted small">
|
||||
Sets stay on the mounted backups volume. No credentials needed.
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="not-armed"
|
||||
>
|
||||
<component :is="ShieldAlert" :size="16" />
|
||||
<span>
|
||||
This destination is configured but not yet armed. Its client
|
||||
library is held back until the security review clears it against
|
||||
external exploit; until then sets keep writing locally.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-if="settings.destinationkind === 'smb'">
|
||||
<label class="field">
|
||||
<span class="field-label">Server host</span>
|
||||
<input v-model="settings.fields.backup_smb_host" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Share</span>
|
||||
<input v-model="settings.fields.backup_smb_share" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Path within share</span>
|
||||
<input v-model="settings.fields.backup_smb_path" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Username</span>
|
||||
<input v-model="settings.fields.backup_smb_username" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Password</span>
|
||||
<input
|
||||
v-model="settings.fields.backup_smb_password"
|
||||
type="password"
|
||||
class="input"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<template v-else-if="settings.destinationkind === 'aws'">
|
||||
<label class="field">
|
||||
<span class="field-label">Region</span>
|
||||
<input v-model="settings.fields.backup_aws_region" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Bucket</span>
|
||||
<input v-model="settings.fields.backup_aws_bucket" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Key prefix</span>
|
||||
<input v-model="settings.fields.backup_aws_prefix" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Access key id</span>
|
||||
<input v-model="settings.fields.backup_aws_accesskeyid" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Secret access key</span>
|
||||
<input
|
||||
v-model="settings.fields.backup_aws_secretaccesskey"
|
||||
type="password"
|
||||
class="input"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn-primary" :disabled="savingSettings" @click="saveSettings">
|
||||
{{ savingSettings ? 'Saving...' : 'Save destination' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div v-if="loading" class="muted">Loading backups...</div>
|
||||
<div v-else-if="!sets.length" class="empty-state">
|
||||
<p>No backups yet. Run your first one above - it is safe to do anytime.</p>
|
||||
</div>
|
||||
|
||||
<table v-else class="backups-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Taken</th>
|
||||
<th>Size</th>
|
||||
<th>Status</th>
|
||||
<th>Restore</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="set in sets" :key="set.snapshotid">
|
||||
<td>
|
||||
<span class="taken">{{ formatTaken(set.takenat) }}</span>
|
||||
<span class="setid">{{ set.snapshotid }}</span>
|
||||
</td>
|
||||
<td>{{ formatSize(set.sizebytes) }}</td>
|
||||
<td>
|
||||
<span class="chip" :class="statusClass(set)">
|
||||
{{ statusLabel(set) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="btn-restore"
|
||||
:class="{ blocked: !set.iscompatible }"
|
||||
:disabled="isBusy"
|
||||
@click="openRestore(set)"
|
||||
>
|
||||
Restore...
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Operational run records, written by the backup container onto the
|
||||
shared volume. Two cards: what was backed up, and what was restored. -->
|
||||
<div class="history-cards">
|
||||
<div class="history-card">
|
||||
<h3 class="history-title">Backup history</h3>
|
||||
<p v-if="!backups.length" class="muted">No backup runs recorded yet.</p>
|
||||
<ul v-else class="history-list">
|
||||
<li v-for="(entry, index) in backups" :key="'b' + index" class="history-row">
|
||||
<span class="history-outcome" :class="outcomeClass(entry.outcome)">
|
||||
{{ outcomeLabel(entry.outcome) }}
|
||||
</span>
|
||||
<span class="history-set">{{ entry.setname || 'unknown set' }}</span>
|
||||
<span class="history-when">{{ formatTaken(entry.finishedat) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="history-card">
|
||||
<h3 class="history-title">Restore history</h3>
|
||||
<p v-if="!restores.length" class="muted">No restore runs recorded yet.</p>
|
||||
<ul v-else class="history-list">
|
||||
<li v-for="(entry, index) in restores" :key="'r' + index" class="history-row">
|
||||
<span class="history-outcome" :class="outcomeClass(entry.outcome)">
|
||||
{{ outcomeLabel(entry.outcome) }}
|
||||
</span>
|
||||
<span class="history-set">{{ entry.setname || 'unknown set' }}</span>
|
||||
<span class="history-when">{{ formatTaken(entry.finishedat) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Guarded restore modal: the one genuinely risky action, so it spends the
|
||||
UI budget on the compatibility verdict before allowing the click. -->
|
||||
<div v-if="restoreTarget" class="modal-backdrop" @click.self="closeRestore">
|
||||
<div class="modal">
|
||||
<h3>Restore this backup?</h3>
|
||||
<p class="modal-set">{{ formatTaken(restoreTarget.takenat || restoreTarget.snapshotid) }}</p>
|
||||
|
||||
<!-- Progress mode: once a restore is queued we swap the compatibility
|
||||
body for an indeterminate spinner and the current phase label. -->
|
||||
<template v-if="restorePhase">
|
||||
<div class="restore-progress" :class="{ failed: restorePhase === 'failed' }">
|
||||
<span v-if="isRestoreRunning" class="spinner" aria-hidden="true"></span>
|
||||
<component
|
||||
:is="restorePhase === 'done' ? ShieldCheck : AlertTriangle"
|
||||
v-else
|
||||
:size="18"
|
||||
/>
|
||||
<span class="restore-phase">{{ restorePhaseLabel }}</span>
|
||||
</div>
|
||||
<p class="restore-hint">
|
||||
Keep this open until the restore finishes. This can take a moment.
|
||||
</p>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
class="btn-ghost"
|
||||
:disabled="isRestoreRunning"
|
||||
@click="closeRestore"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="compat" :class="restoreTarget.iscompatible ? 'ok' : 'warn'">
|
||||
<component :is="restoreTarget.iscompatible ? ShieldCheck : AlertTriangle" :size="18" />
|
||||
<span v-if="restoreTarget.iscompatible">
|
||||
Compatible with the code you are running. Safe to restore.
|
||||
</span>
|
||||
<span v-else>
|
||||
This backup was taken on a newer schema than the running code.
|
||||
Upgrade the code first, then retry - restore is blocked until then.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<details class="explain small">
|
||||
<summary>What restoring does</summary>
|
||||
<div class="explain-body">
|
||||
<p>
|
||||
Restoring replaces the current database and uploaded files with the
|
||||
contents of this backup. Code is not changed. This cannot be undone,
|
||||
so it is gated on the compatibility check above.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn-ghost" @click="closeRestore">Cancel</button>
|
||||
<button
|
||||
class="btn-danger"
|
||||
:disabled="!restoreTarget.iscompatible || isBusy"
|
||||
@click="confirmRestore"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ShieldCheck, AlertTriangle, ShieldAlert, Clock } from 'lucide-vue-next'
|
||||
import api from '../../api'
|
||||
import { useToast } from '../../composables/toast'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const sets = ref([])
|
||||
const loading = ref(true)
|
||||
const isBusy = ref(false)
|
||||
const lastGoodAt = ref(null)
|
||||
const restoreTarget = ref(null)
|
||||
|
||||
// Live restore progress. restoreStatus mirrors the api /status.restore payload;
|
||||
// restorePhase drives the indeterminate spinner and label in the modal.
|
||||
const restoreStatus = ref(null)
|
||||
let statusTimer = null
|
||||
let restoreStartedAt = 0
|
||||
// The Flask-less backup sidecar loads the dump and parks the restore in
|
||||
// 'upgrading', because it cannot run `flask db upgrade` itself. The api can, so
|
||||
// the browser pokes the finalize endpoint once to roll the restored data up to
|
||||
// the running code. Guarded so we fire it exactly once per restore run.
|
||||
let finalizeRequested = false
|
||||
|
||||
const backups = ref([])
|
||||
const restores = ref([])
|
||||
|
||||
// Human labels for the phase names the watcher scripts write. Anything we do
|
||||
// not recognize falls back to the raw phase so a new phase still shows.
|
||||
const RESTORE_PHASE_LABELS = {
|
||||
queued: 'Queued...',
|
||||
checking: 'Checking compatibility...',
|
||||
files: 'Restoring files...',
|
||||
database: 'Loading database...',
|
||||
upgrading: 'Upgrading schema to code...',
|
||||
done: 'Restore complete',
|
||||
failed: 'Restore failed',
|
||||
}
|
||||
|
||||
const restorePhase = computed(() => restoreStatus.value?.phase || null)
|
||||
const isRestoreRunning = computed(() => {
|
||||
const phase = restorePhase.value
|
||||
return !!phase && phase !== 'done' && phase !== 'failed'
|
||||
})
|
||||
const restorePhaseLabel = computed(() => {
|
||||
const phase = restorePhase.value
|
||||
if (!phase) return ''
|
||||
return RESTORE_PHASE_LABELS[phase] || phase
|
||||
})
|
||||
|
||||
const settingsOpen = ref(false)
|
||||
const settingsLoading = ref(true)
|
||||
const savingSettings = ref(false)
|
||||
const settings = ref({
|
||||
destinationkind: 'local',
|
||||
destinationkinds: ['local'],
|
||||
fields: {},
|
||||
})
|
||||
|
||||
const DESTINATION_LABELS = {
|
||||
local: 'Local volume',
|
||||
smb: 'SMB / CIFS share',
|
||||
aws: 'AWS S3 bucket',
|
||||
}
|
||||
|
||||
function destinationLabel(kind) {
|
||||
return DESTINATION_LABELS[kind] || kind
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
settingsLoading.value = true
|
||||
try {
|
||||
const response = await api.get('/shopdbbackups/settings')
|
||||
const data = response.data.data || {}
|
||||
settings.value = {
|
||||
destinationkind: data.destinationkind || 'local',
|
||||
destinationkinds: data.destinationkinds || ['local'],
|
||||
fields: data.fields || {},
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Could not load backup destination.')
|
||||
} finally {
|
||||
settingsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
savingSettings.value = true
|
||||
try {
|
||||
const response = await api.put('/shopdbbackups/settings', {
|
||||
destinationkind: settings.value.destinationkind,
|
||||
fields: settings.value.fields,
|
||||
})
|
||||
const data = response.data.data || {}
|
||||
settings.value = {
|
||||
destinationkind: data.destinationkind || 'local',
|
||||
destinationkinds: data.destinationkinds || ['local'],
|
||||
fields: data.fields || {},
|
||||
}
|
||||
toast.success('Backup destination saved.')
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.data?.error?.message
|
||||
toast.error(message || 'Could not save the backup destination.')
|
||||
} finally {
|
||||
savingSettings.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get('/shopdbbackups')
|
||||
sets.value = response.data.data || []
|
||||
lastGoodAt.value = response.data.meta?.lastgoodat || null
|
||||
} catch (error) {
|
||||
toast.error('Could not load backups.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runBackup() {
|
||||
isBusy.value = true
|
||||
try {
|
||||
const response = await api.post('/shopdbbackups')
|
||||
toast.success(response.data.message || 'Backup queued.')
|
||||
} catch (error) {
|
||||
toast.error('Could not queue a backup.')
|
||||
} finally {
|
||||
isBusy.value = false
|
||||
load()
|
||||
}
|
||||
}
|
||||
|
||||
function openRestore(set) {
|
||||
restoreTarget.value = set
|
||||
}
|
||||
|
||||
function closeRestore() {
|
||||
stopStatusPolling()
|
||||
restoreStatus.value = null
|
||||
restoreTarget.value = null
|
||||
}
|
||||
|
||||
async function confirmRestore() {
|
||||
if (!restoreTarget.value?.iscompatible) return
|
||||
isBusy.value = true
|
||||
try {
|
||||
await api.post(`/shopdbbackups/${restoreTarget.value.snapshotid}/restore`)
|
||||
toast.success('Restore queued and validated against the live schema.')
|
||||
// Keep the modal open and switch it to progress mode; polling drives the
|
||||
// spinner through each phase until the watcher reports done or failed.
|
||||
startStatusPolling()
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.data?.error?.message
|
||||
toast.error(message || 'Could not queue the restore.')
|
||||
} finally {
|
||||
isBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const response = await api.get('/shopdbbackups/history')
|
||||
const data = response.data.data || {}
|
||||
backups.value = data.backups || []
|
||||
restores.value = data.restores || []
|
||||
} catch (error) {
|
||||
// History is non-critical; a failure here should not disrupt the page.
|
||||
}
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
let status
|
||||
try {
|
||||
const response = await api.get('/shopdbbackups/status')
|
||||
status = response.data.data?.restore || null
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
// Ignore a terminal status left over from a previous restore: if it finished
|
||||
// before we started this one, it is stale and would close the modal instantly
|
||||
// before the spinner ever paints. Wait for a fresh status from this run.
|
||||
if (status && (status.phase === 'done' || status.phase === 'failed')) {
|
||||
const finishedAt = new Date(normalizeStamp(status.updatedat || '')).getTime()
|
||||
if (Number.isNaN(finishedAt) || finishedAt < restoreStartedAt) {
|
||||
return
|
||||
}
|
||||
}
|
||||
restoreStatus.value = status
|
||||
const phase = status?.phase
|
||||
if (phase === 'upgrading' && !finalizeRequested) {
|
||||
// Hand off to the api container, which owns the Flask CLI, to run the
|
||||
// post-load schema upgrade. Fire once; polling keeps tracking until it
|
||||
// reports done or failed.
|
||||
finalizeRequested = true
|
||||
try {
|
||||
await api.post('/shopdbbackups/finalize-restore')
|
||||
} catch (error) {
|
||||
// The finalize endpoint stamps its own failed status on error, which the
|
||||
// next poll surfaces; nothing extra to do here.
|
||||
}
|
||||
}
|
||||
if (phase === 'done' || phase === 'failed') {
|
||||
stopStatusPolling()
|
||||
if (phase === 'done') {
|
||||
toast.success('Restore complete.')
|
||||
closeRestore()
|
||||
} else {
|
||||
toast.error(status?.message || 'Restore failed.')
|
||||
}
|
||||
load()
|
||||
loadHistory()
|
||||
}
|
||||
}
|
||||
|
||||
function startStatusPolling() {
|
||||
stopStatusPolling()
|
||||
restoreStartedAt = Date.now()
|
||||
finalizeRequested = false
|
||||
restoreStatus.value = { phase: 'queued', message: 'Restore queued' }
|
||||
// Poll on an interval only; skip the immediate call so the seeded "queued"
|
||||
// phase is guaranteed to paint before the first real status can replace it.
|
||||
statusTimer = setInterval(pollStatus, 1000)
|
||||
}
|
||||
|
||||
function stopStatusPolling() {
|
||||
if (statusTimer) {
|
||||
clearInterval(statusTimer)
|
||||
statusTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// -- Confidence banner state ------------------------------------------------
|
||||
const STALE_HOURS = 48
|
||||
|
||||
const hoursSinceLastGood = computed(() => {
|
||||
if (!lastGoodAt.value) return null
|
||||
const then = new Date(normalizeStamp(lastGoodAt.value)).getTime()
|
||||
return (Date.now() - then) / 36e5
|
||||
})
|
||||
|
||||
const bannerState = computed(() => {
|
||||
if (!lastGoodAt.value) return 'none'
|
||||
if (hoursSinceLastGood.value > STALE_HOURS) return 'stale'
|
||||
return 'good'
|
||||
})
|
||||
|
||||
const bannerClass = computed(() => `banner-${bannerState.value}`)
|
||||
const bannerIcon = computed(() => ({
|
||||
good: ShieldCheck, stale: Clock, none: ShieldAlert,
|
||||
}[bannerState.value]))
|
||||
const bannerHeadline = computed(() => ({
|
||||
good: 'Your data is protected',
|
||||
stale: 'Backup is overdue',
|
||||
none: 'No verified backup yet',
|
||||
}[bannerState.value]))
|
||||
const bannerSub = computed(() => {
|
||||
if (bannerState.value === 'none') return 'Run one now - it only takes a moment.'
|
||||
return `Last verified backup ${formatTaken(lastGoodAt.value)}.`
|
||||
})
|
||||
|
||||
// -- Formatting -------------------------------------------------------------
|
||||
function formatTaken(value) {
|
||||
if (!value) return 'unknown'
|
||||
const date = new Date(normalizeStamp(value))
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
// Stamps are UTC; render them in UTC so the displayed time matches the
|
||||
// Zulu-stamped set id rather than shifting to the operator's local zone.
|
||||
return date.toLocaleString(undefined, { timeZone: 'UTC', timeZoneName: 'short' })
|
||||
}
|
||||
|
||||
function outcomeClass(outcome) {
|
||||
return outcome === 'success' ? 'outcome-success' : 'outcome-failed'
|
||||
}
|
||||
|
||||
function outcomeLabel(outcome) {
|
||||
return outcome === 'success' ? 'Success' : 'Failed'
|
||||
}
|
||||
|
||||
// Backup ids and manifest stamps use a filename-safe UTC form with no colons
|
||||
// (YYYY-MM-DDTHHMMSSZ, sometimes prefixed with a site name), which the Date
|
||||
// parser rejects. Reinsert the colons so it parses as real ISO 8601.
|
||||
function normalizeStamp(value) {
|
||||
const match = String(value).match(/(\d{4}-\d{2}-\d{2})T(\d{2})(\d{2})(\d{2})Z$/)
|
||||
if (!match) return value
|
||||
const [, day, hour, minute, second] = match
|
||||
return `${day}T${hour}:${minute}:${second}Z`
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024
|
||||
unit += 1
|
||||
}
|
||||
return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`
|
||||
}
|
||||
|
||||
function statusClass(set) {
|
||||
if (set.ischeckpassed) return 'chip-verified'
|
||||
if (!set.hasmanifest) return 'chip-unverified'
|
||||
return 'chip-pending'
|
||||
}
|
||||
|
||||
function statusLabel(set) {
|
||||
if (set.ischeckpassed) return 'Verified'
|
||||
if (!set.hasmanifest) return 'Unverified'
|
||||
return 'Not checked'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadSettings()
|
||||
loadHistory()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopStatusPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.confidence-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.confidence-banner .banner-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
.banner-sub {
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.banner-good { border-color: #2f855a; }
|
||||
.banner-good .banner-icon { color: #38a169; }
|
||||
.banner-stale { border-color: #b7791f; }
|
||||
.banner-stale .banner-icon { color: #d69e2e; }
|
||||
.banner-none { border-color: #c53030; }
|
||||
.banner-none .banner-icon { color: #e53e3e; }
|
||||
|
||||
.explain {
|
||||
margin-bottom: 1.25rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.explain.small { margin: 0.75rem 0; }
|
||||
.explain summary {
|
||||
cursor: pointer;
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.86rem;
|
||||
color: var(--text-light);
|
||||
user-select: none;
|
||||
}
|
||||
.explain-body {
|
||||
padding: 0 0.9rem 0.8rem 0.9rem;
|
||||
}
|
||||
.explain-body p {
|
||||
margin: 0 0 0.6rem 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.backups-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.backups-table th,
|
||||
.backups-table td {
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.backups-table th {
|
||||
color: var(--text-light);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.taken { display: block; color: var(--text); }
|
||||
.setid { display: block; color: var(--text-light); font-size: 0.74rem; }
|
||||
|
||||
.chip {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.chip-verified { background: rgba(56, 161, 105, 0.18); color: #48bb78; }
|
||||
.chip-pending { background: rgba(214, 158, 46, 0.18); color: #ecc94b; }
|
||||
.chip-unverified { background: rgba(229, 62, 62, 0.18); color: #fc8181; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
.btn-restore {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-restore.blocked { opacity: 0.55; }
|
||||
|
||||
.empty-state, .muted {
|
||||
color: var(--text-light);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
}
|
||||
.modal {
|
||||
width: min(460px, 92vw);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1.2rem;
|
||||
}
|
||||
.modal h3 { margin: 0 0 0.3rem 0; }
|
||||
.modal-set { color: var(--text-light); font-size: 0.8rem; margin: 0 0 0.9rem 0; }
|
||||
.compat {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.compat.ok { background: rgba(56, 161, 105, 0.15); color: #9ae6b4; }
|
||||
.compat.warn { background: rgba(214, 158, 46, 0.15); color: #f6e05e; }
|
||||
|
||||
.restore-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(56, 161, 105, 0.15);
|
||||
color: #9ae6b4;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.restore-progress.failed { background: rgba(197, 48, 48, 0.15); color: #feb2b2; }
|
||||
.restore-phase { font-weight: 600; }
|
||||
.restore-hint {
|
||||
color: var(--text-light);
|
||||
font-size: 0.8rem;
|
||||
margin: 0.5rem 0 0 0;
|
||||
}
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(154, 230, 180, 0.35);
|
||||
border-top-color: #9ae6b4;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.history-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.history-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
.history-title {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.history-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.history-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.history-row:first-child { border-top: none; }
|
||||
.history-outcome {
|
||||
flex-shrink: 0;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.outcome-success { background: rgba(56, 161, 105, 0.15); color: #9ae6b4; }
|
||||
.outcome-failed { background: rgba(197, 48, 48, 0.15); color: #feb2b2; }
|
||||
.history-set {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.history-when { color: var(--text-light); flex-shrink: 0; }
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-danger {
|
||||
background: #c53030;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-danger:disabled { opacity: 0.5; cursor: default; }
|
||||
</style>
|
||||
111
frontend/src/views/settings/SiteConfigHub.vue
Normal file
111
frontend/src/views/settings/SiteConfigHub.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="site-config-hub">
|
||||
<div class="hub-header">
|
||||
<h2>Site Configuration</h2>
|
||||
<p class="description">Configure site identity, facility settings, branding, and asset imports</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<router-link
|
||||
:to="{ name: 'site-config-site' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'site-config-site' }"
|
||||
>
|
||||
Site & Facility
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'site-config-floormap' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'site-config-floormap' }"
|
||||
>
|
||||
Floor Map
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'site-config-branding' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'site-config-branding' }"
|
||||
>
|
||||
Branding
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'site-config-imports' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'site-config-imports' }"
|
||||
>
|
||||
Import Site Assets
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
if (route.name === 'site-config') {
|
||||
router.replace({ name: 'site-config-site' })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.site-config-hub {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hub-header h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
/* Content styled by child components */
|
||||
}
|
||||
</style>
|
||||
@@ -115,8 +115,7 @@ const LABELS = {
|
||||
printer_hostname_template: 'Printer Hostname Template',
|
||||
contact_email_domain: 'Contact Email Domain',
|
||||
dualpath_single_machine: 'Dualpath as Single Machine',
|
||||
employee_directory_mode: 'Employee Directory Mode',
|
||||
usb_directory_mode: 'USB Directory Mode',
|
||||
employee_photo_base_url: 'Employee Photo Base URL',
|
||||
site_timezone: 'Site Timezone',
|
||||
audit_retentiondays: 'Audit Log Retention (days)',
|
||||
setup_complete: 'Setup Complete',
|
||||
@@ -128,8 +127,6 @@ function prettyLabel(key) {
|
||||
|
||||
// Enumerated settings render as dropdowns to prevent typos.
|
||||
const OPTIONS = {
|
||||
employee_directory_mode: ['selfhosted', 'external'],
|
||||
usb_directory_mode: ['selfhosted', 'external'],
|
||||
site_timezone: [
|
||||
'America/New_York', 'America/Chicago', 'America/Denver', 'America/Phoenix',
|
||||
'America/Los_Angeles', 'America/Anchorage', 'Pacific/Honolulu',
|
||||
@@ -147,7 +144,7 @@ const GROUPS = [
|
||||
{ title: 'Identity', keys: ['facility_name', 'site_base_url'] },
|
||||
{ title: 'Behavior', keys: ['dualpath_single_machine'] },
|
||||
{ title: 'Naming & Patterns', keys: ['pc_access_domain', 'printer_hostname_template', 'contact_email_domain', 'employeeid_pattern'] },
|
||||
{ title: 'Data Sources', keys: ['employee_directory_mode', 'usb_directory_mode'] },
|
||||
{ title: 'Data Sources', keys: ['employee_photo_base_url'] },
|
||||
{ title: 'Localization', keys: ['site_timezone'] },
|
||||
{ title: 'Audit Log', keys: ['audit_retentiondays'] },
|
||||
{ title: 'System', keys: ['setup_complete'] }
|
||||
@@ -155,8 +152,7 @@ const GROUPS = [
|
||||
|
||||
// Inline help for site fields that need more than the stored description.
|
||||
const HELP = {
|
||||
employee_directory_mode: 'Where employee records live: selfhosted (managed in this app, with CSV import and photo uploads) or external (a read-only HR database configured via EMPLOYEE_DB_* environment variables).',
|
||||
usb_directory_mode: 'Where USB checkout data lives: selfhosted (tables in this app) or external (a separate cmmc_usb database via CMMC_USB_DB_* environment variables).',
|
||||
employee_photo_base_url: 'Where a relative photo path from the HR directory resolves, e.g. https://host/EmployeeDBAPP/images/. Blank serves them from this site\'s /static/employees/. The directory itself is read-only and configured with the EMPLOYEE_DB_* environment variables.',
|
||||
setup_complete: 'Set automatically when the first-run setup wizard finishes. Turning it off sends admins back to the /setup wizard on next login.',
|
||||
employeeid_pattern: 'Regular expression that a scanned/typed employee ID must match to be recognized. Default: ^\\d{9}$ (9 digits). An invalid regex is ignored and the default is used.',
|
||||
printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net', // ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
|
||||
|
||||
@@ -154,15 +154,36 @@
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Roles</label>
|
||||
<label for="userscope">Scope</label>
|
||||
<select id="userscope" v-model="selectedRoleId">
|
||||
<option :value="null">No scope (sees nothing until granted)</option>
|
||||
<option v-for="role in roles" :key="role.roleid" :value="role.roleid">
|
||||
{{ scopeLabel(role.rolename) }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="field-hint">{{ scopeDescription }}</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Endpoint Actions</label>
|
||||
<p class="field-hint">{{ endpointActionsHint }}</p>
|
||||
<div class="checkbox-group">
|
||||
<label v-for="role in roles" :key="role.roleid" class="checkbox-label">
|
||||
<label
|
||||
v-for="action in endpointActionPermissions"
|
||||
:key="action.name"
|
||||
class="checkbox-label"
|
||||
:class="{ 'checkbox-label-disabled': !endpointActionsSelectable }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="role.roleid"
|
||||
v-model="userForm.roles"
|
||||
:value="action.name"
|
||||
:checked="isActionChecked(action.name)"
|
||||
:disabled="!endpointActionsSelectable"
|
||||
@change="toggleAction(action.name, $event.target.checked)"
|
||||
>
|
||||
{{ role.rolename }}
|
||||
{{ action.label }}
|
||||
<span v-if="endpointActionsSelectable && heldThroughRole(action.name)" class="field-hint">
|
||||
(already granted by a role)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,7 +327,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { usersApi } from '../../api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
@@ -340,10 +361,168 @@ const userForm = reactive({
|
||||
lastname: '',
|
||||
password: '',
|
||||
roles: [],
|
||||
directpermissions: [],
|
||||
isactive: true,
|
||||
unlock: false
|
||||
})
|
||||
|
||||
// The endpoint actions the shop gates per person rather than per policy.
|
||||
// Named here rather than derived from the permission catalog so the user editor
|
||||
// stays a short, meaningful list instead of the full permission grid, which
|
||||
// belongs to the role editor. Mirrors SCOPE_ENDPOINT_ACTIONS in
|
||||
// shopdb/core/models/user.py; adding one here without adding it there leaves a
|
||||
// checkbox the server will refuse to store.
|
||||
const endpointActionPermissions = [
|
||||
{ name: 'endpoints.restart', label: 'Remote Restart' },
|
||||
{ name: 'endpoints.runscript', label: 'Script Push' },
|
||||
{ name: 'endpoints.updateapp', label: 'Application Update' },
|
||||
{ name: 'endpoints.corptools', label: 'Corporate Tool Use' },
|
||||
{ name: 'endpoints.operatorreports', label: 'Operator Reports' }
|
||||
]
|
||||
|
||||
// What each scope means, in the words shown at the point of use.
|
||||
//
|
||||
// This text is doing real work, not decorating. Operator and Technician carry
|
||||
// the SAME role grants - both are read-only on records at the role level - and
|
||||
// differ only in whether the endpoint action checkboxes below are offered. Read
|
||||
// off the role grid alone the two look identical, so the description is what
|
||||
// makes the distinction legible to the person choosing, at the moment they
|
||||
// choose. Keep it accurate if the scopes change.
|
||||
const SCOPE_INFO = {
|
||||
admin: {
|
||||
label: 'Admin - full access',
|
||||
description: 'Full access to everything, including deleting records, ' +
|
||||
'managing users, and site settings.',
|
||||
actions: 'inherent'
|
||||
},
|
||||
operator: {
|
||||
label: 'Operator - create and edit',
|
||||
description: 'Can view, create, and edit records, and can see the user ' +
|
||||
'directory. Cannot delete records, manage users, or change settings - ' +
|
||||
'deletion stays with admins.',
|
||||
actions: 'selectable'
|
||||
},
|
||||
technician: {
|
||||
label: 'Technician - endpoint work, no record edits',
|
||||
description: 'Can view records and the user directory but cannot create ' +
|
||||
'or edit them, and cannot author policy. Intended for endpoint work: ' +
|
||||
'grant the actions below as needed.',
|
||||
actions: 'selectable'
|
||||
},
|
||||
reporter: {
|
||||
label: 'Reporter - read and export only',
|
||||
description: 'Can view everything and export reports. Holds no action ' +
|
||||
'capability and cannot be granted one, so the endpoint actions below ' +
|
||||
'are unavailable for this scope.',
|
||||
actions: 'denied'
|
||||
}
|
||||
}
|
||||
|
||||
// Scopes the server refuses to store endpoint actions against. Mirrors
|
||||
// SCOPES_DENIED_ENDPOINT_ACTIONS in shopdb/core/models/user.py. Greying the
|
||||
// boxes here is a courtesy that explains the rule before it is hit; the API is
|
||||
// what enforces it, so the two must agree or the UI offers something the save
|
||||
// will reject.
|
||||
const SCOPES_DENIED_ACTIONS = ['reporter']
|
||||
|
||||
// A single-select scope, kept as a one-item array on the way to the API so the
|
||||
// existing `roles: [roleid...]` contract is untouched. Roles remain
|
||||
// many-to-many in the database; this is a UI constraint, chosen because no user
|
||||
// in the live data holds more than one role and stacking them mainly produces
|
||||
// the contradictory combinations the server now refuses.
|
||||
const selectedRoleId = computed({
|
||||
get: () => (userForm.roles.length ? userForm.roles[0] : null),
|
||||
set: (value) => {
|
||||
userForm.roles = value === null ? [] : [value]
|
||||
// Clear now-invalid grants rather than leaving them staged for a rejection.
|
||||
// Without this, switching an operator to reporter would keep the restart
|
||||
// grant in the form and the save would fail with a 400 the user did not
|
||||
// knowingly cause.
|
||||
if (!endpointActionsSelectable.value) {
|
||||
userForm.directpermissions = userForm.directpermissions.filter(
|
||||
(name) => !endpointActionPermissions.some((a) => a.name === name))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const selectedRoleName = computed(() => {
|
||||
const role = roles.value.find((r) => r.roleid === selectedRoleId.value)
|
||||
return role ? role.rolename : null
|
||||
})
|
||||
|
||||
const scopeDescription = computed(() => {
|
||||
const name = selectedRoleName.value
|
||||
if (!name) {
|
||||
return 'No scope assigned. This user can sign in but will see no ' +
|
||||
'navigation until a scope is chosen.'
|
||||
}
|
||||
const info = SCOPE_INFO[name]
|
||||
return info
|
||||
? info.description
|
||||
: 'Custom role. Access is whatever this role has been granted in the ' +
|
||||
'role editor.'
|
||||
})
|
||||
|
||||
// Admin's boxes are shown ticked but disabled: an admin already passes every
|
||||
// permission check by rule, so a stored grant would be redundant, and leaving
|
||||
// them unticked would wrongly suggest an admin cannot restart a machine.
|
||||
const scopeActionMode = computed(() => {
|
||||
const name = selectedRoleName.value
|
||||
if (!name) return 'selectable'
|
||||
return SCOPE_INFO[name] ? SCOPE_INFO[name].actions : 'selectable'
|
||||
})
|
||||
|
||||
const endpointActionsSelectable = computed(
|
||||
() => scopeActionMode.value === 'selectable')
|
||||
|
||||
const endpointActionsHint = computed(() => {
|
||||
if (scopeActionMode.value === 'inherent') {
|
||||
return 'Admins hold every action automatically; these are shown for ' +
|
||||
'reference and are not stored as individual grants.'
|
||||
}
|
||||
if (scopeActionMode.value === 'denied') {
|
||||
return 'The Reporter scope cannot hold endpoint actions. Choose ' +
|
||||
'Technician or Operator if this user needs to act on endpoints.'
|
||||
}
|
||||
return 'Granted to this user directly, in addition to anything their scope ' +
|
||||
'already provides.'
|
||||
})
|
||||
|
||||
function isActionChecked(name) {
|
||||
// Admin: display-only ticks. Nothing is stored, per the API guard's note that
|
||||
// grants on an admin are meaningless and become misleading if the scope
|
||||
// later changes.
|
||||
if (scopeActionMode.value === 'inherent') return true
|
||||
if (scopeActionMode.value === 'denied') return false
|
||||
return userForm.directpermissions.includes(name)
|
||||
}
|
||||
|
||||
function toggleAction(name, checked) {
|
||||
if (!endpointActionsSelectable.value) return
|
||||
const has = userForm.directpermissions.includes(name)
|
||||
if (checked && !has) {
|
||||
userForm.directpermissions.push(name)
|
||||
} else if (!checked && has) {
|
||||
userForm.directpermissions = userForm.directpermissions.filter(
|
||||
(n) => n !== name)
|
||||
}
|
||||
}
|
||||
|
||||
function scopeLabel(rolename) {
|
||||
return SCOPE_INFO[rolename] ? SCOPE_INFO[rolename].label : rolename
|
||||
}
|
||||
|
||||
// A capability supplied by a role is shown but not implied by the checkbox:
|
||||
// the checkbox reflects the DIRECT grant only, so unticking it cannot appear to
|
||||
// remove something only the role can remove.
|
||||
function heldThroughRole(name) {
|
||||
const user = editingUser.value
|
||||
if (!user) return false
|
||||
const effective = user.permissions || []
|
||||
const direct = user.directpermissions || []
|
||||
return effective.includes(name) && !direct.includes(name)
|
||||
}
|
||||
|
||||
const roleForm = reactive({
|
||||
rolename: '',
|
||||
description: '',
|
||||
@@ -452,6 +631,7 @@ function editUser(user) {
|
||||
userForm.lastname = user.lastname || ''
|
||||
userForm.password = ''
|
||||
userForm.roles = user.roles.map(r => r.roleid)
|
||||
userForm.directpermissions = [...(user.directpermissions || [])]
|
||||
userForm.isactive = user.isactive
|
||||
userForm.unlock = false
|
||||
}
|
||||
@@ -461,7 +641,7 @@ function closeUserModal() {
|
||||
editingUser.value = null
|
||||
Object.assign(userForm, {
|
||||
username: '', email: '', firstname: '', lastname: '',
|
||||
password: '', roles: [], isactive: true, unlock: false
|
||||
password: '', roles: [], directpermissions: [], isactive: true, unlock: false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -473,7 +653,15 @@ async function saveUser() {
|
||||
email: userForm.email,
|
||||
firstname: userForm.firstname || null,
|
||||
lastname: userForm.lastname || null,
|
||||
roles: userForm.roles
|
||||
roles: userForm.roles,
|
||||
// Admin and Reporter never SEND endpoint actions. For admin the ticks are
|
||||
// display-only and a stored grant would be redundant; for reporter the
|
||||
// API refuses them outright. Stripping here keeps the payload honest
|
||||
// rather than relying on the form state having been cleared.
|
||||
directpermissions: endpointActionsSelectable.value
|
||||
? userForm.directpermissions
|
||||
: userForm.directpermissions.filter(
|
||||
(name) => !endpointActionPermissions.some((a) => a.name === name))
|
||||
}
|
||||
|
||||
if (editingUser.value) {
|
||||
@@ -744,6 +932,12 @@ onMounted(loadData)
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -755,6 +949,19 @@ onMounted(loadData)
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Uses --text-light, an existing theme token, rather than a hardcoded grey:
|
||||
a literal colour here would read as disabled in light mode and near-invisible
|
||||
in dark. */
|
||||
.checkbox-label-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.checkbox-label-disabled input[type="checkbox"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
303
frontend/src/views/settings/asset-types/SubtypesTab.vue
Normal file
303
frontend/src/views/settings/asset-types/SubtypesTab.vue
Normal file
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<div class="subtypes-tab">
|
||||
<div class="tab-header">
|
||||
<h3>{{ typeLabel }} Types</h3>
|
||||
<div class="actions">
|
||||
<label class="show-inactive">
|
||||
<input type="checkbox" v-model="showInactive" />
|
||||
Show inactive
|
||||
</label>
|
||||
<button v-if="descriptor" class="btn btn-primary" @click="openModal()">
|
||||
+ Add {{ typeLabel }} Type
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<div v-else-if="!descriptor" class="empty-state">
|
||||
<p>No descriptor found for this asset type.</p>
|
||||
<p class="hint">The plugin may not have implemented the get_asset_type_descriptor hook yet.</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ capitalize(descriptor.subtype_label_field) }}</th>
|
||||
<th>Description</th>
|
||||
<th>Color</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in visibleItems" :key="item[descriptor.subtype_id_field]">
|
||||
<td>{{ item[descriptor.subtype_label_field] }}</td>
|
||||
<td class="cell-truncate" :title="item[descriptor.subtype_description_field]">
|
||||
{{ item[descriptor.subtype_description_field] || '-' }}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
v-if="item[descriptor.subtype_color_field]"
|
||||
class="color-badge"
|
||||
:style="{ backgroundColor: item[descriptor.subtype_color_field] }"
|
||||
>
|
||||
{{ item[descriptor.subtype_color_field] }}
|
||||
</span>
|
||||
<span v-else class="text-light">auto</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<span v-if="item.isactive === false" class="badge badge-secondary" style="margin-right:6px;">
|
||||
inactive
|
||||
</span>
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(item)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteItem(item)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="visibleItems.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No {{ typeLabel }} types found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editing ? 'Edit' : 'Add' }} {{ typeLabel }} Type</h3>
|
||||
<button class="modal-close" @click="closeModal">×</button>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>{{ capitalize(descriptor.subtype_label_field) }} *</label>
|
||||
<input
|
||||
v-model="form[descriptor.subtype_label_field]"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
v-model="form[descriptor.subtype_description_field]"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
|
||||
<ColorSwatchPicker v-model="form[descriptor.subtype_color_field]" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="form.isactive" />
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import api from '@/api'
|
||||
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const props = defineProps({
|
||||
assettypeid: { type: [Number, String], required: true },
|
||||
descriptor: { type: Object, default: null }
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const items = ref([])
|
||||
const showInactive = ref(false)
|
||||
const loading = ref(true)
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({})
|
||||
|
||||
const typeLabel = computed(() =>
|
||||
props.descriptor?.type_name || capitalize(props.descriptor?.assettype) || 'Type'
|
||||
)
|
||||
|
||||
const visibleItems = computed(() =>
|
||||
showInactive.value
|
||||
? items.value
|
||||
: items.value.filter(x => x.isactive !== false)
|
||||
)
|
||||
|
||||
onMounted(loadData)
|
||||
watch(() => props.descriptor, loadData)
|
||||
|
||||
async function loadData() {
|
||||
if (!props.descriptor) return
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get(props.descriptor.subtype_endpoint, {
|
||||
params: { perpage: 100, active: false }
|
||||
})
|
||||
items.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading subtypes:', err)
|
||||
toast.error(apiError(err, 'Failed to load types'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
const labelField = props.descriptor.subtype_label_field
|
||||
const descField = props.descriptor.subtype_description_field
|
||||
const colorField = props.descriptor.subtype_color_field
|
||||
|
||||
form.value = item
|
||||
? {
|
||||
[labelField]: item[labelField] || '',
|
||||
[descField]: item[descField] || '',
|
||||
[colorField]: item[colorField] || '',
|
||||
isactive: item.isactive !== false
|
||||
}
|
||||
: {
|
||||
[labelField]: '',
|
||||
[descField]: '',
|
||||
[colorField]: '',
|
||||
isactive: true
|
||||
}
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editing.value = null
|
||||
}
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
const endpoint = props.descriptor.subtype_endpoint
|
||||
const idField = props.descriptor.subtype_id_field
|
||||
|
||||
if (editing.value) {
|
||||
await api.put(`${endpoint}/${editing.value[idField]}`, form.value)
|
||||
} else {
|
||||
await api.post(endpoint, form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteItem(item) {
|
||||
const labelField = props.descriptor.subtype_label_field
|
||||
const idField = props.descriptor.subtype_id_field
|
||||
|
||||
if (!confirm(`Delete ${typeLabel.value} type "${item[labelField]}"?`)) return
|
||||
|
||||
try {
|
||||
await api.delete(`${props.descriptor.subtype_endpoint}/${item[idField]}`)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
|
||||
function capitalize(str) {
|
||||
if (!str) return ''
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subtypes-tab {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.tab-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tab-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.show-inactive {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.color-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85rem;
|
||||
color: white;
|
||||
text-shadow: 0 0 2px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.cell-truncate {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
29
frontend/src/views/settings/asset-types/SuppliesTab.vue
Normal file
29
frontend/src/views/settings/asset-types/SuppliesTab.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="supplies-tab">
|
||||
<div class="page-header">
|
||||
<h3>Supplies Management</h3>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p>Placeholder for printer supplies management (printer types specific).</p>
|
||||
<p class="hint">This tab proves the optional tabs pattern works - it only appears for printers.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
assettypeid: { type: Number, required: true }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.supplies-tab {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
45
frontend/src/views/settings/asset-types/TypesTabWrapper.vue
Normal file
45
frontend/src/views/settings/asset-types/TypesTabWrapper.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<component
|
||||
:is="tabComponent"
|
||||
v-if="tabComponent"
|
||||
:assettypeid="assettypeid"
|
||||
:descriptor="descriptor"
|
||||
/>
|
||||
<div v-else class="loading">Loading...</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { shallowRef, watch, defineAsyncComponent, markRaw } from 'vue'
|
||||
import SubtypesTab from './SubtypesTab.vue'
|
||||
|
||||
const props = defineProps({
|
||||
assettypeid: { type: [Number, String], required: true },
|
||||
descriptor: { type: Object, default: null }
|
||||
})
|
||||
|
||||
const tabComponent = shallowRef(null)
|
||||
|
||||
watch(() => props.descriptor, async (desc) => {
|
||||
if (!desc) {
|
||||
tabComponent.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (desc.custom_component) {
|
||||
const componentName = desc.custom_component
|
||||
tabComponent.value = markRaw(defineAsyncComponent(() =>
|
||||
import(`../types/${componentName}.vue`)
|
||||
))
|
||||
} else {
|
||||
tabComponent.value = markRaw(SubtypesTab)
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
@@ -4,95 +4,59 @@
|
||||
// Order: site identity + the reference-data catalogs users touch daily come
|
||||
// first, then the platform/system groups (integrations, communication, search,
|
||||
// plugins, access) cluster together at the end.
|
||||
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key, Upload } from 'lucide-vue-next'
|
||||
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key, Upload, DatabaseBackup } from 'lucide-vue-next'
|
||||
|
||||
export const settingsGroups = [
|
||||
{
|
||||
title: 'Site & Facility',
|
||||
cards: [
|
||||
{ to: '/settings/site', icon: Home, title: 'Site & Facility', description: 'Site URL/FQDN, facility name, PC access domain, employee-ID pattern, printer hostname template' },
|
||||
{ to: '/settings/floormap', icon: MapPin, title: 'Floor Map', description: 'Facility floor-plan blueprint image and pixel dimensions (width, height) for the site map' },
|
||||
{ to: '/settings/branding', icon: Palette, title: 'Branding', description: 'Site, QR, and badge logos, favicon, and brand primary, accent, sidebar, and theme colors' },
|
||||
{ to: '/settings/importsiteassets', icon: Upload, title: 'Import Site Assets', description: 'Seed Machines, PCs, and Applications via the Asset Collector script, existing databases, or Intune/Azure exports; set the shared CSV collection location and download collected files' },
|
||||
{ to: '/settings/siteconfig', icon: Home, title: 'Site Configuration', description: 'Site identity, facility settings, floor map, branding, and asset imports' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'General Reference',
|
||||
cards: [
|
||||
{ to: '/settings/vendors', icon: Factory, title: 'Vendors', description: 'Manage equipment vendors and manufacturers' },
|
||||
{ to: '/settings/models', icon: Package, title: 'Models', description: 'Manage equipment models by vendor' },
|
||||
{ to: '/settings/modeltypes', icon: Monitor, title: 'Model Types', description: 'Manage model type categories' },
|
||||
{ to: '/settings/statuses', icon: Tag, title: 'Statuses', description: 'Manage asset status types' },
|
||||
{ to: '/settings/relationshiptypes', icon: Link, title: 'Relationship Types', description: 'Manage asset relationship types (Controls, Contains...) + colors' },
|
||||
{ to: '/settings/assettypes', icon: Palette, title: 'Asset Type Colors', description: 'Map colors for the top-level asset categories' },
|
||||
{ to: '/settings/customfields', icon: SlidersHorizontal, title: 'Custom Fields', description: 'Define extra attributes per asset type (shown on detail + forms)' },
|
||||
{ to: '/settings/catalog', icon: Package, title: 'Model Catalog', description: 'Manage vendors, model types, equipment models, and operating systems' },
|
||||
{ to: '/settings/assetconfig', icon: Cog, title: 'Asset Configuration', description: 'Configure asset statuses, relationship types, custom fields, and identifiers' },
|
||||
{ to: '/settings/assettypes', icon: Factory, title: 'Asset Types', description: 'Manage asset type subtypes, colors, and custom fields for all asset categories' },
|
||||
{ to: '/settings/supportteams', icon: Users, title: 'Support Teams', description: 'Application support teams and their contacts (ServiceNow group links)' },
|
||||
// Operating Systems is cross-asset (PCs, machines, measuring tools, network
|
||||
// devices all run an OS), so it lives in General Reference, not under PCs.
|
||||
{ to: '/settings/operatingsystems', icon: Cog, title: 'Operating Systems', description: 'Manage OS versions and EOL dates (used by PCs, machines, network devices, and more)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'PCs',
|
||||
cards: [
|
||||
{ to: '/settings/pctypes', icon: Laptop, title: 'PC Types', description: 'Manage PC form factors + map colors' },
|
||||
{ to: '/settings/accessprotocols', icon: Network, title: 'PC Access Protocols', description: 'Remote-access protocols (VNC, RDP, WinRM) and link templates' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Printers',
|
||||
cards: [
|
||||
{ to: '/settings/printertypes', icon: Printer, title: 'Printer Types', description: 'Manage printer subtypes + map colors' },
|
||||
{ to: '/settings/modelsupplies', icon: Droplets, title: 'Model Toners and Supplies', description: 'Map toner, drum, and waste part numbers to printer models' },
|
||||
{ to: '/settings/printerdrivers', icon: Printer, title: 'Printer Drivers', description: 'Named SMB / HTTP links to printer driver packages' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Machines',
|
||||
cards: [
|
||||
{ to: '/settings/machinetypes', icon: Wrench, title: 'Machine Types', description: 'Manage machine subtypes + map colors' },
|
||||
],
|
||||
},
|
||||
// The "Measuring Tools" cards are contributed by the measuringtools plugin via
|
||||
// the ADR-010 get_settings_cards hook (merged in composables/settingsCatalog).
|
||||
// This empty placeholder fixes the group's position among the asset groups
|
||||
// (right after Machines); the merger fills it in place instead of appending a
|
||||
// new group at the end. When the plugin is disabled the group stays empty and
|
||||
// the rail/landing drop it (empty groups never render).
|
||||
{
|
||||
title: 'Measuring Tools',
|
||||
cards: [],
|
||||
},
|
||||
{
|
||||
title: 'Locations & Organization',
|
||||
cards: [
|
||||
{ to: '/settings/employeedirectory', icon: Contact, title: 'Employee Directory', description: 'Manage the self-hosted people directory (add/edit/import); read-only in external HR mode' },
|
||||
{ to: '/settings/locations', icon: MapPin, title: 'Locations', description: 'Manage physical locations and sites' },
|
||||
{ to: '/settings/locationtypes', icon: Tag, title: 'Location Types', description: 'Manage location types + colors' },
|
||||
{ to: '/settings/businessunits', icon: Building, title: 'Business Units', description: 'Manage organizational units' },
|
||||
{ to: '/settings/locations', icon: MapPin, title: 'Locations & Organization', description: 'Physical locations, business units, and employee directory' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Network',
|
||||
cards: [
|
||||
{ to: '/settings/networktypes', icon: Router, title: 'Network Device Types', description: 'Manage network device subtypes + map colors' },
|
||||
{ to: '/settings/vlans', icon: Globe, title: 'VLANs', description: 'Manage virtual LANs' },
|
||||
{ to: '/settings/subnets', icon: Link, title: 'Subnets', description: 'Manage IP subnets and DHCP' },
|
||||
{ to: '/settings/network', icon: Globe, title: 'Network Infrastructure', description: 'Manage VLANs and IP subnets' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Displays & Kiosks',
|
||||
cards: [
|
||||
{ to: '/settings/dashboarddefaults', icon: MonitorSmartphone, title: 'Dashboard Defaults', description: 'Map kiosk IPs to a default location' },
|
||||
{ to: '/settings/notificationtypes', icon: Bell, title: 'Notification Types', description: 'Manage notification types, display styles, colors, and auto-expiry' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Integrations',
|
||||
cards: [
|
||||
{ to: '/settings/servicenow', icon: Link, title: 'ServiceNow', description: 'ServiceNow ticket links (incident, change) prefixes and global-search redirect' },
|
||||
{ to: '/settings/zabbix', icon: Droplets, title: 'Zabbix Supplies', description: 'Zabbix API for real-time printer toner and supply monitoring' },
|
||||
{ to: '/settings/dellwarranty', icon: ShieldCheck, title: 'Dell Warranty', description: 'Dell TechDirect warranty API lookup by service tag' },
|
||||
{ to: '/settings/integrations', icon: Link, title: 'Integrations', description: 'ServiceNow, Zabbix, and Dell Warranty API connections' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -105,24 +69,20 @@ export const settingsGroups = [
|
||||
title: 'Search & Identity',
|
||||
cards: [
|
||||
{ to: '/settings/globalsearch', icon: Search, title: 'Global Search', description: 'Choose which content types (domains) appear in global search results' },
|
||||
{ to: '/settings/assetidentifiers', icon: Fingerprint, title: 'Asset Identifiers', description: 'Enable optional asset identifiers per type: gauge lab, maintenance, FQDN hostname' },
|
||||
{ to: '/settings/printing', icon: Printer, title: 'Printing & Labels', description: 'Printed QR code and USB label targets, barcode style, and URL templates' },
|
||||
{ to: '/settings/printing', icon: Printer, title: 'Printing & Labels', description: 'Printed QR code label targets, barcode style, and URL templates' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
cards: [
|
||||
{ to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' },
|
||||
{ to: '/settings/shopdbbackups', icon: DatabaseBackup, title: 'Site Backups', description: 'Scheduled database and file backups with guided restore; keeps any day in the last 5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Access & Security',
|
||||
cards: [
|
||||
{ to: '/settings/authentication', icon: KeyRound, title: 'Authentication', description: 'SAML single sign-on (SSO) with your IdP, local login, and auto-create users' },
|
||||
{ to: '/settings/apitokens', icon: Key, title: 'API Tokens', description: 'Personal access tokens for scripts and integrations (e.g. long-running imports) that outlive login sessions' },
|
||||
{ to: '/settings/users', icon: Users, title: 'Users & Roles', description: 'Manage user accounts and permissions' },
|
||||
{ to: '/settings/audit', icon: History, title: 'Audit & Logging', description: 'Audit log retention period and history purge policy' },
|
||||
{ to: '/settings/auditlogs', icon: FileText, title: 'Audit Logs', description: 'View system activity and change history' },
|
||||
{ to: '/settings/security', icon: ShieldCheck, title: 'Access & Security', description: 'Authentication, API tokens, users, roles, and audit logs' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
286
frontend/src/views/settings/types/NotificationTypesTab.vue
Normal file
286
frontend/src/views/settings/types/NotificationTypesTab.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<div class="notification-types-tab">
|
||||
<div class="tab-header">
|
||||
<h3>Notification Types</h3>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" @click="openModal()">
|
||||
+ Add Notification Type
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Color</th>
|
||||
<th>Display Style</th>
|
||||
<th>Board Order</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.notificationtypeid">
|
||||
<td>
|
||||
<strong>{{ item.typename }}</strong>
|
||||
<div v-if="item.typedescription" class="hint">{{ item.typedescription }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="color-badge"
|
||||
:style="{ backgroundColor: item.typecolor }"
|
||||
>
|
||||
{{ item.typecolor }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.displaystyle || 'standard' }}</td>
|
||||
<td>{{ item.boardorder || 100 }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="item.isactive ? 'badge-success' : 'badge-secondary'">
|
||||
{{ item.isactive ? 'yes' : 'no' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(item)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteItem(item)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0">
|
||||
<td colspan="6" style="text-align: center; color: var(--text-light);">
|
||||
No notification types found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editing ? 'Edit' : 'Add' }} Notification Type</h3>
|
||||
<button class="modal-close" @click="closeModal">×</button>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Name *</label>
|
||||
<input
|
||||
v-model="form.typename"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
maxlength="50"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
v-model="form.typedescription"
|
||||
class="form-control"
|
||||
rows="2"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Color</label>
|
||||
<ColorSwatchPicker v-model="form.typecolor" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Display Style</label>
|
||||
<select v-model="form.displaystyle" class="form-control">
|
||||
<option value="standard">Standard rows</option>
|
||||
<option value="carousel">Carousel</option>
|
||||
<option value="grid">Grid</option>
|
||||
<option value="banner">Banner</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Board Order</label>
|
||||
<input
|
||||
v-model.number="form.boardorder"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="0"
|
||||
max="999"
|
||||
step="10"
|
||||
/>
|
||||
<small class="hint">Lower numbers appear first on the board</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Board Category (optional)</label>
|
||||
<input
|
||||
v-model="form.boardcategory"
|
||||
type="text"
|
||||
class="form-control"
|
||||
maxlength="50"
|
||||
/>
|
||||
<small class="hint">Group types under one category name</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="form.splitperemployee" />
|
||||
Split one card per employee
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="form.showemployeephoto" />
|
||||
Show employee photo
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="form.isactive" />
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '@/api'
|
||||
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({})
|
||||
|
||||
onMounted(loadData)
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get('/notifications/types')
|
||||
items.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading notification types:', err)
|
||||
toast.error(apiError(err, 'Failed to load notification types'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item
|
||||
? {
|
||||
typename: item.typename || '',
|
||||
typedescription: item.typedescription || '',
|
||||
typecolor: item.typecolor || '',
|
||||
displaystyle: item.displaystyle || 'standard',
|
||||
boardorder: item.boardorder || 100,
|
||||
boardcategory: item.boardcategory || '',
|
||||
splitperemployee: item.splitperemployee || false,
|
||||
showemployeephoto: item.showemployeephoto || false,
|
||||
isactive: item.isactive !== false
|
||||
}
|
||||
: {
|
||||
typename: '',
|
||||
typedescription: '',
|
||||
typecolor: '',
|
||||
displaystyle: 'standard',
|
||||
boardorder: 100,
|
||||
boardcategory: '',
|
||||
splitperemployee: false,
|
||||
showemployeephoto: false,
|
||||
isactive: true
|
||||
}
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editing.value = null
|
||||
}
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await api.put(`/notifications/types/${editing.value.notificationtypeid}`, form.value)
|
||||
} else {
|
||||
await api.post('/notifications/types', form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteItem(item) {
|
||||
if (!confirm(`Delete notification type "${item.typename}"?`)) return
|
||||
|
||||
try {
|
||||
await api.delete(`/notifications/types/${item.notificationtypeid}`)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-types-tab {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.tab-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tab-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.color-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85rem;
|
||||
color: white;
|
||||
text-shadow: 0 0 2px rgba(0,0,0,0.5);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user