Bring the wilm branch into main: endpoints plugin, agent, policies, CSV import
Ports the corporate-GitHub wilm branch onto main, keeping the two branches
separate. Thirteen upstream commits arrive with their authorship intact; the
review fixes and the portability work below are on top.
What arrives:
endpoints plugin - a ShopDB agent reporting endpoint state, telemetry,
services and interfaces, with a command channel the agent
long-polls. Ingest stays on the generic collector route
(ADR-006); the agent key is a UI-managed single-scope
token whose revocation is an instant kill switch.
computers policies - Configuration and Business Policies (RMM-style), scope
trees validated at write time, actions checked against
the endpoints command allowlist. Feature-gated: every
route 404s while the setting is off, so enabling it can
never half-expose the surface.
asset CSV import - templates, a collector script carrying the site path, and
a validate-then-commit import. A plugin can own a virtual
column plus a post-write row hook, which is how a PC's
machine number becomes a controls link without a
hand-built relationships sheet.
agent/ - the Windows agent and its installer.
Contract 0.21.0 -> 0.24.0, additive throughout: register_import_hook (0.22),
mint/list/revoke_service_token (0.23), collector_authorized (0.24). The stability
table had no rows for any of them and now does.
MySQL 5.6 portability. The branch stored three policy columns as sa.JSON, the
only JSON columns in the product, and 5.6 has no JSON type: the migration failed
with a bare syntax error near the word JSON. They are TEXT now, through a
JSONText type decorator, so all seventeen call sites still read and write dicts.
The convention elsewhere is already TEXT (guides.blocksjson,
geenforce.manifestjson).
Review fixes carried on top:
- six policy GETs were jwt_required(optional=True), which authenticates a
caller without checking anything; they require computers.view now, and six
redundant jwt_required decorators are gone
- the agent installer took a default BaseUrl and CollectorKey, so a mistyped
run silently pointed an endpoint at the wrong instance; both are mandatory
and a non-https BaseUrl throws unless -AllowInsecureHttp is passed
- a committing CSV import wrote no audit row
Documentation: the 34 new routes are described in docs/api-inventory.json, which
is hand-maintained on purpose, and the generated spec now covers 331 paths and
477 operations across 22 surfaces. DEVELOPMENT-SETUP's 1071 troubleshooting row
said "use MySQL 8 for dev" and now gives the actual fix, including the trap that
5.6 downgrades ROW_FORMAT=DYNAMIC to Compact with a warning instead of an error.
Full suite 2123 passed, vitest 260 passed, all seventeen plugin chains migrate
clean on MySQL 5.6 and the naming, docs, parity and read-surface gates are green.
This commit is contained in:
136
frontend/DEVELOPMENT-STANDARDS.md
Normal file
136
frontend/DEVELOPMENT-STANDARDS.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Frontend Development Standards
|
||||
|
||||
## Naming Convention (LOCKED)
|
||||
|
||||
The shopdb-flask naming convention lives in the root `CONTRIBUTING.md`. Read it before naming any variable, component, API param, or CSS class.
|
||||
|
||||
Frontend-specific reminders pulled from the convention:
|
||||
|
||||
- Variables holding API field values: match the API field name exactly. Do NOT convert to camelCase. (`response.machineid`, NOT `response.machineId`)
|
||||
- Pure JS variables: camelCase (`currentUser`, `isLoading`)
|
||||
- Vue components: PascalCase, spelled out (`AssetDetail.vue`, `MachineForm.vue`)
|
||||
- CSS classes: lowercase with dashes (`asset-detail`, `machine-form`)
|
||||
- API params sent to backend: match DB column names without underscores (`params.locationid = 5`, NOT `params.location_id`)
|
||||
- No emojis, em-dashes, smart quotes, or Unicode arrows anywhere. Plain ASCII only.
|
||||
- Banned shorthand as standalone variables: `cfg`, `ctx`, `mgr`, `req`, `res`, `env`, `util`, `helper`. Spell them out (`canvasContext`, `response`, `manager`, etc.). Suffix usage like `printers_bp` is allowed.
|
||||
|
||||
Pre-commit hook at `scripts/check-naming-and-style.sh` enforces these rules.
|
||||
|
||||
## CSS Styling Standards
|
||||
|
||||
### Use CSS Variables for ALL Colors
|
||||
|
||||
**NEVER hardcode colors in component styles.** Always use CSS variables defined in `src/assets/style.css`.
|
||||
|
||||
Available variables:
|
||||
```css
|
||||
--primary /* Primary brand color */
|
||||
--primary-dark /* Darker primary for hover states */
|
||||
--secondary /* Secondary/muted color */
|
||||
--success /* Success states (green) */
|
||||
--warning /* Warning states (orange) */
|
||||
--danger /* Error/danger states (red) */
|
||||
--bg /* Page background */
|
||||
--bg-card /* Card/panel background */
|
||||
--text /* Primary text color */
|
||||
--text-light /* Secondary/muted text */
|
||||
--border /* Border color */
|
||||
--link /* Link color (bright blue in dark mode) */
|
||||
```
|
||||
|
||||
**Bad:**
|
||||
```css
|
||||
.my-card {
|
||||
background: white;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
```
|
||||
|
||||
**Good:**
|
||||
```css
|
||||
.my-card {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
}
|
||||
```
|
||||
|
||||
### Detail Pages - Use Global Styles
|
||||
|
||||
All detail pages (MachineDetail, PCDetail, PrinterDetail, ApplicationDetail) should use the **unified global styles** from `style.css`:
|
||||
|
||||
- `.detail-page` - Container wrapper
|
||||
- `.hero-card` - Main hero section with image and info
|
||||
- `.hero-image`, `.hero-content`, `.hero-title`, `.hero-meta`, `.hero-details`
|
||||
- `.section-card` - Info sections
|
||||
- `.section-title` - Section headers
|
||||
- `.info-list`, `.info-row`, `.info-label`, `.info-value`
|
||||
- `.content-grid`, `.content-column` - Two-column layout
|
||||
- `.audit-footer` - Created/modified timestamps
|
||||
|
||||
**Only add scoped styles for page-specific elements** (e.g., supplies grid for printers, version list for applications).
|
||||
|
||||
### PrinterDetail.vue is the Master Template for Detail Pages
|
||||
|
||||
Use `PrinterDetail.vue` as the reference for new detail pages. Follow its structure and styling patterns.
|
||||
|
||||
### List Pages - Use Global Styles
|
||||
|
||||
All list pages should use the **unified global styles** from `style.css`:
|
||||
|
||||
- `.page-header` - Header with title and action button
|
||||
- `.filters` - Search and filter controls
|
||||
- `.card` - Main content container
|
||||
- `.table-container` - Scrollable table wrapper
|
||||
- `table`, `th`, `td` - Table styling
|
||||
- `.pagination` - Page navigation
|
||||
- `.badge`, `.badge-success`, etc. - Status badges
|
||||
- `.actions` - Action button column
|
||||
|
||||
**PrintersList.vue is the Master Template for List Pages**
|
||||
|
||||
Use `PrintersList.vue` as the reference for new list pages. It has NO scoped styles - everything uses global CSS.
|
||||
|
||||
**Only add scoped styles for page-specific elements** (e.g., icon cells for applications, stats badge for knowledge base).
|
||||
|
||||
### Dark Mode Support
|
||||
|
||||
Dark mode is automatic via `@media (prefers-color-scheme: dark)`. Using CSS variables ensures colors adapt automatically - no extra work needed per page.
|
||||
|
||||
## Data Entry: Page or Modal
|
||||
|
||||
Both patterns are in use on purpose. Pick by what the record IS, not by how big the form feels today.
|
||||
|
||||
**Routed form page** (`views/<Thing>Form.vue` + a route) when the record has its own detail page and its own URL: machines, PCs, printers, network devices, measuring tools, USB devices, applications, knowledge-base articles, notifications, printed items. These forms are long, often carry an image upload, a map-position picker or relationship editing, and someone will want to link straight to one.
|
||||
|
||||
**Modal over its list** when the record is reference data that only exists inside the list it belongs to: every `*TypesList`, plus locations, vendors, models, VLANs, subnets, support teams, custom fields, API tokens, printer drivers, dashboard defaults. The form is a handful of fields, and keeping the list visible behind the dialog is the point.
|
||||
|
||||
Rule of thumb: **a thing with a detail page gets a form page; a lookup row gets a modal.**
|
||||
|
||||
Modal rules (from the overlay-close fix, commit d8fe0a4):
|
||||
|
||||
- A modal holding typed input must NOT close on overlay click or Escape. Losing a part-filled form to a stray click is not an acceptable failure.
|
||||
- A confirmation dialog MAY close on overlay click (`@click.self`), since it holds nothing to lose.
|
||||
- The panel background is `var(--bg-card-solid)`, never `var(--bg-card)`: the card variable is translucent in dark mode, which leaves a dialog see-through over the overlay. The shared `.modal` in `style.css` already does this; hand-rolled `.modal-panel` rules must too.
|
||||
|
||||
## Component Organization
|
||||
|
||||
- **Global styles**: `src/assets/style.css`
|
||||
- **Page-specific styles**: Scoped `<style scoped>` block, only for unique elements
|
||||
- **Font sizes**: Use `rem` units, base is 18px for readability on 1080p
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
assets/
|
||||
style.css # Global styles, CSS variables, detail page styles
|
||||
views/
|
||||
machines/
|
||||
MachineDetail.vue # Uses global styles only
|
||||
pcs/
|
||||
PCDetail.vue # Global + PC-specific (app-list, etc.)
|
||||
printers/
|
||||
PrinterDetail.vue # Global + printer-specific (supplies-grid)
|
||||
applications/
|
||||
ApplicationDetail.vue # Global + app-specific (version-list, pc-list)
|
||||
```
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "shopdb-frontend",
|
||||
"version": "0.7.0",
|
||||
"version": "0.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "shopdb-frontend",
|
||||
"version": "0.7.0",
|
||||
"version": "0.12.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
|
||||
@@ -146,6 +146,27 @@ export const machinesApi = {
|
||||
}
|
||||
}
|
||||
|
||||
// Endpoints API (plugin) - fleet telemetry read surface
|
||||
export const endpointsApi = {
|
||||
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`)
|
||||
}
|
||||
}
|
||||
|
||||
// Computers API (plugin)
|
||||
export const computersApi = {
|
||||
list(params = {}) {
|
||||
@@ -187,6 +208,11 @@ export const computersApi = {
|
||||
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}`)
|
||||
},
|
||||
@@ -214,6 +240,50 @@ export const computersApi = {
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,6 +343,12 @@ 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}`)
|
||||
},
|
||||
@@ -1019,6 +1095,42 @@ export const settingsApi = {
|
||||
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' }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,30 @@
|
||||
on any asset detail page, so a plugin adds detail-page UI with no Vue. -->
|
||||
<template v-for="panel in panels" :key="panel.id">
|
||||
<div v-if="panelVisible(panel)" class="section-card">
|
||||
<h3 class="section-title">{{ panel.title }}</h3>
|
||||
<!-- collapsible panels get a clickable header with a caret and, when the
|
||||
panel declares a status rollup, a green/yellow/red dot for the worst
|
||||
row (a stopped service or a warning/critical event). --><h3
|
||||
v-if="panel.collapsible"
|
||||
class="section-title pap-header"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-expanded="!isCollapsed(panel)"
|
||||
@click="toggleCollapse(panel)"
|
||||
@keydown.enter.prevent="toggleCollapse(panel)"
|
||||
@keydown.space.prevent="toggleCollapse(panel)"
|
||||
>
|
||||
<span class="pap-caret" :class="{ collapsed: isCollapsed(panel) }" aria-hidden="true">▾</span>
|
||||
<span class="pap-header-title">{{ panel.title }}</span>
|
||||
<span
|
||||
v-if="rollupStatus(panel)"
|
||||
class="pap-status-dot"
|
||||
:style="{ backgroundColor: rollupStatus(panel).color }"
|
||||
:title="rollupStatus(panel).level"
|
||||
></span>
|
||||
</h3>
|
||||
<h3 v-else class="section-title">{{ panel.title }}</h3>
|
||||
|
||||
<div v-show="!panel.collapsible || !isCollapsed(panel)" class="pap-body">
|
||||
|
||||
<!-- list: items each with a title, optional status badge, meta lines -->
|
||||
<div v-if="panel.render === 'list'" class="pap-list">
|
||||
@@ -16,6 +39,13 @@
|
||||
:style="colorStyle(mapBadge(panel, item).color)">
|
||||
{{ mapBadge(panel, item).label }}
|
||||
</span>
|
||||
<button
|
||||
v-if="rowAction(panel, item)"
|
||||
type="button"
|
||||
class="btn btn-sm pap-row-action"
|
||||
:disabled="actionBusy[panel.id]"
|
||||
@click="runRowAction(panel, item)"
|
||||
>{{ rowAction(panel, item).toggleLabel }}</button>
|
||||
</div>
|
||||
<div v-if="mapMeta(panel, item).length" class="pap-meta">
|
||||
<span v-for="(m, mi) in mapMeta(panel, item)" :key="mi"
|
||||
@@ -25,6 +55,9 @@
|
||||
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
|
||||
{{ panel.manage.label || 'Manage' }}
|
||||
</router-link>
|
||||
<div v-if="actionError[panel.id]" class="pap-action-error">
|
||||
{{ actionError[panel.id] }}
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="pap-empty">
|
||||
<span class="muted">{{ panel.empty || 'Nothing to show.' }}</span>
|
||||
@@ -90,6 +123,7 @@
|
||||
<span v-for="(b, bi) in badges(panel)" :key="bi" class="status-badge"
|
||||
:style="colorStyle(b.color)">{{ b.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -100,7 +134,8 @@ import api from '../api'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import {
|
||||
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
|
||||
keyvalueFields, tableColumns, cell, badges, tabSections,
|
||||
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
|
||||
rowAction,
|
||||
} from './pluginAssetPanels'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -115,6 +150,117 @@ const tabs = ref({})
|
||||
function activeTab(panel) { return tabs.value[panel.id] || 0 }
|
||||
function setTab(panel, index) { tabs.value = { ...tabs.value, [panel.id]: index } }
|
||||
|
||||
// Collapsed state per panel id, kept here for the same reason as tabs: panels
|
||||
// are replaced wholesale on refetch. A panel may declare collapsedDefault to
|
||||
// start collapsed (for example a low-priority events card); once the user
|
||||
// toggles it, their explicit choice (stored by id) wins.
|
||||
const collapsed = ref({})
|
||||
function isCollapsed(panel) {
|
||||
const state = collapsed.value[panel.id]
|
||||
if (state === undefined) return !!panel.collapsedDefault
|
||||
return !!state
|
||||
}
|
||||
function toggleCollapse(panel) {
|
||||
collapsed.value = { ...collapsed.value, [panel.id]: !isCollapsed(panel) }
|
||||
}
|
||||
|
||||
// Per-panel action state: busy guards a double-click while an enqueue POST is
|
||||
// in flight, and the error string surfaces a rejected enqueue (bad action, or
|
||||
// a 409 when a command is already queued). Keyed by panel id for the same
|
||||
// reason as tabs/collapsed: panels are replaced wholesale on refetch.
|
||||
const actionBusy = ref({})
|
||||
const actionError = ref({})
|
||||
|
||||
// Enqueue the inline row toggle's command (Start/Stop for an actionable
|
||||
// services/processes row, e.g. MTConnect), then reload so the row reflects the
|
||||
// new state. The agent long-polls, runs it, posts the result, and wakes an
|
||||
// immediate telemetry re-collect, so the settled run state lands on the next
|
||||
// panel refresh rather than after the full cadence.
|
||||
async function runRowAction(panel, item) {
|
||||
if (actionBusy.value[panel.id]) return
|
||||
const info = rowAction(panel, item)
|
||||
if (!info || !info.endpoint || !info.toggle) return
|
||||
actionBusy.value = { ...actionBusy.value, [panel.id]: true }
|
||||
actionError.value = { ...actionError.value, [panel.id]: '' }
|
||||
try {
|
||||
const body = { action: info.toggle }
|
||||
if (info.target) body.target = info.target
|
||||
// Baseline telemetry freshness before queuing so we can tell a genuinely
|
||||
// new snapshot from the stale cached one after the command settles.
|
||||
const baseline = await currentTelemetryStamp()
|
||||
const response = await api.post(toApiPath(info.endpoint, props.assetid), body)
|
||||
const commandId = response?.data?.data?.endpointcommandid
|
||||
// The POST only queues the command (201). The agent runs it asynchronously,
|
||||
// so poll for the terminal result and surface the agent's own message when
|
||||
// it fails (e.g. a bad launch path), rather than reporting a false success.
|
||||
const settled = await waitForCommandResult(commandId)
|
||||
if (settled && settled.status === 'failed') {
|
||||
actionError.value = {
|
||||
...actionError.value,
|
||||
[panel.id]: settled.resultmessage || 'The endpoint agent could not run the command.',
|
||||
}
|
||||
} else if (settled && settled.status === 'succeeded') {
|
||||
// The command ran, but the fresh run state only lands when the agent's
|
||||
// follow-up telemetry post arrives. Wait for lastreportedat to advance so
|
||||
// the reloaded row reflects reality instead of the pre-command snapshot.
|
||||
await waitForFreshTelemetry(baseline)
|
||||
}
|
||||
await load()
|
||||
} catch (err) {
|
||||
const message = err?.response?.data?.message || 'Command failed to queue.'
|
||||
actionError.value = { ...actionError.value, [panel.id]: message }
|
||||
} finally {
|
||||
actionBusy.value = { ...actionBusy.value, [panel.id]: false }
|
||||
}
|
||||
}
|
||||
|
||||
// Poll the endpoint's recent commands until the given command reaches a terminal
|
||||
// state (succeeded/failed) or the attempt budget runs out. Returns the command
|
||||
// row or null. Uses the same commands endpoint the operator view reads.
|
||||
async function waitForCommandResult(commandId, { attempts = 15, delayMs = 1000 } = {}) {
|
||||
if (!commandId || !props.assetid) return null
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
try {
|
||||
const response = await api.get(`/endpoints/${props.assetid}/commands`)
|
||||
const command = (response?.data?.data || []).find(
|
||||
(row) => row.endpointcommandid === commandId)
|
||||
if (command && (command.status === 'succeeded' || command.status === 'failed')) {
|
||||
return command
|
||||
}
|
||||
} catch {
|
||||
// Transient poll failure: keep trying until the attempt budget runs out.
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Read the endpoint's current lastreportedat (ISO string or null) so a row
|
||||
// action can baseline freshness before it forces a state change.
|
||||
async function currentTelemetryStamp() {
|
||||
if (!props.assetid) return null
|
||||
try {
|
||||
const response = await api.get(`/endpoints/asset/${props.assetid}/telemetrymeta`)
|
||||
return response?.data?.data?.lastreportedat || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Poll lastreportedat until it advances past the baseline (proof the agent has
|
||||
// posted a fresh snapshot reflecting the command) or the attempt budget runs
|
||||
// out. Bounded so an idle/offline agent never hangs the row button; the caller
|
||||
// reloads regardless once this returns.
|
||||
async function waitForFreshTelemetry(baseline, { attempts = 15, delayMs = 1000 } = {}) {
|
||||
const baseMs = baseline ? Date.parse(baseline) : 0
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
const stamp = await currentTelemetryStamp()
|
||||
if (stamp && Date.parse(stamp) > baseMs) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Fetch the declared panels for this asset, then each panel's data endpoint.
|
||||
async function load() {
|
||||
panels.value = []
|
||||
@@ -142,6 +288,10 @@ async function load() {
|
||||
}
|
||||
|
||||
watch(() => props.assetid, load, { immediate: true })
|
||||
|
||||
// Let a parent detail page (for example PCDetail's header Refresh button)
|
||||
// trigger a panel reload without duplicating the fetch plumbing.
|
||||
defineExpose({ refresh: load })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -195,6 +345,22 @@ watch(() => props.assetid, load, { immediate: true })
|
||||
.pap-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
.pap-table th, .pap-table td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); }
|
||||
.pap-badges { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.pap-row-action { margin-left: 0.5rem; flex: 0 0 auto; }
|
||||
.pap-action-error { color: var(--danger, #f5365c); font-size: 0.82rem; }
|
||||
.pap-header {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
cursor: pointer; user-select: none;
|
||||
}
|
||||
.pap-header:hover .pap-header-title { color: var(--primary); }
|
||||
.pap-header-title { flex: 1 1 auto; }
|
||||
.pap-caret {
|
||||
font-size: 0.7rem; color: var(--text-light);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.pap-caret.collapsed { transform: rotate(-90deg); }
|
||||
.pap-status-dot {
|
||||
flex: 0 0 auto; width: 0.7rem; height: 0.7rem; border-radius: 50%;
|
||||
}
|
||||
.mono { font-family: monospace; }
|
||||
.muted { color: var(--text-light); }
|
||||
</style>
|
||||
|
||||
@@ -29,10 +29,20 @@ export function humanize(key) {
|
||||
|
||||
export function rows(panel) {
|
||||
const data = panel._data
|
||||
if (Array.isArray(data)) return data
|
||||
if (data && Array.isArray(data.rows)) return data.rows
|
||||
if (data && Array.isArray(data.items)) return data.items
|
||||
return []
|
||||
let list
|
||||
if (Array.isArray(data)) list = data
|
||||
else if (data && Array.isArray(data.rows)) list = data.rows
|
||||
else if (data && Array.isArray(data.items)) list = data.items
|
||||
else return []
|
||||
// Optional panel-level row filter so two panels can share one endpoint feed
|
||||
// and each show a slice of it (for example Tracked Services vs Tracked
|
||||
// Processes off the same services snapshot). Absent filter = all rows.
|
||||
const filter = panel.filter
|
||||
if (filter && filter.key) {
|
||||
const want = String(filter.equals).toLowerCase()
|
||||
return list.filter((item) => String(item[filter.key]).toLowerCase() === want)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
export function mapTitle(panel, item) {
|
||||
@@ -98,6 +108,37 @@ export function badges(panel) {
|
||||
return list.map((b) => ({ label: b.label, color: b.color }))
|
||||
}
|
||||
|
||||
// Header status rollup for a card. A panel opts in with
|
||||
// status: { key, levels: { <rowvalue>: 'critical'|'warning'|'ok'|'unknown' } }.
|
||||
// The card header then shows a single green/yellow/red dot reflecting the worst
|
||||
// row: any critical -> red, else any warning -> yellow, else ok -> green. A
|
||||
// value not in levels is treated as unknown and does not raise the level.
|
||||
export const STATUS_RANK = { ok: 1, unknown: 1, warning: 2, critical: 3 }
|
||||
export const STATUS_COLOR = {
|
||||
ok: '#2dce89',
|
||||
warning: '#ffc107',
|
||||
critical: '#f5365c',
|
||||
unknown: '#6c757d',
|
||||
}
|
||||
|
||||
export function rollupStatus(panel) {
|
||||
const status = panel && panel.status
|
||||
if (!status || !status.key) return null
|
||||
const items = rows(panel)
|
||||
if (!items.length) return null
|
||||
const levels = status.levels || {}
|
||||
let worst = 'ok'
|
||||
let sawKnown = false
|
||||
for (const item of items) {
|
||||
const level = levels[item[status.key]]
|
||||
if (!level) continue
|
||||
sawKnown = true
|
||||
if (STATUS_RANK[level] > STATUS_RANK[worst]) worst = level
|
||||
}
|
||||
if (!sawKnown) return null
|
||||
return { level: worst, color: STATUS_COLOR[worst] || STATUS_COLOR.unknown }
|
||||
}
|
||||
|
||||
// tabs: [{ label, fields: [{label, value, mono}] }]. One section is shown at a
|
||||
// time, so a panel with 30 values stays the height of its largest section
|
||||
// instead of dragging one multicol column far past the other.
|
||||
@@ -114,6 +155,30 @@ export function tabSections(panel) {
|
||||
.filter((s) => s.fields.length > 0)
|
||||
}
|
||||
|
||||
// Inline row action for a list panel. A monitored services/processes row may
|
||||
// carry action: { running, start, stop, status } when it has a registered
|
||||
// command (e.g. MTConnect). The card renders a single toggle on that row: when
|
||||
// running it offers Stop, otherwise Start. Rows with no action are badge-only.
|
||||
// commandendpoint on the panel is where the enqueue POST goes.
|
||||
export function rowAction(panel, item) {
|
||||
const action = item && item.action
|
||||
if (!action || (!action.start && !action.stop)) return null
|
||||
const running = action.running === true
|
||||
return {
|
||||
running,
|
||||
// The action name to enqueue for the primary toggle button.
|
||||
toggle: running ? action.stop : action.start,
|
||||
toggleLabel: running ? 'Stop' : 'Start',
|
||||
// status is a non-mutating probe, always available when declared.
|
||||
status: action.status || null,
|
||||
// Generic actions (service_*/process_*) carry the subject to act on; fixed
|
||||
// actions (mtconnect_*) leave this null.
|
||||
target: action.target || null,
|
||||
// The declared enqueue endpoint template; the caller substitutes assetid.
|
||||
endpoint: panel.commandendpoint || null,
|
||||
}
|
||||
}
|
||||
|
||||
export function panelVisible(panel) {
|
||||
if (panel.render === 'tabs') return tabSections(panel).length > 0
|
||||
if (panel.render === 'list') return rows(panel).length > 0 || !!panel.empty
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink,
|
||||
keyvalueFields, tableColumns, badges, panelVisible, tabSections,
|
||||
keyvalueFields, tableColumns, badges, panelVisible, tabSections, rollupStatus,
|
||||
rowAction,
|
||||
} from './pluginAssetPanels'
|
||||
|
||||
const warrantyPanel = {
|
||||
@@ -71,6 +72,34 @@ describe('list render mapping (warranty)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('list row filtering (tracked services vs processes)', () => {
|
||||
const feedPanel = {
|
||||
id: 'endpoints-tracked-services', render: 'list',
|
||||
filter: { key: 'kind', equals: 'service' },
|
||||
map: { title: 'name' },
|
||||
_data: [
|
||||
{ name: 'UDC MTConnect Agent', kind: 'service' },
|
||||
{ name: 'DncMain', kind: 'process' },
|
||||
{ name: 'Spooler', kind: 'SERVICE' },
|
||||
],
|
||||
}
|
||||
|
||||
it('keeps only rows whose filter key matches, case-insensitively', () => {
|
||||
const kept = rows(feedPanel).map((r) => r.name)
|
||||
expect(kept).toEqual(['UDC MTConnect Agent', 'Spooler'])
|
||||
})
|
||||
|
||||
it('filters the same feed to processes when equals is process', () => {
|
||||
const kept = rows({ ...feedPanel, filter: { key: 'kind', equals: 'process' } })
|
||||
expect(kept.map((r) => r.name)).toEqual(['DncMain'])
|
||||
})
|
||||
|
||||
it('returns every row when no filter is declared', () => {
|
||||
const kept = rows({ ...feedPanel, filter: undefined })
|
||||
expect(kept).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('keyvalue + table + badge', () => {
|
||||
it('keyvalue reads a fields array or a plain object', () => {
|
||||
const fromFields = keyvalueFields({
|
||||
@@ -130,3 +159,72 @@ describe('tabSections', () => {
|
||||
expect(panelVisible({ render: 'tabs', _data: { sections: [] } })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rollupStatus', () => {
|
||||
const status = {
|
||||
key: 'statecolor',
|
||||
levels: { green: 'ok', yellow: 'warning', red: 'critical' },
|
||||
}
|
||||
|
||||
it('returns null when the panel declares no status', () => {
|
||||
expect(rollupStatus({ _data: [{ statecolor: 'green' }] })).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when there are no rows', () => {
|
||||
expect(rollupStatus({ status, _data: [] })).toBeNull()
|
||||
})
|
||||
|
||||
it('is ok (green) when every row is ok', () => {
|
||||
const result = rollupStatus({ status, _data: [{ statecolor: 'green' }, { statecolor: 'green' }] })
|
||||
expect(result).toEqual({ level: 'ok', color: '#2dce89' })
|
||||
})
|
||||
|
||||
it('is warning (yellow) when a warning is present but no critical', () => {
|
||||
const result = rollupStatus({ status, _data: [{ statecolor: 'green' }, { statecolor: 'yellow' }] })
|
||||
expect(result).toEqual({ level: 'warning', color: '#ffc107' })
|
||||
})
|
||||
|
||||
it('is critical (red) when any row is stopped', () => {
|
||||
const result = rollupStatus({ status, _data: [{ statecolor: 'yellow' }, { statecolor: 'red' }] })
|
||||
expect(result).toEqual({ level: 'critical', color: '#f5365c' })
|
||||
})
|
||||
|
||||
it('ignores unmapped values without raising the level', () => {
|
||||
const result = rollupStatus({ status, _data: [{ statecolor: 'gray' }, { statecolor: 'green' }] })
|
||||
expect(result).toEqual({ level: 'ok', color: '#2dce89' })
|
||||
})
|
||||
|
||||
it('returns null when no row value maps to a known level', () => {
|
||||
expect(rollupStatus({ status, _data: [{ statecolor: 'gray' }] })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rowAction (inline MTConnect toggle)', () => {
|
||||
const panel = { id: 'endpoints-services', render: 'list', commandendpoint: '/api/endpoints/5/commands' }
|
||||
|
||||
it('returns null for rows without an action', () => {
|
||||
expect(rowAction(panel, { name: 'Some Service' })).toBeNull()
|
||||
expect(rowAction(panel, { name: 'x', action: { status: 'mtconnect_status' } })).toBeNull()
|
||||
})
|
||||
|
||||
it('offers Stop when the row is running', () => {
|
||||
const info = rowAction(panel, {
|
||||
name: 'MTConnect',
|
||||
action: { running: true, start: 'mtconnect_start', stop: 'mtconnect_stop', status: 'mtconnect_status' },
|
||||
})
|
||||
expect(info.running).toBe(true)
|
||||
expect(info.toggle).toBe('mtconnect_stop')
|
||||
expect(info.toggleLabel).toBe('Stop')
|
||||
expect(info.endpoint).toBe('/api/endpoints/5/commands')
|
||||
})
|
||||
|
||||
it('offers Start when the row is not running', () => {
|
||||
const info = rowAction(panel, {
|
||||
name: 'MTConnect',
|
||||
action: { running: false, start: 'mtconnect_start', stop: 'mtconnect_stop', status: 'mtconnect_status' },
|
||||
})
|
||||
expect(info.running).toBe(false)
|
||||
expect(info.toggle).toBe('mtconnect_start')
|
||||
expect(info.toggleLabel).toBe('Start')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -150,6 +150,12 @@ export default [
|
||||
component: () => import('../../views/settings/BrandingSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/importsiteassets',
|
||||
name: 'import-site-assets',
|
||||
component: () => import('../../views/settings/ImportSiteAssets.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/printing',
|
||||
name: 'printing-settings',
|
||||
|
||||
@@ -113,7 +113,7 @@ import ToastHost from '../components/ToastHost.vue'
|
||||
import {
|
||||
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
|
||||
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
|
||||
Box, KeyRound, LogOut, Wrench
|
||||
Box, KeyRound, LogOut, Wrench, Activity
|
||||
} from 'lucide-vue-next'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { currentTheme, toggleTheme } from '../stores/theme'
|
||||
@@ -183,6 +183,7 @@ const iconMap = {
|
||||
'ruler': Ruler,
|
||||
'box': Box,
|
||||
'wrench': Wrench,
|
||||
'activity': Activity,
|
||||
}
|
||||
|
||||
// Default navigation (used as fallback if API fails)
|
||||
|
||||
@@ -360,8 +360,10 @@ async function loadFilterOptions(fields) {
|
||||
filterOptions.value.assettypes = response.data.data || []
|
||||
}
|
||||
if (fields.includes('location') && !filterOptions.value.locations.length) {
|
||||
const response = await locationsApi.list({ perpage: 100 })
|
||||
filterOptions.value.locations = response.data.data || []
|
||||
// listAll: 100 is the server-side cap, not a generous limit, and the
|
||||
// location list is past it - a report filtered by a late-alphabet
|
||||
// location could not be built.
|
||||
filterOptions.value.locations = await locationsApi.listAll()
|
||||
}
|
||||
if (fields.includes('application') && !filterOptions.value.applications.length) {
|
||||
// listAll: 100 is the server-side cap, not a generous limit, and the
|
||||
|
||||
966
frontend/src/views/settings/ImportSiteAssets.vue
Normal file
966
frontend/src/views/settings/ImportSiteAssets.vue
Normal file
@@ -0,0 +1,966 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Import Site Assets</h1>
|
||||
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="lead">
|
||||
There are several ways to seed this facility's inventory - Machines, PCs,
|
||||
Applications, and their properties - into ShopDB. You can combine them:
|
||||
each source writes to the same set of importable CSVs, and a later run
|
||||
fills gaps without wiping data an earlier run captured.
|
||||
</p>
|
||||
|
||||
<div class="methods">
|
||||
<div class="method is-primary">
|
||||
<div class="method-head">
|
||||
<span class="badge">Recommended</span>
|
||||
<h3>Asset Collector PowerShell script</h3>
|
||||
</div>
|
||||
<p>
|
||||
Deploy <code>Collect-ShopDB.ps1</code> to your existing endpoints in a
|
||||
way of your choosing (logon script, scheduled task, remote execution,
|
||||
or a manual run). It runs read-only, extracts each PC and any attached
|
||||
eDNC/DNC machine, and extrapolates vendor, model, OS, network, and
|
||||
installed-application details. Every run UPSERTs rows into the shared
|
||||
CSV store below, so a site builds its inventory one PC at a time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<div class="method-head">
|
||||
<h3>Existing databases</h3>
|
||||
</div>
|
||||
<p>
|
||||
Export from an existing MES, CMDB, or asset spreadsheet into the same
|
||||
CSV schema and drop the files in the collection location.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<div class="method-head">
|
||||
<h3>Intune / Azure exports</h3>
|
||||
</div>
|
||||
<p>
|
||||
Map a device or application export (for example from Microsoft Graph)
|
||||
onto the computers and applications CSVs, then place them alongside the
|
||||
script output.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="section-title">Asset Collector Script</h3>
|
||||
<p class="muted">
|
||||
Download <code>Collect-ShopDB.ps1</code> and deploy it to your endpoints
|
||||
(logon script, scheduled task, remote execution, or a manual run). The CSV
|
||||
Collections Location you save below is baked into the script's
|
||||
<code>$NetworkStoreRoot</code> variable, so every endpoint publishes into
|
||||
the same store without editing PowerShell by hand.
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="muted">Loading...</div>
|
||||
<template v-else>
|
||||
<label class="field">
|
||||
<span>CSV Collections Location</span>
|
||||
<input
|
||||
v-model="location"
|
||||
type="text"
|
||||
placeholder="\\server.fqdn\share\folder"
|
||||
/>
|
||||
<small class="muted">
|
||||
UNC path or folder where the script deposits its CSVs (e.g.
|
||||
\\server.fqdn\share\folder). Saved here and embedded into the script
|
||||
download.
|
||||
</small>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving...' : 'Save Location' }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" :disabled="downloadingScript" @click="downloadScript">
|
||||
{{ downloadingScript ? 'Preparing...' : 'Download Script' }}
|
||||
</button>
|
||||
<span v-if="saved" class="muted">Saved.</span>
|
||||
<span v-if="error" class="error">{{ error }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="section-title">Import CSVs</h3>
|
||||
<p class="muted">
|
||||
Select one or more filled-in CSVs. They are ordered by dependency
|
||||
automatically, so you can pick the whole set at once. Preview first: it
|
||||
writes nothing and reports exactly what each file would create or update,
|
||||
plus any rows that need fixing. Nothing is imported until you press
|
||||
Commit, and the whole set is applied together - if any row has a problem,
|
||||
nothing is written.
|
||||
</p>
|
||||
|
||||
<label class="field">
|
||||
<span>Source format</span>
|
||||
<div class="source-cards">
|
||||
<button
|
||||
v-for="s in IMPORT_SOURCES"
|
||||
:key="s.key"
|
||||
type="button"
|
||||
:class="['source-card', { active: importSource === s.key }]"
|
||||
@click="selectSource(s.key)"
|
||||
>
|
||||
<span class="source-card-title">{{ s.label }}</span>
|
||||
<span class="source-card-desc">{{ s.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<small class="muted">
|
||||
ShopDB native expects the canonical per-table CSVs below. Azure and
|
||||
Thor accept those tools' own exports and convert them for you.
|
||||
</small>
|
||||
</label>
|
||||
|
||||
<div v-if="importSource === 'azure'" class="source-help">
|
||||
<p class="muted">
|
||||
An Azure device-group export lists device hostnames plus their Entra
|
||||
deviceId. The device displayName is matched to an existing PC by
|
||||
hostname (case-insensitive): a match updates that PC in place and
|
||||
stores its deviceId, object id, and category; a device with no
|
||||
matching hostname creates a new computer (assettype computer, status
|
||||
In Use). It carries no model, OS, location, or user data - enrich
|
||||
those later with the collector or a Thor import.
|
||||
</p>
|
||||
<label class="field">
|
||||
<span>Assign computer type (optional)</span>
|
||||
<select v-model="azureComputerType">
|
||||
<option value="">No type</option>
|
||||
<option v-for="t in computerTypes" :key="t.computertypeid" :value="t.computertype">
|
||||
{{ t.computertype }}
|
||||
</option>
|
||||
<option value="__custom__">Custom (add a new type)...</option>
|
||||
</select>
|
||||
<small class="muted">
|
||||
Stamped onto every device in this file. For example import the SHP
|
||||
group with "Shopfloor PC" and the BUS group with "Inspection PC".
|
||||
Picking from the list prevents typos labelling hundreds of machines.
|
||||
</small>
|
||||
</label>
|
||||
<label v-if="azureComputerType === '__custom__'" class="field">
|
||||
<span>New computer type name</span>
|
||||
<div class="custom-type-row">
|
||||
<input
|
||||
v-model="customComputerType"
|
||||
type="text"
|
||||
placeholder="e.g. Inspection PC"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="!customComputerType.trim() || addingType"
|
||||
@click="addComputerType"
|
||||
>
|
||||
{{ addingType ? 'Adding...' : 'Add type' }}
|
||||
</button>
|
||||
</div>
|
||||
<small v-if="customTypeError" class="error">{{ customTypeError }}</small>
|
||||
<small v-else class="muted">
|
||||
Adds the type to your settings now and selects it for this import.
|
||||
</small>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Azure device category (optional)</span>
|
||||
<input
|
||||
v-model="azureDeviceCategory"
|
||||
type="text"
|
||||
placeholder="e.g. SFLD_DYN_Device_Group_<Site>_BUS_NC_COUNTRY:USA"
|
||||
/>
|
||||
<small class="muted">
|
||||
The Entra group/category these devices belong to. Stored on every
|
||||
imported device and shown under Entra Device Info on the PC card.
|
||||
</small>
|
||||
</label>
|
||||
<details class="instructions">
|
||||
<summary>How to export group members from Azure</summary>
|
||||
<ol>
|
||||
<li>Open the Microsoft Entra admin center (entra.microsoft.com).</li>
|
||||
<li>Go to Groups, then open the device group you want to import.</li>
|
||||
<li>Select Members in the left menu.</li>
|
||||
<li>In the top command bar, click Bulk operations->Download members to export the CSV.</li>
|
||||
<li>Upload that CSV here with the source set to Azure device group.</li>
|
||||
</ol>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div v-else-if="importSource === 'thor'" class="source-help">
|
||||
<p class="muted">
|
||||
A Thor PC-inventory export is a wide, fixed-format dump. This maps the
|
||||
hostname, BIOS manufacturer, model, OS, logged-in user, last-reported
|
||||
and boot times, and location into ShopDB. Each host is matched to an
|
||||
existing PC by hostname (case-insensitive): a match updates that PC in
|
||||
place; a host with no matching hostname creates a new computer.
|
||||
Network details (MAC, switch, VLAN) and the machine relationship are
|
||||
not imported in this pass.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="import-controls">
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".csv"
|
||||
multiple
|
||||
@change="onFilesPicked"
|
||||
/>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
:disabled="!selectedFiles.length || previewing || committing"
|
||||
@click="preview"
|
||||
>
|
||||
{{ previewing ? 'Checking...' : 'Preview (dry run)' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="!canCommit"
|
||||
@click="commit"
|
||||
>
|
||||
{{ committing ? 'Importing...' : 'Commit Import' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedFiles.length"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="previewing || committing"
|
||||
@click="clearSelection"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="selectedFiles.length" class="muted">
|
||||
{{ selectedFiles.length }} file(s) selected:
|
||||
{{ selectedFiles.map(f => f.name).join(', ') }}
|
||||
</p>
|
||||
|
||||
<p v-if="importError" class="error">{{ importError }}</p>
|
||||
|
||||
<div v-if="report" class="report">
|
||||
<p v-if="report.request_error" class="error">
|
||||
{{ report.request_error }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-else
|
||||
:class="['report-banner', report.committed ? 'ok' : (report.problem_count ? 'warn' : 'info')]"
|
||||
>
|
||||
<template v-if="report.committed">
|
||||
Imported: {{ report.total_created }} created,
|
||||
{{ report.total_updated }} updated.
|
||||
</template>
|
||||
<template v-else-if="report.problem_count">
|
||||
{{ report.problem_count }} problem(s) found - nothing was written.
|
||||
Fix the rows below and preview again.
|
||||
</template>
|
||||
<template v-else>
|
||||
Looks good: {{ report.total_created }} would be created,
|
||||
{{ report.total_updated }} updated. Press Commit Import to apply.
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<p v-if="report.reconciled_count" class="report-banner reconcile">
|
||||
{{ report.reconciled_count }} name(s) reconciled to an existing record
|
||||
within the similarity threshold ({{ report.reconcile_threshold }}).
|
||||
Review the matches below before committing.
|
||||
</p>
|
||||
|
||||
<p v-if="report.warning_count" class="report-banner amber">
|
||||
{{ report.warning_count }} warning(s): these rows still import, but
|
||||
something needs a look - most often a machine link that could not be
|
||||
made. Review them below; each says what to do.
|
||||
</p>
|
||||
|
||||
<table v-if="report.files && report.files.length" class="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Table</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
<th>Reconciled</th>
|
||||
<th>Warnings</th>
|
||||
<th>Problems</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="f in report.files" :key="f.table">
|
||||
<td><code>{{ f.filename }}</code></td>
|
||||
<td>{{ f.table }}</td>
|
||||
<td>{{ f.created }}</td>
|
||||
<td>{{ f.updated }}</td>
|
||||
<td :class="{ 'has-reconcile': (f.reconciliations || []).length }">
|
||||
{{ (f.reconciliations || []).length }}
|
||||
</td>
|
||||
<td :class="{ 'has-warnings': (f.warnings || []).length }">
|
||||
{{ (f.warnings || []).length }}
|
||||
</td>
|
||||
<td :class="{ 'has-problems': f.problems.length }">
|
||||
{{ f.problems.length }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="allReconciliations.length" class="reconciliations">
|
||||
<h4>Reconciled matches</h4>
|
||||
<p class="muted">
|
||||
These names did not match exactly but were close enough to bind to an
|
||||
existing record. Confirm each is correct; if any is wrong, add the
|
||||
exact name to the matching CSV (or lower/raise the reconciliation
|
||||
threshold in Site settings) and preview again.
|
||||
</p>
|
||||
<ul>
|
||||
<li v-for="(r, i) in allReconciliations" :key="i">
|
||||
<code>{{ r.filename }}</code>
|
||||
line {{ r.line }}<template v-if="r.column">, column
|
||||
<code>{{ r.column }}</code></template>:
|
||||
"{{ r.input }}" matched <strong>{{ r.matched }}</strong>
|
||||
({{ r.table }}, {{ Math.round(r.score * 100) }}%)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="allWarnings.length" class="warnings">
|
||||
<h4>Warnings</h4>
|
||||
<p class="muted">
|
||||
Each row below imported, but something could not be completed - most
|
||||
often a PC's machine link. Nothing here blocks the import; fix what
|
||||
you can and re-import to complete the links.
|
||||
</p>
|
||||
<ul>
|
||||
<li v-for="(w, i) in allWarnings" :key="i">
|
||||
<code>{{ w.filename }}</code>
|
||||
line {{ w.line }}<template v-if="w.column">, column
|
||||
<code>{{ w.column }}</code></template>:
|
||||
{{ w.reason }}<template v-if="w.action"> - <em>{{ w.action }}</em></template>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="allProblems.length" class="problems">
|
||||
<h4>Problems to fix</h4>
|
||||
<ul>
|
||||
<li v-for="(p, i) in allProblems" :key="i">
|
||||
<code>{{ p.filename }}</code>
|
||||
line {{ p.line }}<template v-if="p.column">, column
|
||||
<code>{{ p.column }}</code></template>: {{ p.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p v-if="report.skipped && report.skipped.length" class="muted warn">
|
||||
Skipped (not importable): {{ report.skipped.join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="section-title">CSV Import Templates</h3>
|
||||
<p class="muted">
|
||||
These import templates are generated from the live ShopDB schema. Each
|
||||
file has a header row plus a small worked example - commented rows you
|
||||
can uncomment and edit. The examples are internally consistent across
|
||||
every file (the same locations, vendors, and types are referenced), so
|
||||
you can download the full set, keep it as a starter workbook, and fill
|
||||
in your own assets right away. Import them in the order below - a table
|
||||
never references something that has not been loaded yet. Foreign keys
|
||||
accept the referenced row's name or its numeric id, and re-importing a
|
||||
file updates matching rows instead of duplicating them.
|
||||
</p>
|
||||
|
||||
<p v-if="templatesLoading" class="muted">Loading...</p>
|
||||
<ol v-else class="templates">
|
||||
<li v-for="t in templates" :key="t.table">
|
||||
<div class="template-row">
|
||||
<code>{{ t.filename }}</code>
|
||||
<button class="btn btn-secondary btn-sm" @click="downloadTemplate(t)">
|
||||
Download Template
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p v-if="templatesError" class="error">{{ templatesError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="section-title">PC to Machine Relationships</h3>
|
||||
<p class="muted">
|
||||
A PC that drives a machine (a CMM PC, a DNC/eDNC host) is linked to that
|
||||
machine so the two show up connected across ShopDB. You can build that
|
||||
link two ways. The Asset Collector builds it automatically: the script
|
||||
reads the four-digit workstation number from the PC it runs on, reports
|
||||
it, and ShopDB matches that number to a machine's
|
||||
<code>assetnumber</code>.
|
||||
</p>
|
||||
<p class="muted">
|
||||
Or build it straight from the spreadsheet: put the machine's
|
||||
<code>assetnumber</code> in the <code>machineassetnumber</code> column on
|
||||
<code>computers.csv</code>, and the import creates the link for you.
|
||||
</p>
|
||||
<p class="muted">
|
||||
Either way, load your machines first (so their
|
||||
<code>assetnumber</code> values exist), then the PCs. A machine number
|
||||
that does not match an existing machine - or one already driven by
|
||||
another PC - is reported as a warning and skipped, not guessed, so
|
||||
nothing links to the wrong asset. The PC still imports; fix the number
|
||||
and re-import to complete the link.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { settingsApi, computersApi } from '@/api'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const LOCATION_KEY = 'asset_import_csv_location'
|
||||
|
||||
const location = ref('')
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
const error = ref('')
|
||||
const downloadingScript = ref(false)
|
||||
|
||||
const templates = ref([])
|
||||
const templatesLoading = ref(false)
|
||||
const templatesError = ref('')
|
||||
|
||||
const fileInput = ref(null)
|
||||
const selectedFiles = ref([])
|
||||
const previewing = ref(false)
|
||||
const committing = ref(false)
|
||||
const importError = ref('')
|
||||
const report = ref(null)
|
||||
|
||||
// Which format the selected files are in. 'native' means the uploads are
|
||||
// already canonical per-table CSVs; 'azure' and 'thor' route through a
|
||||
// server-side adapter that transforms the foreign export first.
|
||||
const IMPORT_SOURCES = [
|
||||
{ key: 'native', label: 'ShopDB native', description: 'The canonical per-table CSVs below (assets, computers, vendors, ...).' },
|
||||
{ key: 'azure', label: 'Azure / Intune device group', description: 'A device-group member export from Entra. Creates PCs by hostname and stores the deviceId.' },
|
||||
{ key: 'thor', label: 'Thor PC inventory', description: 'A Thor inventory export. Maps hostname, vendor, model, OS, user, and location.' }
|
||||
]
|
||||
const importSource = ref('native')
|
||||
// Optional computer type stamped onto every computer an Azure import creates or
|
||||
// updates, so a SHP group becomes Shopfloor PCs and a BUS group Inspection PCs.
|
||||
const azureComputerType = ref('')
|
||||
const customComputerType = ref('')
|
||||
const customTypeError = ref('')
|
||||
const addingType = ref(false)
|
||||
const computerTypes = ref([])
|
||||
const azureDeviceCategory = ref('')
|
||||
|
||||
// Commit is allowed only after a clean preview of the CURRENT selection: a
|
||||
// report with no problems and no request-level error that has not already been
|
||||
// committed.
|
||||
const canCommit = computed(() => {
|
||||
if (!report.value || committing.value || previewing.value) return false
|
||||
if (report.value.committed) return false
|
||||
if (report.value.request_error) return false
|
||||
return report.value.problem_count === 0
|
||||
})
|
||||
|
||||
const allProblems = computed(() => {
|
||||
if (!report.value?.files) return []
|
||||
const out = []
|
||||
for (const file of report.value.files) {
|
||||
for (const problem of file.problems) {
|
||||
out.push({ filename: file.filename, ...problem })
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const allWarnings = computed(() => {
|
||||
if (!report.value?.files) return []
|
||||
const out = []
|
||||
for (const file of report.value.files) {
|
||||
for (const warning of (file.warnings || [])) {
|
||||
out.push({ filename: file.filename, ...warning })
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const allReconciliations = computed(() => {
|
||||
if (!report.value?.files) return []
|
||||
const out = []
|
||||
for (const file of report.value.files) {
|
||||
for (const event of (file.reconciliations || [])) {
|
||||
out.push({ filename: file.filename, ...event })
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
async function loadLocation() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await settingsApi.get(LOCATION_KEY).catch(nullOn404)
|
||||
location.value = readValue(response) || ''
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not load the CSV location.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function nullOn404(err) {
|
||||
if (err.response?.status === 404) return null
|
||||
throw err
|
||||
}
|
||||
|
||||
function readValue(response) {
|
||||
if (!response) return ''
|
||||
const setting = response.data?.data || response.data
|
||||
return setting?.value ?? ''
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
error.value = ''
|
||||
try {
|
||||
await settingsApi.update(LOCATION_KEY, location.value)
|
||||
saved.value = true
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Save failed.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function saveBlob(data, filename) {
|
||||
const url = window.URL.createObjectURL(new Blob([data]))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function downloadScript() {
|
||||
downloadingScript.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await settingsApi.downloadCollectorScript()
|
||||
saveBlob(response.data, 'Collect-ShopDB.ps1')
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Script download failed.')
|
||||
} finally {
|
||||
downloadingScript.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTemplates() {
|
||||
templatesLoading.value = true
|
||||
templatesError.value = ''
|
||||
try {
|
||||
const response = await settingsApi.listImportTemplates()
|
||||
const data = response.data?.data || response.data || {}
|
||||
templates.value = data.tables || []
|
||||
} catch (err) {
|
||||
templatesError.value = apiError(err, 'Could not list import templates.')
|
||||
} finally {
|
||||
templatesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadTemplate(t) {
|
||||
templatesError.value = ''
|
||||
try {
|
||||
const response = await settingsApi.downloadImportTemplate(t.table)
|
||||
saveBlob(response.data, t.filename)
|
||||
} catch (err) {
|
||||
templatesError.value = apiError(err, 'Template download failed.')
|
||||
}
|
||||
}
|
||||
|
||||
function onFilesPicked(event) {
|
||||
selectedFiles.value = Array.from(event.target.files || [])
|
||||
// A new selection invalidates any previous preview so a stale clean report
|
||||
// cannot be committed against different files.
|
||||
report.value = null
|
||||
importError.value = ''
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedFiles.value = []
|
||||
report.value = null
|
||||
importError.value = ''
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
|
||||
function selectSource(key) {
|
||||
importSource.value = key
|
||||
// Switching format invalidates a preview built against the previous adapter.
|
||||
report.value = null
|
||||
importError.value = ''
|
||||
}
|
||||
|
||||
async function runImport(commit) {
|
||||
importError.value = ''
|
||||
try {
|
||||
const options = { source: importSource.value }
|
||||
if (importSource.value === 'azure') {
|
||||
if (azureComputerType.value && azureComputerType.value !== '__custom__') {
|
||||
options.computertype = azureComputerType.value.trim()
|
||||
}
|
||||
if (azureDeviceCategory.value.trim()) {
|
||||
options.category = azureDeviceCategory.value.trim()
|
||||
}
|
||||
}
|
||||
const response = await settingsApi.importAssetCsvs(selectedFiles.value, commit, options)
|
||||
report.value = response.data?.data || response.data || null
|
||||
} catch (err) {
|
||||
importError.value = apiError(err, commit ? 'Import failed.' : 'Preview failed.')
|
||||
report.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function preview() {
|
||||
previewing.value = true
|
||||
try {
|
||||
await runImport(false)
|
||||
} finally {
|
||||
previewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
committing.value = true
|
||||
try {
|
||||
await runImport(true)
|
||||
} finally {
|
||||
committing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addComputerType() {
|
||||
const name = customComputerType.value.trim()
|
||||
if (!name) return
|
||||
customTypeError.value = ''
|
||||
addingType.value = true
|
||||
try {
|
||||
const response = await computersApi.types.create({ computertype: name })
|
||||
const created = response.data?.data || response.data
|
||||
await loadComputerTypes()
|
||||
// Select the freshly added type by its name.
|
||||
azureComputerType.value = created?.computertype || name
|
||||
customComputerType.value = ''
|
||||
} catch (err) {
|
||||
customTypeError.value = apiError(err, 'Could not add the computer type.')
|
||||
} finally {
|
||||
addingType.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadComputerTypes() {
|
||||
try {
|
||||
// listAll returns the rows themselves, already paged through.
|
||||
computerTypes.value = await computersApi.types.listAll()
|
||||
} catch {
|
||||
computerTypes.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadLocation()
|
||||
await loadTemplates()
|
||||
await loadComputerTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lead {
|
||||
margin-top: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
.methods {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.method {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.method.is-primary {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.method-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.method-head h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.method p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.badge {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.section-title {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 640px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.field > span {
|
||||
font-weight: 600;
|
||||
}
|
||||
.field input[type='text'] {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
.field select {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.custom-type-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.custom-type-row input {
|
||||
flex: 1;
|
||||
}
|
||||
.source-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.source-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.source-card:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.source-card.active {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 1px var(--primary) inset;
|
||||
}
|
||||
.source-card-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.source-card-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.source-help {
|
||||
margin-top: 12px;
|
||||
border-left: 3px solid var(--border);
|
||||
padding-left: 12px;
|
||||
}
|
||||
.source-help .instructions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.source-help .instructions summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.source-help .instructions ol {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 20px;
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.source-help .instructions li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.templates {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.templates li {
|
||||
margin: 6px 0;
|
||||
}
|
||||
.template-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.template-row code {
|
||||
min-width: 200px;
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.muted.warn {
|
||||
color: var(--warning, #c47f00);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.import-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.report {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.report-banner {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.report-banner.ok {
|
||||
background: var(--success-bg, rgba(40, 160, 80, 0.15));
|
||||
color: var(--success, #2e7d32);
|
||||
}
|
||||
.report-banner.info {
|
||||
background: var(--info-bg, rgba(60, 120, 200, 0.15));
|
||||
color: var(--text);
|
||||
}
|
||||
.report-banner.warn {
|
||||
background: var(--warning-bg, rgba(196, 127, 0, 0.15));
|
||||
color: var(--warning, #c47f00);
|
||||
}
|
||||
.report-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.report-table th,
|
||||
.report-table td {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.report-table td.has-problems {
|
||||
color: var(--danger);
|
||||
font-weight: 700;
|
||||
}
|
||||
.report-table td.has-reconcile {
|
||||
color: var(--warning, #c47f00);
|
||||
font-weight: 700;
|
||||
}
|
||||
.report-banner.reconcile {
|
||||
background: var(--warning-bg, rgba(196, 127, 0, 0.15));
|
||||
color: var(--warning, #c47f00);
|
||||
}
|
||||
.reconciliations {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.reconciliations h4 {
|
||||
margin: 0 0 6px;
|
||||
color: var(--warning, #c47f00);
|
||||
}
|
||||
.reconciliations ul {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.reconciliations li {
|
||||
margin: 4px 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.report-table td.has-warnings {
|
||||
color: var(--amber, #d18b16);
|
||||
font-weight: 700;
|
||||
}
|
||||
.report-banner.amber {
|
||||
background: var(--amber-bg, rgba(209, 139, 22, 0.15));
|
||||
color: var(--amber, #d18b16);
|
||||
}
|
||||
.warnings {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.warnings h4 {
|
||||
margin: 0 0 6px;
|
||||
color: var(--amber, #d18b16);
|
||||
}
|
||||
.warnings ul {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.warnings li {
|
||||
margin: 4px 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.warnings li em {
|
||||
color: var(--text-muted, #9aa4b2);
|
||||
font-style: normal;
|
||||
}
|
||||
.problems {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.problems h4 {
|
||||
margin: 0 0 6px;
|
||||
color: var(--danger);
|
||||
}
|
||||
.problems ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.problems li {
|
||||
margin: 4px 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -119,7 +119,8 @@ const LABELS = {
|
||||
usb_directory_mode: 'USB Directory Mode',
|
||||
site_timezone: 'Site Timezone',
|
||||
audit_retentiondays: 'Audit Log Retention (days)',
|
||||
setup_complete: 'Setup Complete'
|
||||
setup_complete: 'Setup Complete',
|
||||
asset_import_csv_location: 'Asset Import CSV Location'
|
||||
}
|
||||
function prettyLabel(key) {
|
||||
return LABELS[key] || key
|
||||
@@ -228,7 +229,10 @@ async function load() {
|
||||
try {
|
||||
const response = await settingsApi.list()
|
||||
const all = response.data?.data || response.data || []
|
||||
items.value = all.filter(s => s.category === 'site')
|
||||
// The Asset Collector CSV location is managed on the dedicated Import Site
|
||||
// Assets page.
|
||||
const importKeys = ['asset_import_csv_location']
|
||||
items.value = all.filter(s => s.category === 'site' && !importKeys.includes(s.key))
|
||||
} catch (err) {
|
||||
console.error('Error loading site settings:', err)
|
||||
error.value = 'Could not load settings.'
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Order: site identity + the reference-data catalogs users touch daily come
|
||||
// first, then the platform/system groups (integrations, communication, search,
|
||||
// plugins, access) cluster together at the end.
|
||||
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key } from 'lucide-vue-next'
|
||||
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key, Upload } from 'lucide-vue-next'
|
||||
|
||||
export const settingsGroups = [
|
||||
{
|
||||
@@ -13,6 +13,7 @@ export const settingsGroups = [
|
||||
{ to: '/settings/site', icon: Home, title: 'Site & Facility', description: 'Site URL/FQDN, facility name, PC access domain, employee-ID pattern, printer hostname template' },
|
||||
{ to: '/settings/floormap', icon: MapPin, title: 'Floor Map', description: 'Facility floor-plan blueprint image and pixel dimensions (width, height) for the site map' },
|
||||
{ to: '/settings/branding', icon: Palette, title: 'Branding', description: 'Site, QR, and badge logos, favicon, and brand primary, accent, sidebar, and theme colors' },
|
||||
{ to: '/settings/importsiteassets', icon: Upload, title: 'Import Site Assets', description: 'Seed Machines, PCs, and Applications via the Asset Collector script, existing databases, or Intune/Azure exports; set the shared CSV collection location and download collected files' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user