The wizard configured five of the product's 129 settings. A site that finished
it still had no timezone, no authentication, no mail server, no backup
destination and no endpoint agent policy, and nothing told the operator so.
Six steps now: Administrator, Site, Authentication, Features, Mail and Backups,
Review. Everything else moves to a standing setup checklist at
/settings/setupchecklist, because a wizard that asked for all 129 settings would
be worse than one that asked for none.
Two new endpoints back it. /setup/checklist reports twelve areas as
ok/partial/todo/na with a link to the page that fixes each, and /setup/health
reports account counts plus whether a plugin toggle has reached every worker.
Both take care not to lie:
- Several settings are SEEDED non-empty (the shipped logo, a placeholder floor
plan, every identifier field), so "is it set" compares against the shipped
default rather than emptiness.
- plugin.endpoints.sharedkeyallowed DEFAULTS TO ALLOWED when its row is absent,
so an absent row is reported as the fleet-wide key being accepted. Reporting
it as refused told an admin the opposite of the truth about the credential
they are migrating away from.
- A plugin is held at "starting" until several DISTINCT workers report it live.
Counting replies instead let one recycled worker answer four times and declare
a change complete while other workers still served the old routes.
Recommended plugins are declared in each plugin's manifest rather than listed in
the wizard, so adding a plugin cannot leave a stale list in another file.
Also fixed: the logo preview was invisible, because the image borrowed a class
that hardcodes a white background while the shipped logo is white; and signing
in did not re-read setup state, so an admin already browsing was never steered
to the wizard and a hard refresh was the only cure.
(cherry picked from commit 32d3f4b29d)
1784 lines
53 KiB
JavaScript
1784 lines
53 KiB
JavaScript
import axios from 'axios'
|
|
import { withBase, stripBase } from './../utils/basePath'
|
|
|
|
// BASE_URL ends in '/', so this is '/api' at root or '/ops/api' under a subpath
|
|
// mount. Keeps the SPA, its API, and IIS all on the same mount path.
|
|
const api = axios.create({
|
|
baseURL: import.meta.env.BASE_URL + 'api',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
})
|
|
|
|
// Add auth token and normalize pagination params
|
|
// Kiosk device tokens. The kiosk routes are the only ones that write without a
|
|
// login, so each is additionally gated on a token an administrator provisioned
|
|
// into THAT screen's browser - see the kiosk views, which claim it from a
|
|
// ?token= URL once and strip it from the address bar. A token served inside the
|
|
// 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',
|
|
}
|
|
|
|
function kioskTokenFor(url) {
|
|
const path = url || ''
|
|
for (const [prefix, storageKey] of Object.entries(KIOSK_TOKEN_KEYS)) {
|
|
if (path.includes(prefix)) {
|
|
try { return localStorage.getItem(storageKey) || '' } catch { return '' }
|
|
}
|
|
}
|
|
return ''
|
|
}
|
|
|
|
// Claim a kiosk's device token, once, from the URL an admin used to set the
|
|
// screen up. Kept in THIS browser and stripped from the address bar straight
|
|
// away, so it does not sit there for a shoulder to read or travel in a bookmark
|
|
// or a screenshot.
|
|
export function claimKioskToken(storageKey) {
|
|
try {
|
|
const url = new URL(window.location.href)
|
|
const token = (url.searchParams.get('token') || '').trim()
|
|
if (!token) return
|
|
localStorage.setItem(storageKey, token)
|
|
url.searchParams.delete('token')
|
|
window.history.replaceState({}, '', url.pathname + url.search + url.hash)
|
|
} catch {
|
|
// A browser refusing localStorage cannot be provisioned. Nothing to
|
|
// recover; the routes 404 and the operator sees the standard error.
|
|
}
|
|
}
|
|
|
|
api.interceptors.request.use(config => {
|
|
const token = kioskTokenFor(config.url)
|
|
if (token) config.headers['X-Kiosk-Token'] = token
|
|
return config
|
|
})
|
|
|
|
api.interceptors.request.use(config => {
|
|
const token = localStorage.getItem('token')
|
|
if (token) {
|
|
config.headers.Authorization = `Bearer ${token}`
|
|
}
|
|
// `per_page` used to be sent alongside `perpage` for backend compatibility.
|
|
// get_pagination_params reads `perpage` and only falls back to `per_page`, and
|
|
// this client always sends `perpage`, so the duplicate never did anything -
|
|
// it just made every list URL carry a redundant parameter.
|
|
//
|
|
// Dropping it also has a one-off benefit worth recording. The endpoints list
|
|
// route used to redirect (308, PERMANENT and therefore cached by the browser
|
|
// against the exact URL including its query string). Clients that hit the old
|
|
// route hold that rewrite even after the server is fixed: the request never
|
|
// leaves the machine, so nothing deployed can clear it. Changing the query
|
|
// string changes the cache key, so those clients miss the poisoned entry and
|
|
// reach the corrected route.
|
|
return config
|
|
})
|
|
|
|
// Handle 401 errors (token expired) - only redirect if user was logged in
|
|
api.interceptors.response.use(
|
|
response => response,
|
|
error => {
|
|
if (error.response?.status === 401) {
|
|
const hadToken = localStorage.getItem('token')
|
|
localStorage.removeItem('token')
|
|
localStorage.removeItem('user')
|
|
// Only redirect if user was previously logged in (session expired).
|
|
// Preserve the destination so login returns the user to this page.
|
|
if (hadToken) {
|
|
const loginPath = withBase('/login')
|
|
// Router paths exclude the mount base; strip it or login's
|
|
// router.push double-prefixes under a subpath mount.
|
|
const here = stripBase(window.location.pathname) + window.location.search
|
|
const target = here && here !== '/login'
|
|
? loginPath + '?redirect=' + encodeURIComponent(here)
|
|
: loginPath
|
|
window.location.href = target
|
|
}
|
|
}
|
|
return Promise.reject(error)
|
|
}
|
|
)
|
|
|
|
export default api
|
|
|
|
// The backend clamps perpage to MAX_PAGE_SIZE (100) and says nothing about it,
|
|
// so asking for `perpage: 1000` silently returns the first 100 rows and drops
|
|
// the rest. A picker built that way looks complete and is not: with 126
|
|
// applications on a live site, the 26 sorting last were simply unselectable.
|
|
//
|
|
// Use this wherever a control needs the WHOLE list (dropdowns, pickers, label
|
|
// batches) rather than a page of it. Returns the full array directly, not an
|
|
// axios response. Anything that renders a paged table should keep calling
|
|
// list() with a real page number instead.
|
|
export async function fetchAllPages(path, params = {}) {
|
|
const first = await api.get(path, { params: { ...params, perpage: 100, page: 1 } })
|
|
let items = first.data.data || []
|
|
const totalpages = first.data.meta?.pagination?.totalpages || 1
|
|
if (totalpages > 1) {
|
|
const rest = await Promise.all(
|
|
Array.from({ length: totalpages - 1 }, (_, i) =>
|
|
api.get(path, { params: { ...params, perpage: 100, page: i + 2 } })
|
|
)
|
|
)
|
|
rest.forEach(response => { items = items.concat(response.data.data || []) })
|
|
}
|
|
return items
|
|
}
|
|
|
|
// Auth API
|
|
export const authApi = {
|
|
login(username, password) {
|
|
return api.post('/auth/login', { username, password })
|
|
},
|
|
logout() {
|
|
return api.post('/auth/logout')
|
|
},
|
|
me() {
|
|
return api.get('/auth/me')
|
|
},
|
|
refresh() {
|
|
const refreshToken = localStorage.getItem('refreshToken')
|
|
return api.post('/auth/refresh', {}, {
|
|
headers: { Authorization: `Bearer ${refreshToken}` }
|
|
})
|
|
},
|
|
changePassword(payload) {
|
|
return api.post('/auth/change-password', payload)
|
|
}
|
|
}
|
|
|
|
// Machines API (plugin)
|
|
export const machinesApi = {
|
|
list(params = {}) {
|
|
return api.get('/machines', { params })
|
|
},
|
|
// Every machine, 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('/machines', params)
|
|
},
|
|
get(id) {
|
|
return api.get(`/machines/${id}`)
|
|
},
|
|
getByAsset(assetId) {
|
|
return api.get(`/machines/by-asset/${assetId}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/machines', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/machines/${id}`, data)
|
|
},
|
|
delete(id) {
|
|
return api.delete(`/machines/${id}`)
|
|
},
|
|
dashboardSummary() {
|
|
return api.get('/machines/dashboard/summary')
|
|
},
|
|
// Machine types
|
|
types: {
|
|
list(params = {}) {
|
|
return api.get('/machines/types', { params })
|
|
},
|
|
get(id) {
|
|
return api.get(`/machines/types/${id}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/machines/types', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/machines/types/${id}`, data)
|
|
},
|
|
remove(id) {
|
|
return api.delete(`/machines/types/${id}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Endpoints API (plugin) - fleet telemetry read surface
|
|
export const endpointsApi = {
|
|
dashboardSummary() {
|
|
return api.get('/endpoints/dashboard/summary')
|
|
},
|
|
dashboardStability() {
|
|
return api.get('/endpoints/dashboard/stability')
|
|
},
|
|
list(params = {}) {
|
|
return api.get('/endpoints', { params })
|
|
},
|
|
get(assetId) {
|
|
return api.get(`/endpoints/${assetId}`)
|
|
},
|
|
// 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`)
|
|
},
|
|
// Monitor catalog: reusable process/service definitions. Shipped rows are
|
|
// product-owned and read-only; local rows are user-built on the Settings page.
|
|
monitors: {
|
|
list() {
|
|
return api.get('/endpoints/monitors')
|
|
},
|
|
create(data) {
|
|
return api.post('/endpoints/monitors', data)
|
|
},
|
|
update(monitorid, data) {
|
|
return api.put(`/endpoints/monitors/${monitorid}`, data)
|
|
},
|
|
remove(monitorid) {
|
|
return api.delete(`/endpoints/monitors/${monitorid}`)
|
|
},
|
|
resolve(pctype) {
|
|
return api.get('/endpoints/monitors/resolve', { params: { pctype } })
|
|
}
|
|
}
|
|
}
|
|
|
|
// Computers API (plugin)
|
|
export const computersApi = {
|
|
list(params = {}) {
|
|
return api.get('/computers', { params })
|
|
},
|
|
// Every PC, 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('/computers', params)
|
|
},
|
|
displayKiosks() {
|
|
return api.get('/computers/display-kiosks')
|
|
},
|
|
get(id) {
|
|
return api.get(`/computers/${id}`)
|
|
},
|
|
getByAsset(assetId) {
|
|
return api.get(`/computers/by-asset/${assetId}`)
|
|
},
|
|
getByHostname(hostname) {
|
|
return api.get(`/computers/by-hostname/${hostname}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/computers', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/computers/${id}`, data)
|
|
},
|
|
delete(id) {
|
|
return api.delete(`/computers/${id}`)
|
|
},
|
|
dashboardSummary() {
|
|
return api.get('/computers/dashboard/summary')
|
|
},
|
|
// Computer types
|
|
types: {
|
|
list(params = {}) {
|
|
return api.get('/computers/types', { params })
|
|
},
|
|
// Every type, for a dropdown. perpage is capped at 100 server-side, so
|
|
// asking for 200 returns 100 and silently truncates the list.
|
|
listAll(params = {}) {
|
|
return fetchAllPages('/computers/types', params)
|
|
},
|
|
get(id) {
|
|
return api.get(`/computers/types/${id}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/computers/types', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/computers/types/${id}`, data)
|
|
},
|
|
remove(id) {
|
|
return api.delete(`/computers/types/${id}`)
|
|
}
|
|
},
|
|
// Remote-access protocol catalog
|
|
protocols: {
|
|
list(params = {}) {
|
|
return api.get('/computers/protocols', { params })
|
|
},
|
|
create(data) {
|
|
return api.post('/computers/protocols', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/computers/protocols/${id}`, data)
|
|
},
|
|
remove(id) {
|
|
return api.delete(`/computers/protocols/${id}`)
|
|
}
|
|
},
|
|
// Configuration/Business policies (RMM-style). Gated server-side by the
|
|
// computers_configpolicies_enabled setting; routes return 404 while off.
|
|
policies: {
|
|
actions() {
|
|
return api.get('/computers/policies/actions')
|
|
},
|
|
effective(assetid) {
|
|
return api.get(`/computers/policies/effective/${assetid}`)
|
|
},
|
|
configuration: {
|
|
list() {
|
|
return api.get('/computers/policies/configuration')
|
|
},
|
|
get(id) {
|
|
return api.get(`/computers/policies/configuration/${id}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/computers/policies/configuration', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/computers/policies/configuration/${id}`, data)
|
|
},
|
|
remove(id) {
|
|
return api.delete(`/computers/policies/configuration/${id}`)
|
|
}
|
|
},
|
|
business: {
|
|
list() {
|
|
return api.get('/computers/policies/business')
|
|
},
|
|
get(id) {
|
|
return api.get(`/computers/policies/business/${id}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/computers/policies/business', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/computers/policies/business/${id}`, data)
|
|
},
|
|
remove(id) {
|
|
return api.delete(`/computers/policies/business/${id}`)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Relationship Types API
|
|
export const relationshipTypesApi = {
|
|
list() {
|
|
return api.get('/assets/relationshiptypes')
|
|
},
|
|
create(data) {
|
|
return api.post('/assets/relationshiptypes', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/assets/relationshiptypes/${id}`, data)
|
|
},
|
|
remove(id) {
|
|
return api.delete(`/assets/relationshiptypes/${id}`)
|
|
}
|
|
}
|
|
|
|
// Model Types API (the vendor models catalog: modeltypeid)
|
|
export const modeltypesApi = {
|
|
list(params = {}) {
|
|
return api.get('/modeltypes', { params })
|
|
},
|
|
create(data) {
|
|
return api.post('/modeltypes', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/modeltypes/${id}`, data)
|
|
},
|
|
delete(id) {
|
|
return api.delete(`/modeltypes/${id}`)
|
|
}
|
|
}
|
|
|
|
// Vendors API
|
|
export const vendorsApi = {
|
|
list(params = {}) {
|
|
return api.get('/vendors', { params })
|
|
},
|
|
// Every vendor, paged. perpage is capped at 100 server-side, so a picker
|
|
// asking for 500 silently showed the first 100 and dropped the rest - with a
|
|
// 200, so nothing looked wrong.
|
|
listAll(params = {}) {
|
|
return fetchAllPages('/vendors', params)
|
|
},
|
|
get(id) {
|
|
return api.get(`/vendors/${id}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/vendors', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/vendors/${id}`, data)
|
|
},
|
|
delete(id) {
|
|
return api.delete(`/vendors/${id}`)
|
|
}
|
|
}
|
|
|
|
// Locations API
|
|
export const locationsApi = {
|
|
list(params = {}) {
|
|
return api.get('/locations', { params })
|
|
},
|
|
// Every location, paged past the backend's 100-row cap. Location selector
|
|
// 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('/locations', params)
|
|
},
|
|
get(id) {
|
|
return api.get(`/locations/${id}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/locations', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/locations/${id}`, data)
|
|
},
|
|
delete(id) {
|
|
return api.delete(`/locations/${id}`)
|
|
},
|
|
types: {
|
|
list(params = {}) {
|
|
return api.get('/locations/types', { params })
|
|
},
|
|
create(data) {
|
|
return api.post('/locations/types', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/locations/types/${id}`, data)
|
|
},
|
|
remove(id) {
|
|
return api.delete(`/locations/types/${id}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Printers API
|
|
export const printersApi = {
|
|
list(params = {}) {
|
|
return api.get('/printers', { params })
|
|
},
|
|
|
|
// Printer assignment for one asset - a MACHINE normally, since the assignment
|
|
// belongs to the bay and reaches whichever PC controls it. Reads and writes
|
|
// that asset's OWN rows: the resolved view (own, else inherited) is
|
|
// /printers/for-host, which the client uses, not the editor.
|
|
assignment: {
|
|
get(assetid) {
|
|
return api.get(`/printers/assignments/for-asset/${assetid}`)
|
|
},
|
|
// The WHOLE set in one call. A row-at-a-time edit is a non-atomic
|
|
// reconcile: an HTTP failure part way leaves an asset half-assigned, with
|
|
// nothing recording what was meant.
|
|
set(assetid, printerassetids, defaultprinterassetid) {
|
|
return api.put(`/printers/assignments/for-asset/${assetid}`, {
|
|
printerassetids,
|
|
defaultprinterassetid: defaultprinterassetid ?? null
|
|
})
|
|
}
|
|
},
|
|
// Every printer, 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('/printers', params)
|
|
},
|
|
get(id) {
|
|
return api.get(`/printers/${id}`)
|
|
},
|
|
// create/update write asset core + printer extension in one call (the
|
|
// printers plugin owns both). Use these instead of the legacy machinesApi.
|
|
create(data) {
|
|
return api.post('/printers', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/printers/${id}`, data)
|
|
},
|
|
// Soft delete: the server deactivates the asset, so the printer leaves every
|
|
// list while its history and relationships stay intact.
|
|
remove(id) {
|
|
return api.delete(`/printers/${id}`)
|
|
},
|
|
updateExtension(id, data) {
|
|
return api.put(`/printers/${id}/printerdata`, data)
|
|
},
|
|
// printer sub-types (Laser, Inkjet, Label, Card, Wide Format, ...)
|
|
types: {
|
|
list(params = {}) {
|
|
return api.get('/printers/types', { params })
|
|
},
|
|
create(data) {
|
|
return api.post('/printers/types', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/printers/types/${id}`, data)
|
|
},
|
|
remove(id) {
|
|
return api.delete(`/printers/types/${id}`)
|
|
}
|
|
},
|
|
updateCommunication(id, data) {
|
|
return api.put(`/printers/${id}/communication`, data)
|
|
},
|
|
getSupplies(id) {
|
|
return api.get(`/printers/${id}/supplies`)
|
|
},
|
|
getDrivers(id) {
|
|
return api.get(`/printers/${id}/drivers`)
|
|
},
|
|
supplyForecast(days = 90) {
|
|
return api.get('/printers/supplies/forecast', { params: { days } })
|
|
},
|
|
lowSupplies() {
|
|
return api.get('/printers/lowsupplies')
|
|
},
|
|
refreshSupplies() {
|
|
return api.post('/printers/supplies/refresh')
|
|
},
|
|
lookup({ ip, fqdn } = {}) {
|
|
return api.get('/printers/lookup', { params: { ip, fqdn } })
|
|
},
|
|
dashboardSummary() {
|
|
return api.get('/printers/dashboard/summary')
|
|
},
|
|
// Flat network-printer list (with mapx/mapy) for the installer map.
|
|
installList() {
|
|
return api.get('/printers/install-list')
|
|
},
|
|
drivers: {
|
|
list(params = {}) {
|
|
return api.get('/printers/drivers', { params })
|
|
},
|
|
create(data) {
|
|
return api.post('/printers/drivers', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/printers/drivers/${id}`, data)
|
|
},
|
|
delete(id) {
|
|
return api.delete(`/printers/drivers/${id}`)
|
|
}
|
|
},
|
|
supplyTypes: {
|
|
list() {
|
|
return api.get('/printers/supplytypes')
|
|
},
|
|
create(data) {
|
|
return api.post('/printers/supplytypes', data)
|
|
}
|
|
},
|
|
// model -> toner/drum/waste part-number management
|
|
modelSupplies: {
|
|
meta() {
|
|
return api.get('/printers/supplies/meta')
|
|
},
|
|
listModels(params = {}) {
|
|
return api.get('/printers/models', { params })
|
|
},
|
|
list(modelnumberid) {
|
|
return api.get(`/printers/models/${modelnumberid}/supplies`)
|
|
},
|
|
create(modelnumberid, data) {
|
|
return api.post(`/printers/models/${modelnumberid}/supplies`, data)
|
|
},
|
|
update(modelsupplyid, data) {
|
|
return api.put(`/printers/supplies/${modelsupplyid}`, data)
|
|
},
|
|
delete(modelsupplyid) {
|
|
return api.delete(`/printers/supplies/${modelsupplyid}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Dashboard API
|
|
export const dashboardApi = {
|
|
summary() {
|
|
return api.get('/dashboard/summary')
|
|
},
|
|
navigation() {
|
|
return api.get('/dashboard/navigation')
|
|
},
|
|
capabilities() {
|
|
return api.get('/dashboard/capabilities')
|
|
}
|
|
}
|
|
|
|
// Models API
|
|
export const modelsApi = {
|
|
list(params = {}) {
|
|
return api.get('/models', { params })
|
|
},
|
|
// Backend caps perpage at 100, so page through every model. Returns the
|
|
// full array directly (not an axios response). Use in forms whose model
|
|
// dropdown must include the editing record's model regardless of page.
|
|
listAll(params = {}) {
|
|
return fetchAllPages('/models', params)
|
|
},
|
|
get(id) {
|
|
return api.get(`/models/${id}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/models', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/models/${id}`, data)
|
|
},
|
|
delete(id) {
|
|
return api.delete(`/models/${id}`)
|
|
},
|
|
uploadImage(id, file) {
|
|
// multipart photo upload; backend sets imageurl to the served URL
|
|
const form = new FormData()
|
|
form.append('file', file)
|
|
return api.post(`/models/${id}/image`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
|
},
|
|
removeImage(id) {
|
|
return api.delete(`/models/${id}/image`)
|
|
}
|
|
}
|
|
|
|
// Operating Systems API
|
|
export const operatingsystemsApi = {
|
|
list(params = {}) {
|
|
return api.get('/operatingsystems', { params })
|
|
},
|
|
get(id) {
|
|
return api.get(`/operatingsystems/${id}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/operatingsystems', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/operatingsystems/${id}`, data)
|
|
},
|
|
delete(id) {
|
|
return api.delete(`/operatingsystems/${id}`)
|
|
}
|
|
}
|
|
|
|
// Business Units API
|
|
export const businessunitsApi = {
|
|
list(params = {}) {
|
|
return api.get('/businessunits', { params })
|
|
},
|
|
// See vendorsApi.listAll: the cap is 100 whatever the caller asks for.
|
|
listAll(params = {}) {
|
|
return fetchAllPages('/businessunits', params)
|
|
},
|
|
get(id) {
|
|
return api.get(`/businessunits/${id}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/businessunits', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/businessunits/${id}`, data)
|
|
},
|
|
delete(id) {
|
|
return api.delete(`/businessunits/${id}`)
|
|
}
|
|
}
|
|
|
|
// Applications API
|
|
export const applicationsApi = {
|
|
list(params = {}) {
|
|
return api.get('/applications', { params })
|
|
},
|
|
// Every application, paged past the backend's 100-row cap. The catalogue is
|
|
// already over 100 entries on a live site, so any picker offering "all
|
|
// applications" must use this and not list({ perpage: <big number> }).
|
|
listAll(params = {}) {
|
|
return fetchAllPages('/applications', params)
|
|
},
|
|
get(id) {
|
|
return api.get(`/applications/${id}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/applications', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/applications/${id}`, data)
|
|
},
|
|
// 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) {
|
|
const form = new FormData()
|
|
form.append('file', file)
|
|
return api.post(`/applications/${id}/image`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
|
},
|
|
removeImage(id) {
|
|
return api.delete(`/applications/${id}/image`)
|
|
},
|
|
// installer upload; backend sets installpath to the download URL.
|
|
// onProgress gets 0-100 - an installer is big enough that a silent wait
|
|
// reads as a hang.
|
|
uploadPackage(id, file, onProgress) {
|
|
const form = new FormData()
|
|
form.append('file', file)
|
|
return api.post(`/applications/${id}/package`, form, {
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
onUploadProgress: event => {
|
|
if (onProgress && event.total) {
|
|
onProgress(Math.round((event.loaded * 100) / event.total))
|
|
}
|
|
}
|
|
})
|
|
},
|
|
removePackage(id) {
|
|
return api.delete(`/applications/${id}/package`)
|
|
},
|
|
// Versions
|
|
getVersions(appId) {
|
|
return api.get(`/applications/${appId}/versions`)
|
|
},
|
|
createVersion(appId, data) {
|
|
return api.post(`/applications/${appId}/versions`, data)
|
|
},
|
|
// Get PCs that have this app installed
|
|
getInstalledOn(appId) {
|
|
return api.get(`/applications/${appId}/installed`)
|
|
},
|
|
// Machine applications (installed apps)
|
|
getMachineApps(machineId) {
|
|
return api.get(`/applications/machines/${machineId}`)
|
|
},
|
|
installApp(machineId, data) {
|
|
return api.post(`/applications/machines/${machineId}`, data)
|
|
},
|
|
uninstallApp(machineId, appId) {
|
|
return api.delete(`/applications/machines/${machineId}/${appId}`)
|
|
},
|
|
updateInstalledApp(machineId, appId, data) {
|
|
return api.put(`/applications/machines/${machineId}/${appId}`, data)
|
|
}
|
|
}
|
|
|
|
// ShopDB-hosted script library. Backs run_script delivery: the agent fetches a
|
|
// 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 })
|
|
},
|
|
listAll(params = {}) {
|
|
return fetchAllPages('/scripts', params)
|
|
},
|
|
get(id) {
|
|
return api.get(`/scripts/${id}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/scripts', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/scripts/${id}`, data)
|
|
},
|
|
delete(id) {
|
|
return api.delete(`/scripts/${id}`)
|
|
},
|
|
// script-body upload; backend sets filename/sha256 to the stored payload.
|
|
uploadBody(id, file, onProgress) {
|
|
const form = new FormData()
|
|
form.append('file', file)
|
|
return api.post(`/scripts/${id}/body`, form, {
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
onUploadProgress: event => {
|
|
if (onProgress && event.total) {
|
|
onProgress(Math.round((event.loaded * 100) / event.total))
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Support Teams API (teams + nested contacts)
|
|
export const supportteamsApi = {
|
|
list(params = {}) {
|
|
return api.get('/supportteams', { params })
|
|
},
|
|
get(id) {
|
|
return api.get(`/supportteams/${id}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/supportteams', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/supportteams/${id}`, data)
|
|
},
|
|
remove(id) {
|
|
return api.delete(`/supportteams/${id}`)
|
|
},
|
|
contacts: {
|
|
add(teamId, data) {
|
|
return api.post(`/supportteams/${teamId}/contacts`, data)
|
|
},
|
|
update(teamId, contactId, data) {
|
|
return api.put(`/supportteams/${teamId}/contacts/${contactId}`, data)
|
|
},
|
|
remove(teamId, contactId) {
|
|
return api.delete(`/supportteams/${teamId}/contacts/${contactId}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Search API
|
|
export const searchApi = {
|
|
search(query, params = {}) {
|
|
return api.get('/search', { params: { q: query, ...params } })
|
|
}
|
|
}
|
|
|
|
// Knowledge Base API
|
|
export const knowledgebaseApi = {
|
|
list(params = {}) {
|
|
return api.get('/knowledgebase', { params })
|
|
},
|
|
get(id) {
|
|
return api.get(`/knowledgebase/${id}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/knowledgebase', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/knowledgebase/${id}`, data)
|
|
},
|
|
delete(id) {
|
|
return api.delete(`/knowledgebase/${id}`)
|
|
},
|
|
trackClick(id) {
|
|
return api.post(`/knowledgebase/${id}/click`)
|
|
},
|
|
getStats() {
|
|
return api.get('/knowledgebase/stats')
|
|
}
|
|
}
|
|
|
|
// Assets API (unified)
|
|
export const assetsApi = {
|
|
list(params = {}) {
|
|
return api.get('/assets', { params })
|
|
},
|
|
get(id) {
|
|
return api.get(`/assets/${id}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/assets', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/assets/${id}`, data)
|
|
},
|
|
delete(id) {
|
|
return api.delete(`/assets/${id}`)
|
|
},
|
|
getMap(params = {}) {
|
|
return api.get('/assets/map', { params })
|
|
},
|
|
// Relationships
|
|
getRelationships(id) {
|
|
return api.get(`/assets/${id}/relationships`)
|
|
},
|
|
createRelationship(data) {
|
|
return api.post('/assets/relationships', data)
|
|
},
|
|
deleteRelationship(relationshipId) {
|
|
return api.delete(`/assets/relationships/${relationshipId}`)
|
|
},
|
|
// Search assets (for relationship picker)
|
|
search(query, params = {}) {
|
|
return api.get('/assets', { params: { search: query, ...params } })
|
|
},
|
|
// Lookup asset by asset/machine number
|
|
lookup(assetnumber) {
|
|
return api.get(`/assets/lookup/${encodeURIComponent(assetnumber)}`)
|
|
},
|
|
types: {
|
|
list() {
|
|
return api.get('/assets/types')
|
|
},
|
|
get(id) {
|
|
return api.get(`/assets/types/${id}`)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/assets/types/${id}`, data)
|
|
}
|
|
},
|
|
statuses: {
|
|
list(params = {}) {
|
|
return api.get('/assets/statuses', { params })
|
|
},
|
|
create(data) {
|
|
return api.post('/assets/statuses', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/assets/statuses/${id}`, data)
|
|
},
|
|
delete(id) {
|
|
return api.delete(`/assets/statuses/${id}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Notifications API
|
|
export const notificationsApi = {
|
|
list(params = {}) {
|
|
return api.get('/notifications', { params })
|
|
},
|
|
get(id) {
|
|
return api.get(`/notifications/${id}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/notifications', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/notifications/${id}`, data)
|
|
},
|
|
delete(id) {
|
|
return api.delete(`/notifications/${id}`)
|
|
},
|
|
getActive() {
|
|
return api.get('/notifications/active')
|
|
},
|
|
getCalendar(params = {}) {
|
|
return api.get('/notifications/calendar', { params })
|
|
},
|
|
dashboardSummary() {
|
|
return api.get('/notifications/dashboard/summary')
|
|
},
|
|
getShopfloor(params = {}) {
|
|
return api.get('/notifications/shopfloor', { params })
|
|
},
|
|
getEmployeeRecognitions(sso) {
|
|
return api.get(`/notifications/employee/${sso}`)
|
|
},
|
|
types: {
|
|
list() {
|
|
return api.get('/notifications/types')
|
|
},
|
|
get(id) {
|
|
return api.get(`/notifications/types/${id}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/notifications/types', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/notifications/types/${id}`, data)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 = {
|
|
list() {
|
|
return api.get('/reports')
|
|
},
|
|
machinesByType(params = {}) {
|
|
return api.get('/reports/machines-by-type', { params })
|
|
},
|
|
assetsByStatus(params = {}) {
|
|
return api.get('/reports/assets-by-status', { params })
|
|
},
|
|
kbPopularity(params = {}) {
|
|
return api.get('/reports/kb-popularity', { params })
|
|
},
|
|
softwareCompliance(params = {}) {
|
|
return api.get('/reports/software-compliance', { params })
|
|
},
|
|
assetInventory(params = {}) {
|
|
return api.get('/reports/asset-inventory', { params })
|
|
},
|
|
pcRelationships(params = {}) {
|
|
return api.get('/reports/pc-relationships', { params })
|
|
},
|
|
// On-demand report delivery: email the given rows as an HTML table.
|
|
// Recipients default to the site Alert Recipients when `to` is omitted.
|
|
email(payload) {
|
|
return api.post('/reports/email', payload)
|
|
}
|
|
}
|
|
|
|
// Employees API (wjf_employees database)
|
|
export const employeesApi = {
|
|
search(query, limit = 10) {
|
|
return api.get('/employees/search', { params: { q: query, limit } })
|
|
},
|
|
lookup(sso) {
|
|
return api.get(`/employees/lookup/${sso}`)
|
|
},
|
|
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`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Alias for different casing
|
|
export const businessUnitsApi = businessunitsApi
|
|
|
|
// Dashboard defaults: visitor-IP -> business-unit mapping for kiosks
|
|
export const dashboardDefaultsApi = {
|
|
list() {
|
|
return api.get('/dashboarddefaults')
|
|
},
|
|
create(data) {
|
|
return api.post('/dashboarddefaults', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/dashboarddefaults/${id}`, data)
|
|
},
|
|
delete(id) {
|
|
return api.delete(`/dashboarddefaults/${id}`)
|
|
},
|
|
// Resolve the calling display's business unit by its IP
|
|
visitorLocation() {
|
|
return api.get('/dashboarddefaults/visitor-location')
|
|
}
|
|
}
|
|
|
|
// System Settings API
|
|
export const pluginsApi = {
|
|
list() {
|
|
return api.get('/plugins')
|
|
},
|
|
// Flat array of enabled plugin names. jwt-optional, safe for kiosk routes.
|
|
enabled() {
|
|
return api.get('/plugins/enabled')
|
|
},
|
|
setEnabled(name, enabled) {
|
|
return api.put(`/plugins/${name}`, { enabled })
|
|
}
|
|
}
|
|
|
|
export const setupApi = {
|
|
needsAdmin() {
|
|
return api.get('/setup/needs-admin')
|
|
},
|
|
createAdmin(data) {
|
|
return api.post('/setup/create-admin', data)
|
|
},
|
|
seedReference() {
|
|
return api.post('/setup/seed-reference')
|
|
},
|
|
seedStarter() {
|
|
return api.post('/setup/seed-starter')
|
|
},
|
|
// What is configured and what is not, with a route to each fix. Backs the
|
|
// standing Setup Checklist page as well as the wizard's review step.
|
|
checklist() {
|
|
return api.get('/setup/checklist')
|
|
},
|
|
// Account counts plus whether plugins.json and the running process disagree,
|
|
// which is the only way to tell that a plugin toggle needs a restart.
|
|
health() {
|
|
return api.get('/setup/health')
|
|
},
|
|
complete() {
|
|
return api.post('/setup/complete')
|
|
}
|
|
}
|
|
|
|
// Buildings and the levels within them (ADR-017). Reads are public - the
|
|
// printer installer map draws a blueprint before anyone logs in.
|
|
export const mapLevelsApi = {
|
|
list() {
|
|
return api.get('/maplevels')
|
|
},
|
|
get(levelid) {
|
|
return api.get(`/maplevels/${levelid}`)
|
|
},
|
|
createBuilding(payload) {
|
|
return api.post('/maplevels/buildings', payload)
|
|
},
|
|
updateBuilding(buildingid, payload) {
|
|
return api.patch(`/maplevels/buildings/${buildingid}`, payload)
|
|
},
|
|
create(payload) {
|
|
return api.post('/maplevels', payload)
|
|
},
|
|
update(levelid, payload) {
|
|
return api.patch(`/maplevels/${levelid}`, payload)
|
|
},
|
|
remove(levelid) {
|
|
return api.delete(`/maplevels/${levelid}`)
|
|
},
|
|
// Returns the image's real pixel size alongside the stored dimensions. On an
|
|
// empty level the server adopts them; on a populated one it refuses and says
|
|
// so, because changing the coordinate space moves every marker on it.
|
|
uploadBlueprint(levelid, theme, file) {
|
|
const form = new FormData()
|
|
form.append('file', file)
|
|
form.append('theme', theme)
|
|
return api.post(`/maplevels/${levelid}/blueprint`, form, {
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
})
|
|
},
|
|
}
|
|
|
|
// Bulk marker positions: the landmark transform, bulk placement, review state
|
|
// and undo. Every write snapshots first.
|
|
export const mapPositionsApi = {
|
|
setPositions(positions, verified = true) {
|
|
return api.post('/mappositions/positions', { positions, verified })
|
|
},
|
|
transform(payload) {
|
|
return api.post('/mappositions/transform', payload)
|
|
},
|
|
verify(assetids, unverify = false) {
|
|
return api.post('/mappositions/verify', { assetids, unverify })
|
|
},
|
|
snapshots() {
|
|
return api.get('/mappositions/snapshots')
|
|
},
|
|
restore(snapshotid) {
|
|
return api.post(`/mappositions/snapshots/${snapshotid}/restore`)
|
|
},
|
|
}
|
|
|
|
export const settingsApi = {
|
|
list(params = {}) {
|
|
return api.get('/settings', { params })
|
|
},
|
|
get(key) {
|
|
return api.get(`/settings/${key}`)
|
|
},
|
|
update(key, value) {
|
|
return api.put(`/settings/${key}`, { value })
|
|
},
|
|
testEmail(to) {
|
|
return api.post('/settings/test-email', { to })
|
|
},
|
|
create(data) {
|
|
return api.post('/settings', data)
|
|
},
|
|
uploadMapBlueprint(theme, file) {
|
|
const form = new FormData()
|
|
form.append('theme', theme)
|
|
form.append('file', file)
|
|
return api.post('/settings/map-blueprint', form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
|
},
|
|
uploadBrandingLogo(kind, file) {
|
|
// kind is one of site/qr/badge/favicon; backend writes the matching setting
|
|
const form = new FormData()
|
|
form.append('kind', kind)
|
|
form.append('file', file)
|
|
return api.post('/settings/branding-logo', form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
|
},
|
|
downloadCollectorScript() {
|
|
// Collect-ShopDB.ps1 with this site's CSV path baked into $NetworkStoreRoot
|
|
return api.get('/settings/asset-import/script', { responseType: 'blob' })
|
|
},
|
|
listImportTemplates() {
|
|
// Importable CSV tables in the order they must be uploaded
|
|
return api.get('/settings/asset-import/templates')
|
|
},
|
|
downloadImportTemplate(tablename) {
|
|
// Generated CSV template (header + example row) for one table
|
|
return api.get(`/settings/asset-import/templates/${encodeURIComponent(tablename)}`, { responseType: 'blob' })
|
|
},
|
|
importAssetCsvs(files, commit = false, options = {}) {
|
|
// Multipart upload of one or more CSVs. commit=false is a dry-run preview
|
|
// that writes nothing; commit=true applies the whole set in one transaction.
|
|
// options.source picks a foreign adapter (azure, thor); default native means
|
|
// the files are already canonical per-table CSVs. options.computertype is
|
|
// stamped onto every imported computer when a source assigns a PC type.
|
|
const form = new FormData()
|
|
for (const file of files) {
|
|
form.append('files', file)
|
|
}
|
|
form.append('commit', commit ? 'true' : 'false')
|
|
if (options.source) {
|
|
form.append('source', options.source)
|
|
}
|
|
if (options.computertype) {
|
|
form.append('computertype', options.computertype)
|
|
}
|
|
if (options.category) {
|
|
form.append('category', options.category)
|
|
}
|
|
return api.post('/settings/asset-import/import', form, {
|
|
headers: { 'Content-Type': 'multipart/form-data' }
|
|
})
|
|
},
|
|
wincPreview(file) {
|
|
// Read-only WINC (Maximo-style) machine-adoption analysis. Uploads one CSV
|
|
// and returns the approval packet (counts, needs-create business units,
|
|
// unresolved values); writes nothing.
|
|
const form = new FormData()
|
|
form.append('files', file)
|
|
return api.post('/settings/asset-import/winc/preview', form, {
|
|
headers: { 'Content-Type': 'multipart/form-data' }
|
|
})
|
|
},
|
|
wincCommit(file, acknowledge = [], planhash = '') {
|
|
// Apply a WINC plan reversibly. acknowledge is the list of needs-create
|
|
// group names (e.g. ['businessunit']) the admin confirmed. planhash is the
|
|
// one the PREVIEW returned - the server rejects a commit without it,
|
|
// because otherwise "approved" means only that these group names were
|
|
// ticked, and the plan applied could be a different one.
|
|
const form = new FormData()
|
|
form.append('files', file)
|
|
if (acknowledge.length) {
|
|
form.append('acknowledge', acknowledge.join(','))
|
|
}
|
|
if (planhash) {
|
|
form.append('planhash', planhash)
|
|
}
|
|
return api.post('/settings/asset-import/winc/commit', form, {
|
|
headers: { 'Content-Type': 'multipart/form-data' }
|
|
})
|
|
},
|
|
wincBatches() {
|
|
// Applied/undone WINC import batches, newest first, for the undo list.
|
|
return api.get('/settings/asset-import/winc/batches')
|
|
},
|
|
wincUndo(batchid) {
|
|
// Reverse an applied WINC import batch by id.
|
|
return api.post('/settings/asset-import/winc/undo', { batchid })
|
|
}
|
|
}
|
|
|
|
// Slide manager (lobby display + shopfloor screensaver)
|
|
export const slidesApi = {
|
|
list(surface) {
|
|
return api.get(`/slides/${surface}`)
|
|
},
|
|
upload(surface, formData) {
|
|
return api.post(`/slides/${surface}/upload`, formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' }
|
|
})
|
|
},
|
|
reorder(surface, order) {
|
|
return api.post(`/slides/${surface}/order`, { order })
|
|
},
|
|
remove(surface, files) {
|
|
return api.post(`/slides/${surface}/delete`, { files })
|
|
},
|
|
// Per-slide hold time. The route has always existed; nothing called it, so
|
|
// the documented per-slide seconds could only be set with curl.
|
|
update(surface, slideid, data) {
|
|
return api.patch(`/slides/${surface}/${slideid}`, data)
|
|
}
|
|
}
|
|
|
|
// Audit Logs API
|
|
export const auditLogsApi = {
|
|
list(params = {}) {
|
|
return api.get('/auditlogs', { params })
|
|
},
|
|
getEntityHistory(entitytype, entityid) {
|
|
return api.get(`/auditlogs/entity/${entitytype}/${entityid}`)
|
|
},
|
|
getStats() {
|
|
return api.get('/auditlogs/stats')
|
|
}
|
|
}
|
|
|
|
// Users API
|
|
export const usersApi = {
|
|
list() {
|
|
return api.get('/users')
|
|
},
|
|
get(id) {
|
|
return api.get(`/users/${id}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/users', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/users/${id}`, data)
|
|
},
|
|
delete(id) {
|
|
return api.delete(`/users/${id}`)
|
|
},
|
|
// Permissions
|
|
permissions: {
|
|
list() {
|
|
return api.get('/users/permissions')
|
|
}
|
|
},
|
|
// Roles
|
|
roles: {
|
|
list() {
|
|
return api.get('/users/roles')
|
|
},
|
|
create(data) {
|
|
return api.post('/users/roles', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/users/roles/${id}`, data)
|
|
},
|
|
delete(id) {
|
|
return api.delete(`/users/roles/${id}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Personal API tokens: authenticate scripts/integrations as a user without
|
|
// the hourly-expiring login JWT. The secret is returned ONCE, on create.
|
|
export const apitokensApi = {
|
|
// all=true (admin) lists everyone's tokens; otherwise just the caller's.
|
|
list(params = {}) {
|
|
return api.get('/apitokens', { params })
|
|
},
|
|
create(data) {
|
|
return api.post('/apitokens', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/apitokens/${id}`, data)
|
|
},
|
|
remove(id) {
|
|
return api.delete(`/apitokens/${id}`)
|
|
}
|
|
}
|
|
|
|
// Network API (devices, subnets, and VLANs)
|
|
export const networkApi = {
|
|
// Network devices
|
|
list(params = {}) {
|
|
return api.get('/network', { params })
|
|
},
|
|
// Every network 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('/network', params)
|
|
},
|
|
get(id) {
|
|
return api.get(`/network/${id}`)
|
|
},
|
|
getByAsset(assetId) {
|
|
return api.get(`/network/by-asset/${assetId}`)
|
|
},
|
|
getByHostname(hostname) {
|
|
return api.get(`/network/by-hostname/${hostname}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/network', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/network/${id}`, data)
|
|
},
|
|
delete(id) {
|
|
return api.delete(`/network/${id}`)
|
|
},
|
|
dashboardSummary() {
|
|
return api.get('/network/dashboard/summary')
|
|
},
|
|
// Network device types
|
|
types: {
|
|
list(params = {}) {
|
|
return api.get('/network/types', { params })
|
|
},
|
|
get(id) {
|
|
return api.get(`/network/types/${id}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/network/types', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/network/types/${id}`, data)
|
|
},
|
|
remove(id) {
|
|
return api.delete(`/network/types/${id}`)
|
|
}
|
|
},
|
|
// VLANs
|
|
vlans: {
|
|
list(params = {}) {
|
|
return api.get('/network/vlans', { params })
|
|
},
|
|
get(id) {
|
|
return api.get(`/network/vlans/${id}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/network/vlans', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/network/vlans/${id}`, data)
|
|
},
|
|
delete(id) {
|
|
return api.delete(`/network/vlans/${id}`)
|
|
}
|
|
},
|
|
// Subnets
|
|
subnets: {
|
|
list(params = {}) {
|
|
return api.get('/network/subnets', { params })
|
|
},
|
|
get(id) {
|
|
return api.get(`/network/subnets/${id}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/network/subnets', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/network/subnets/${id}`, data)
|
|
},
|
|
delete(id) {
|
|
return api.delete(`/network/subnets/${id}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Custom fields: site-defined attributes per asset type.
|
|
export const customFieldsApi = {
|
|
// Definitions
|
|
list(params = {}) {
|
|
return api.get('/customfields', { params })
|
|
},
|
|
create(data) {
|
|
return api.post('/customfields', data)
|
|
},
|
|
update(fieldid, data) {
|
|
return api.put(`/customfields/${fieldid}`, data)
|
|
},
|
|
remove(fieldid) {
|
|
return api.delete(`/customfields/${fieldid}`)
|
|
},
|
|
// Per-asset values (defs merged with the asset's stored values)
|
|
forAsset(assetid) {
|
|
return api.get(`/customfields/asset/${assetid}`)
|
|
},
|
|
saveForAsset(assetid, values) {
|
|
return api.put(`/customfields/asset/${assetid}`, { values })
|
|
}
|
|
}
|
|
|
|
// Warranty plugin: asset warranty tracking (manual + provider lookups).
|
|
export const warrantyApi = {
|
|
list(params = {}) {
|
|
return api.get('/warranty', { params })
|
|
},
|
|
get(id) {
|
|
return api.get(`/warranty/${id}`)
|
|
},
|
|
forAsset(assetid) {
|
|
return api.get(`/warranty/asset/${assetid}`)
|
|
},
|
|
create(data) {
|
|
return api.post('/warranty', data)
|
|
},
|
|
update(id, data) {
|
|
return api.put(`/warranty/${id}`, data)
|
|
},
|
|
remove(id) {
|
|
return api.delete(`/warranty/${id}`)
|
|
},
|
|
refresh(id) {
|
|
return api.post(`/warranty/${id}/refresh`)
|
|
},
|
|
syncDell(all = false) {
|
|
return api.post('/warranty/sync/dell', null, { params: all ? { all: 'true' } : {} })
|
|
},
|
|
report() {
|
|
return api.get('/warranty/report')
|
|
},
|
|
// proof of cover: invoice, certificate, whatever the vendor sent
|
|
uploadProof(id, file) {
|
|
const form = new FormData()
|
|
form.append('file', file)
|
|
return api.post(`/warranty/${id}/proof`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
|
},
|
|
removeProof(id) {
|
|
return api.delete(`/warranty/${id}/proof`)
|
|
}
|
|
}
|
|
|
|
// 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}`)
|
|
}
|
|
}
|