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:
223
plugins/computers/frontend/views/BusinessPoliciesList.vue
Normal file
223
plugins/computers/frontend/views/BusinessPoliciesList.vue
Normal 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>
|
||||
223
plugins/computers/frontend/views/ConfigurationPoliciesList.vue
Normal file
223
plugins/computers/frontend/views/ConfigurationPoliciesList.vue
Normal 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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
106
plugins/computers/frontend/views/ScopeNodeEditor.vue
Normal file
106
plugins/computers/frontend/views/ScopeNodeEditor.vue
Normal 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>
|
||||
Reference in New Issue
Block a user