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.
367 lines
15 KiB
Vue
367 lines
15 KiB
Vue
<template>
|
|
<!-- Generic renderer for ADR-010 get_asset_panels (Path A). A plugin declares
|
|
a panel as JSON (title, endpoint, render mode, field map); this renders it
|
|
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">
|
|
<!-- 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">
|
|
<template v-if="rows(panel).length">
|
|
<div v-for="(item, index) in rows(panel)" :key="index" class="pap-item">
|
|
<div class="pap-item-top">
|
|
<span class="pap-title">{{ mapTitle(panel, item) }}</span>
|
|
<span v-if="mapBadge(panel, item)" class="status-badge"
|
|
: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"
|
|
:class="{ mono: m.mono }">{{ m.text }}</span>
|
|
</div>
|
|
</div>
|
|
<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>
|
|
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
|
|
{{ panel.manage.emptylabel || panel.manage.label || 'Add' }}
|
|
</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- tabs: several label/value sections, one visible at a time. Keeps a
|
|
panel with many fields to the height of its largest section - a
|
|
30-row card cannot be split by the two-column multicol layout and
|
|
drags one column far past the other. -->
|
|
<div v-else-if="panel.render === 'tabs'" class="pap-tabs">
|
|
<div class="pap-tabstrip" role="tablist">
|
|
<button
|
|
v-for="(section, si) in tabSections(panel)"
|
|
:key="si"
|
|
type="button"
|
|
role="tab"
|
|
class="pap-tab"
|
|
:class="{ on: si === activeTab(panel) }"
|
|
:aria-selected="si === activeTab(panel)"
|
|
@click="setTab(panel, si)"
|
|
>{{ section.label }}</button>
|
|
</div>
|
|
<div class="pap-kv">
|
|
<div
|
|
v-for="(field, fi) in (tabSections(panel)[activeTab(panel)] || {}).fields || []"
|
|
:key="fi"
|
|
class="pap-kv-row"
|
|
>
|
|
<span class="pap-kv-label">{{ field.label }}</span>
|
|
<span class="pap-kv-value" :class="{ mono: field.mono }">{{ field.value }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- keyvalue: a label/value grid -->
|
|
<div v-else-if="panel.render === 'keyvalue'" class="pap-kv">
|
|
<div v-for="(field, fi) in keyvalueFields(panel)" :key="fi" class="pap-kv-row">
|
|
<span class="pap-kv-label">{{ field.label }}</span>
|
|
<span class="pap-kv-value" :class="{ mono: field.mono }">{{ field.value }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- table: columns declared by the panel (or inferred from row keys) -->
|
|
<div v-else-if="panel.render === 'table'" class="pap-table-wrap">
|
|
<table class="pap-table">
|
|
<thead>
|
|
<tr><th v-for="col in tableColumns(panel)" :key="col.key">{{ col.label }}</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(row, ri) in rows(panel)" :key="ri">
|
|
<td v-for="col in tableColumns(panel)" :key="col.key">{{ cell(row, col) }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- badge: a row of colored badges -->
|
|
<div v-else-if="panel.render === 'badge'" class="pap-badges">
|
|
<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>
|
|
|
|
<script setup>
|
|
import { ref, watch } from 'vue'
|
|
import api from '../api'
|
|
import { colorStyle } from '@/utils/colorStyle'
|
|
import {
|
|
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
|
|
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
|
|
rowAction,
|
|
} from './pluginAssetPanels'
|
|
|
|
const props = defineProps({
|
|
assetid: { type: [Number, String], default: null },
|
|
})
|
|
|
|
const panels = ref([])
|
|
|
|
// Which tab each 'tabs' panel is showing, keyed by panel id. Kept here rather
|
|
// than on the panel object because panels are replaced wholesale on refetch.
|
|
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 = []
|
|
if (!props.assetid) return
|
|
let declared
|
|
try {
|
|
const response = await api.get('/pluginui/asset-panels', {
|
|
params: { assetid: props.assetid },
|
|
})
|
|
declared = response.data.data || []
|
|
} catch (err) {
|
|
return
|
|
}
|
|
const withData = await Promise.all(
|
|
declared.map(async (panel) => {
|
|
try {
|
|
const response = await api.get(toApiPath(panel.endpoint, props.assetid))
|
|
return { ...panel, _data: response.data.data }
|
|
} catch (err) {
|
|
return { ...panel, _data: null }
|
|
}
|
|
})
|
|
)
|
|
panels.value = withData
|
|
}
|
|
|
|
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>
|
|
.pap-list { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
.pap-item { padding: 0.6rem 0.75rem; background: var(--bg); border-radius: 6px; }
|
|
.pap-item-top { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
|
|
.pap-title { font-weight: 600; color: var(--text); }
|
|
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
|
|
.pap-meta { margin-top: 0.35rem; display: flex; flex-wrap: wrap; gap: 0.75rem; font-size: 0.82rem; color: var(--text-light); }
|
|
.pap-empty { display: flex; align-items: center; gap: 0.6rem; }
|
|
.pap-manage { font-size: 0.82rem; }
|
|
/* Two aligned columns rather than space-between: a ragged right edge is hard
|
|
to scan once a panel carries more than a handful of rows, and a long value
|
|
(a UNC path) otherwise pushes its label away from the others. */
|
|
.pap-kv { display: grid; grid-template-columns: minmax(7rem, auto) 1fr; gap: 0.35rem 1rem; align-items: baseline; }
|
|
.pap-kv-row { display: contents; }
|
|
.pap-kv-label { color: var(--text-light); }
|
|
.pap-kv-value { color: var(--text); font-weight: 500; overflow-wrap: anywhere; }
|
|
/* Tab strip: a quiet segmented control, not browser-default buttons. The
|
|
active tab is marked by an underline AND weight, so it does not rely on
|
|
colour alone. */
|
|
.pap-tabstrip {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.15rem;
|
|
margin-bottom: 0.85rem;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.pap-tab {
|
|
background: none;
|
|
border: none;
|
|
border-bottom: 2px solid transparent;
|
|
margin-bottom: -1px;
|
|
padding: 0.35rem 0.7rem;
|
|
color: var(--text-light);
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.04em;
|
|
text-transform: uppercase;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
}
|
|
.pap-tab:hover { color: var(--text); }
|
|
.pap-tab.on {
|
|
color: var(--primary);
|
|
border-bottom-color: var(--primary);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.pap-table-wrap { overflow-x: auto; }
|
|
.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>
|