Files
shopdb-flask/frontend/src/api/index.js
cproudlock 30d0d4f6d5 Stop asking for more rows than the server will ever return
You are right that this is the one we keep hitting. perpage is capped at 100
server-side, and the cap is applied SILENTLY: a request for 500 returns the
first 100 rows with a 200, so a picker built that way shows the first 100
options and drops the rest with nothing anywhere reporting a problem. The
missing vendor simply is not in the list.

Twelve call sites were still doing it - vendor and business-unit pickers on the
machine and measuring-tool forms, and the type lists behind PCs, machines,
printers, network devices, measuring tools and configuration policies. Each now
either pages through with listAll()/fetchAllPages() or asks for what it can
actually have. vendorsApi and businessunitsApi gained listAll(), which they
lacked, which is why those two sites reached for 500 in the first place.

The gate is the point. tests/test_core/test_perpage_cap.py reads every .vue and
.js source under frontend/src and plugins/*/frontend and fails on any perpage
over 100. It has to read the SOURCE rather than the running app, because the
response is a valid 200 either way - no runtime assertion can see this one.
Verified by planting a perpage: 500 back into MachineForm: the gate names that
exact line.

Separately, /api/auditlogs built its own meta with the pagination fields flat
and a 'pages' key, while every other list returns
meta.pagination.{page,perpage,total,totalpages,hasnext,hasprev}. A client
written against the documented contract found nothing where it looked, on the
one list whose whole purpose is being read by somebody checking what happened.
It uses paginated_response now.

Also carried here, from the previous batch's working tree: Asset.to_dict emits
ownlocationid/ownmapx/ownmapy/ownlevelid alongside the resolved values, and the
PC form binds to those. The merge of an inherited location is right for a reader
and wrong for a form, which could not tell an owned value from a derived one and
wrote the inherited one back as the record's own.

Scope: product
2026-09-03 16:49:55 -04:00

1664 lines
48 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}`)
},
enqueueCommand(assetId, action, target = null) {
const body = { action }
if (target) body.target = target
return api.post(`/endpoints/${assetId}/commands`, body)
},
listCommands(assetId) {
return api.get(`/endpoints/${assetId}/commands`)
},
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)
},
delete(id) {
return api.delete(`/applications/${id}`)
},
// 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 in the
// script's configured run context (system or user).
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')
},
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}`)
}
}