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:
cproudlock
2026-08-28 16:17:17 -04:00
parent 93ca7587ce
commit 784964c907
149 changed files with 17545 additions and 627 deletions

View File

@@ -0,0 +1,223 @@
<template>
<div>
<div class="page-header">
<h2>Business Policies</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Business Policy</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Priority</th>
<th>Name</th>
<th>Description</th>
<th>Configuration policies</th>
<th>Source</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="p in policies" :key="p.businesspolicyid">
<td>{{ p.priority }}</td>
<td>{{ p.name }}</td>
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
<td>
<span v-for="it in p.items" :key="it.businesspolicyitemid" class="badge" style="margin-right:4px;">
{{ it.name }}
</span>
<span v-if="!p.items || p.items.length === 0" style="color: var(--text-light);">none</span>
</td>
<td>
<span class="badge" :class="p.source === 'local' ? 'badge-secondary' : 'badge-warning'">
{{ p.source }}
</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" :disabled="p.source !== 'local'" @click="openModal(p)">Edit</button>
<button class="btn btn-danger btn-sm" :disabled="p.source !== 'local'" @click="remove(p)">Delete</button>
</td>
</tr>
<tr v-if="policies.length === 0">
<td colspan="6" style="text-align:center; color: var(--text-light);">No business policies</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay">
<div class="modal modal-lg">
<div class="modal-header">
<h3>{{ editing ? 'Edit Business Policy' : 'Add Business Policy' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Name *</label>
<input v-model="form.name" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Priority <span class="hint">(lower runs first; first match wins per action)</span></label>
<input v-model.number="form.priority" type="number" class="form-control" />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="2"></textarea>
</div>
<div class="form-group">
<label>Configuration policies <span class="hint">(evaluated top to bottom)</span></label>
<div v-for="(it, index) in form.items" :key="index" class="item-row">
<span class="order-badge">{{ index + 1 }}</span>
<select v-model.number="it.configurationpolicyid" class="form-control">
<option :value="undefined" disabled>Select configuration policy...</option>
<option v-for="cp in configPolicies" :key="cp.configurationpolicyid" :value="cp.configurationpolicyid">
{{ cp.name }}
</option>
</select>
<button type="button" class="btn btn-secondary btn-sm" :disabled="index === 0" @click="moveUp(index)">Up</button>
<button type="button" class="btn btn-secondary btn-sm" :disabled="index === form.items.length - 1" @click="moveDown(index)">Down</button>
<button type="button" class="btn btn-danger btn-sm" @click="form.items.splice(index, 1)">Remove</button>
</div>
<button type="button" class="btn btn-secondary btn-sm" @click="addItem">+ Add configuration policy</button>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { computersApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const toast = useToast()
const policies = ref([])
const configPolicies = ref([])
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref(emptyForm())
function emptyForm() {
return { name: '', description: '', priority: 100, items: [] }
}
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const [biz, config] = await Promise.all([
computersApi.policies.business.list(),
computersApi.policies.configuration.list()
])
policies.value = biz.data.data || []
configPolicies.value = config.data.data || []
} catch (err) {
console.error('Error loading business policies:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
error.value = ''
if (item) {
form.value = {
name: item.name || '',
description: item.description || '',
priority: item.priority ?? 100,
items: (item.items || [])
.slice()
.sort((a, b) => a.sortorder - b.sortorder)
.map(it => ({ configurationpolicyid: it.configurationpolicyid }))
}
} else {
form.value = emptyForm()
}
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
function addItem() {
form.value.items.push({ configurationpolicyid: undefined })
}
function moveUp(index) {
const items = form.value.items
;[items[index - 1], items[index]] = [items[index], items[index - 1]]
}
function moveDown(index) {
const items = form.value.items
;[items[index + 1], items[index]] = [items[index], items[index + 1]]
}
async function save() {
saving.value = true
error.value = ''
try {
const payload = {
name: form.value.name,
description: form.value.description,
priority: form.value.priority,
items: form.value.items.map((it, order) => ({
configurationpolicyid: it.configurationpolicyid,
sortorder: order
}))
}
if (editing.value) {
await computersApi.policies.business.update(editing.value.businesspolicyid, payload)
} else {
await computersApi.policies.business.create(payload)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save business policy')
} finally {
saving.value = false
}
}
async function remove(p) {
if (!confirm(`Delete business policy "${p.name}"?`)) return
try {
await computersApi.policies.business.remove(p.businesspolicyid)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
</script>
<style scoped>
.item-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.order-badge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 24px; height: 24px; border-radius: 12px;
background: var(--border-color, #444);
}
</style>

View File

@@ -0,0 +1,223 @@
<template>
<div>
<div class="page-header">
<h2>Configuration Policies</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Policy</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Actions in policy</th>
<th>Source</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="p in policies" :key="p.configurationpolicyid">
<td>{{ p.name }}</td>
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
<td>
<span v-for="a in p.actions" :key="a.action" class="badge" style="margin-right:4px;">
{{ a.action }} ({{ a.mode }})
</span>
<span v-if="!p.actions || p.actions.length === 0" style="color: var(--text-light);">none</span>
</td>
<td>
<span class="badge" :class="p.source === 'local' ? 'badge-secondary' : 'badge-warning'">
{{ p.source }}
</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" :disabled="p.source !== 'local'" @click="openModal(p)">Edit</button>
<button class="btn btn-danger btn-sm" :disabled="p.source !== 'local'" @click="remove(p)">Delete</button>
</td>
</tr>
<tr v-if="policies.length === 0">
<td colspan="5" style="text-align:center; color: var(--text-light);">No configuration policies</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay">
<div class="modal modal-lg">
<div class="modal-header">
<h3>{{ editing ? 'Edit Configuration Policy' : 'Add Configuration Policy' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Name *</label>
<input v-model="form.name" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="2"></textarea>
</div>
<div class="form-group">
<label>Scope <span class="hint">(which endpoints this applies to)</span></label>
<div class="scope-builder">
<ScopeNodeEditor :node="form.scopejson" :pctypes="pctypes" :apps="apps" :removable="false" />
</div>
</div>
<div class="form-group">
<label>Actions</label>
<div v-for="(a, index) in form.actions" :key="index" class="action-row">
<select v-model="a.action" class="form-control">
<option :value="''" disabled>Select action...</option>
<option v-for="opt in actionCatalog" :key="opt.action" :value="opt.action">
{{ opt.label || opt.action }}
</option>
</select>
<select v-model="a.mode" class="form-control mode-select">
<option value="gate">Allow (gate)</option>
<option value="desired">Desired state</option>
</select>
<button type="button" class="btn btn-danger btn-sm" @click="form.actions.splice(index, 1)">Remove</button>
</div>
<button type="button" class="btn btn-secondary btn-sm" @click="addAction">+ Add action</button>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { computersApi, applicationsApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
import ScopeNodeEditor from './ScopeNodeEditor.vue'
const toast = useToast()
const policies = ref([])
const pctypes = ref([])
const apps = ref([])
const actionCatalog = ref([])
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref(emptyForm())
function emptyForm() {
return { name: '', description: '', scopejson: { type: 'true' }, actions: [] }
}
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const [pol, types, appList, actions] = await Promise.all([
computersApi.policies.configuration.list(),
computersApi.types.list({ perpage: 200 }),
applicationsApi.listAll({ perpage: 200 }),
computersApi.policies.actions()
])
policies.value = pol.data.data || []
pctypes.value = types.data.data || []
// applicationsApi.listAll resolves to a plain array (fetchAllPages), not an
// axios response, so read it directly. Reading .data on the array throws and
// rejects the whole Promise.all, blanking every dropdown.
apps.value = Array.isArray(appList) ? appList : (appList.data?.data || appList.data || [])
actionCatalog.value = actions.data.data || []
} catch (err) {
console.error('Error loading configuration policies:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
error.value = ''
if (item) {
form.value = {
name: item.name || '',
description: item.description || '',
scopejson: item.scopejson ? JSON.parse(JSON.stringify(item.scopejson)) : { type: 'true' },
actions: (item.actions || []).map(a => ({
action: a.action, mode: a.mode || 'gate',
desiredstate: a.desiredstate, params: a.params, intervalminutes: a.intervalminutes
}))
}
} else {
form.value = emptyForm()
}
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
function addAction() {
form.value.actions.push({ action: '', mode: 'gate' })
}
async function save() {
saving.value = true
error.value = ''
try {
const payload = {
name: form.value.name,
description: form.value.description,
scopejson: form.value.scopejson,
actions: form.value.actions
}
if (editing.value) {
await computersApi.policies.configuration.update(editing.value.configurationpolicyid, payload)
} else {
await computersApi.policies.configuration.create(payload)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save policy')
} finally {
saving.value = false
}
}
async function remove(p) {
if (!confirm(`Delete configuration policy "${p.name}"?`)) return
try {
await computersApi.policies.configuration.remove(p.configurationpolicyid)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
</script>
<style scoped>
.scope-builder {
padding: 10px;
border: 1px solid var(--border-color, #444);
border-radius: 4px;
}
.action-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.mode-select { max-width: 160px; }
</style>

View File

@@ -3,6 +3,8 @@
<div class="page-header">
<h2>Computer Details</h2>
<div class="header-actions">
<button v-if="canCommand" @click="refreshStatus" class="btn btn-secondary" :disabled="restarting || refreshing">{{ refreshing ? 'Refreshing...' : 'Refresh Status' }}</button>
<button v-if="canRestart" @click="confirmRestart" class="btn btn-warning" :disabled="restarting">Restart Computer</button>
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
@@ -153,6 +155,28 @@
</div>
</div>
</div>
<!-- Entra / Azure device info -->
<div
v-if="computer.computer?.azuredeviceid || computer.computer?.azureobjectid || computer.computer?.azuredevicecategory"
class="section-card"
>
<h3 class="section-title">Entra Device Info</h3>
<div class="info-list">
<div v-if="computer.computer?.azuredeviceid" class="info-row">
<span class="info-label">Device ID</span>
<span class="info-value">{{ computer.computer.azuredeviceid }}</span>
</div>
<div v-if="computer.computer?.azureobjectid" class="info-row">
<span class="info-label">Object ID</span>
<span class="info-value">{{ computer.computer.azureobjectid }}</span>
</div>
<div v-if="computer.computer?.azuredevicecategory" class="info-row">
<span class="info-label">Device Category</span>
<span class="info-value">{{ computer.computer.azuredevicecategory }}</span>
</div>
</div>
</div>
</div>
<!-- Right Column -->
@@ -226,7 +250,7 @@
<CustomFieldsSection :assetid="computer.assetid" />
<!-- Warranty -->
<PluginAssetPanels :assetid="computer.assetid" />
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" />
<!-- All relationships (controls, defaultprinter, ...) -->
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
@@ -253,10 +277,10 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { useRoute, useRouter } from 'vue-router'
import { computersApi, applicationsApi } from '@/api'
import { computersApi, applicationsApi, endpointsApi } from '@/api'
import { withBase } from '@/utils/basePath'
import { useWarrantyBadge } from '@/composables/warrantyBadge'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
@@ -267,15 +291,26 @@ import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const authStore = useAuthStore()
const { isEnabled } = useIdentifierFlags()
const loading = ref(true)
const computer = ref(null)
const installedApps = ref([])
const assetPanels = ref(null)
const restarting = ref(false)
const refreshing = ref(false)
// Header endpoint controls only make sense to users who can act on the agent.
// Restart is gated separately because it is a destructive grant (endpoints.restart)
// enforced again server-side; hiding the button is convenience, not the guard.
const canCommand = computed(() => authStore.hasPermission('endpoints.command'))
const canRestart = computed(() => authStore.hasPermission('endpoints.restart'))
// Warranty fetch + hero badge (shared across asset detail pages).
const { heroWarranty, warrantyDate: formatWarrantyDate } = useWarrantyBadge(() => computer.value?.assetid)
@@ -316,6 +351,108 @@ function formatDate(dateStr) {
return new Date(dateStr).toLocaleString()
}
// Force a live telemetry cycle rather than re-reading the cached snapshot.
// Enqueues collect_now (the agent reports off-cadence), waits for the agent to
// finish it, then re-fetches the panels so the cards reflect the endpoint's
// real current state (e.g. a process the operator just closed).
async function refreshStatus() {
if (refreshing.value) return
refreshing.value = true
try {
// Baseline the current telemetry timestamp BEFORE forcing a collect. A
// command reaching 'succeeded' only means the agent ran collect_now, not
// that it has posted the fresh snapshot yet, so we additionally wait for
// lastreportedat to move past this baseline before re-rendering the panels.
const baseline = await currentTelemetryStamp()
const response = await endpointsApi.enqueueCommand(computer.value.assetid, 'collect_now')
const commandId = response?.data?.data?.endpointcommandid
const outcome = await waitForCommand(commandId)
const fresh = outcome === 'succeeded'
? await waitForFreshTelemetry(baseline)
: false
await assetPanels.value?.refresh()
if (outcome === 'timeout') {
toast.info('Refresh queued; the endpoint agent may be offline or busy')
} else if (outcome === 'failed') {
toast.error('The endpoint agent could not refresh telemetry')
} else if (!fresh) {
toast.info('Refresh sent; the endpoint has not reported new data yet')
} else {
toast.success('Status refreshed')
}
} catch (error) {
toast.error(apiError(error, 'Failed to refresh status'))
} finally {
refreshing.value = false
}
}
// Read the endpoint's current lastreportedat (ISO string or null). Used as the
// freshness baseline; a null baseline means the box has never reported, so any
// non-null stamp later counts as fresh.
async function currentTelemetryStamp() {
try {
const response = await endpointsApi.telemetryMeta(computer.value.assetid)
return response?.data?.data?.lastreportedat || null
} catch {
return null
}
}
// Poll lastreportedat until it advances past the baseline (proof a new snapshot
// landed) or the attempt budget runs out. Returns true only on a confirmed
// newer stamp; a bounded false lets the caller still refresh and message that
// the box has not reported yet, rather than hanging the button on an idle agent.
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
}
// Poll the endpoint's recent commands until the given command reaches a terminal
// state or we give up. Returns 'succeeded' | 'failed' | 'timeout'. The agent
// long-polls, so a live box turns this around in a couple of seconds; an offline
// box lets it time out cleanly rather than hanging the button forever.
async function waitForCommand(commandId, { attempts = 15, delayMs = 1000 } = {}) {
if (!commandId) return 'timeout'
for (let i = 0; i < attempts; i += 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
try {
const response = await endpointsApi.listCommands(computer.value.assetid)
const command = (response?.data?.data || []).find(
(row) => row.endpointcommandid === commandId)
if (command && command.status === 'succeeded') return 'succeeded'
if (command && command.status === 'failed') return 'failed'
} catch {
// Transient poll failure: keep trying until the attempt budget runs out.
}
}
return 'timeout'
}
// Destructive: queues a system_restart that reboots the PC immediately
// (shutdown /r /t 0 on the agent). The confirm gate is the last stop before an
// unrecoverable action; the server re-checks endpoints.restart regardless.
async function confirmRestart() {
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
if (!confirm(`Restart ${label} now?\n\n`
+ 'The computer reboots immediately with no grace period. '
+ 'Any unsaved work on it will be lost.')) return
restarting.value = true
try {
await endpointsApi.enqueueCommand(computer.value.assetid, 'system_restart')
toast.success(`Restart queued for ${label}`)
} catch (error) {
toast.error(apiError(error, 'Failed to queue restart'))
} finally {
restarting.value = false
}
}
// Retire, not delete: the server deactivates the asset, so the PC
// leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true.

View File

@@ -166,7 +166,7 @@
>
<option value="">Select vendor...</option>
<option
v-for="v in vendors"
v-for="v in filteredVendors"
:key="v.vendorid"
:value="v.vendorid"
>
@@ -223,6 +223,17 @@
</div>
</div>
<div class="form-group">
<label for="azuredeviceid">Azure Device ID</label>
<input
id="azuredeviceid"
v-model="form.azuredeviceid"
type="text"
class="form-control"
placeholder="Entra/Azure deviceId GUID (set by Azure imports)"
/>
</div>
<div class="form-group">
<label>Remote Access Protocols</label>
<div class="protocol-list">
@@ -383,6 +394,7 @@ const form = ref({
locationid: '',
osid: '',
loggedinuser: '',
azuredeviceid: '',
accessmethods: [],
notes: '',
mapx: null,
@@ -432,12 +444,42 @@ watch(() => form.value.serialnumber, (serial) => {
}
})
// Filter models by selected vendor and PC type
// Ensure a saved model id is present in models.value even when the filtered
// list (category PC) excluded it because it has no modeltype. Fetches the
// single row by id and appends it; a no-op when already present or blank.
async function ensureModelLoaded(modelId) {
if (!modelId) return
if (models.value.some(m => m.modelnumberid === modelId)) return
try {
const response = await modelsApi.get(modelId)
const model = response.data.data
if (model) models.value = [...models.value, model]
} catch (e) {
// Leave it absent; the select shows blank but we did not invent a value.
}
}
// Filter models by selected vendor and PC type. Keep the record's own saved
// model even if it is untyped or a different vendor, so editing an existing PC
// never renders a blank select that clears the field on save.
const filteredModels = computed(() => {
// filter by vendor only (PC type now maps to computertypeid, a different id
// space than a model's machinetypeid)
const savedId = form.value.modelnumberid
if (!form.value.vendorid) return models.value
return models.value.filter(m => m.vendorid === form.value.vendorid)
return models.value.filter(
m => m.vendorid === form.value.vendorid || m.modelnumberid === savedId)
})
// Only offer vendors that actually have PC-category models. models is already
// scoped to category PC at load, so its vendorids are the PC vendor set. Keep
// the record's own vendor even if it falls outside that set, so editing an
// existing PC never silently drops its saved vendor.
const filteredVendors = computed(() => {
const pcVendorIds = new Set(models.value.map(m => m.vendorid))
return vendors.value.filter(
v => pcVendorIds.has(v.vendorid) || v.vendorid === form.value.vendorid
)
})
onMounted(async () => {
@@ -448,8 +490,8 @@ onMounted(async () => {
computersApi.types.list({ perpage: 100 }),
assetsApi.statuses.list(),
vendorsApi.list({ perpage: 100 }),
modelsApi.listAll(), // backend caps perpage at 100; page through all
locationsApi.list({ perpage: 100 }),
modelsApi.listAll({ category: 'PC' }), // backend caps perpage at 100; page through all
locationsApi.listAll(), // backend caps perpage at 100; page through all
operatingsystemsApi.list({ perpage: 100 }),
computersApi.protocols.list(),
// Handles its own failure: a site without printers still gets a form.
@@ -459,7 +501,7 @@ onMounted(async () => {
statuses.value = statusRes.data.data || []
vendors.value = vendorRes.data.data || []
models.value = allModels
locations.value = locRes.data.data || []
locations.value = locRes || []
operatingsystems.value = osRes.data.data || []
protocols.value = protoRes.data.data || []
@@ -486,6 +528,7 @@ onMounted(async () => {
locationid: pc.locationid || '',
osid: ext.osid || '',
loggedinuser: ext.loggedinuser || '',
azuredeviceid: ext.azuredeviceid || '',
accessmethods: (pc.accessmethods || []).map(a => ({
protocolid: a.protocolid,
portoverride: a.portoverride ?? null
@@ -496,6 +539,12 @@ onMounted(async () => {
levelid: pc.levelid ?? null,
ipaddress: primaryComm?.ipaddress || ''
}
// The model list is loaded filtered to category PC, which the backend
// inner-joins against ModelType - so an untyped model is absent. If this
// PC's saved model is one of those rows, fetch it by id and merge it in,
// or its dropdown would render blank and saving would clear the field.
await ensureModelLoaded(form.value.modelnumberid)
}
} catch (err) {
console.error('Error loading data:', err)
@@ -555,6 +604,7 @@ async function savePC() {
locationid: form.value.locationid || null,
osid: form.value.osid || null,
loggedinuser: form.value.loggedinuser || null,
azuredeviceid: form.value.azuredeviceid || null,
accessmethods: form.value.accessmethods,
notes: form.value.notes || null,
ipaddress: form.value.ipaddress || null,

View File

@@ -0,0 +1,106 @@
<template>
<div class="scope-node">
<div class="scope-node-row">
<select v-model="node.type" class="form-control scope-type" @change="onTypeChange">
<option value="true">Matches everything</option>
<option value="pctype">PC type is</option>
<option value="app">App is installed</option>
<option value="and">ALL of (AND)</option>
<option value="or">ANY of (OR)</option>
<option value="not">NOT</option>
</select>
<select v-if="node.type === 'pctype'" v-model.number="node.id" class="form-control">
<option :value="undefined" disabled>Select PC type...</option>
<option v-for="pt in pctypes" :key="pt.computertypeid" :value="pt.computertypeid">
{{ pt.computertype }}
</option>
</select>
<select v-if="node.type === 'app'" v-model.number="node.id" class="form-control">
<option :value="undefined" disabled>Select app...</option>
<option v-for="app in apps" :key="app.appid" :value="app.appid">
{{ app.appname }}
</option>
</select>
<button v-if="removable" type="button" class="btn btn-danger btn-sm" @click="$emit('remove')">
Remove
</button>
</div>
<div v-if="node.type === 'and' || node.type === 'or'" class="scope-children">
<ScopeNodeEditor
v-for="(child, index) in node.nodes"
:key="index"
:node="child"
:pctypes="pctypes"
:apps="apps"
:removable="true"
@remove="removeChild(index)"
/>
<button type="button" class="btn btn-secondary btn-sm" @click="addChild">+ Add condition</button>
</div>
<div v-if="node.type === 'not'" class="scope-children">
<ScopeNodeEditor
:node="node.node"
:pctypes="pctypes"
:apps="apps"
:removable="false"
/>
</div>
</div>
</template>
<script setup>
const props = defineProps({
node: { type: Object, required: true },
pctypes: { type: Array, default: () => [] },
apps: { type: Array, default: () => [] },
removable: { type: Boolean, default: false }
})
defineEmits(['remove'])
function onTypeChange() {
const node = props.node
// Reset payload keys so a switched node never carries stale fields from its
// previous type. Keep only what the new type needs.
delete node.id
delete node.nodes
delete node.node
if (node.type === 'and' || node.type === 'or') {
node.nodes = [{ type: 'true' }]
} else if (node.type === 'not') {
node.node = { type: 'true' }
}
}
function addChild() {
props.node.nodes.push({ type: 'true' })
}
function removeChild(index) {
props.node.nodes.splice(index, 1)
if (props.node.nodes.length === 0) {
props.node.nodes.push({ type: 'true' })
}
}
</script>
<script>
export default { name: 'ScopeNodeEditor' }
</script>
<style scoped>
.scope-node { margin: 4px 0; }
.scope-node-row { display: flex; gap: 8px; align-items: center; }
.scope-type { max-width: 220px; }
.scope-children {
margin-left: 24px;
padding-left: 12px;
border-left: 2px solid var(--border-color, #444);
margin-top: 6px;
}
</style>