harden CSV import reconciliation and preserve saved model selections

Default fuzzy reconcile threshold to 0 and refuse merges when digit runs differ so numbered labels like Bay 311 never bind to Bay 310. Keep edit forms from clearing saved model fields when category filtering hides untyped rows. Includes in-progress computers policy and endpoints command feature work.
This commit is contained in:
benjamin.baker
2026-08-28 09:29:09 -04:00
parent d68c6db9ab
commit 01734e5449
46 changed files with 4241 additions and 52 deletions

View File

@@ -224,6 +224,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}`)
}
}
}
}

View File

@@ -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">&#9662;</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,45 @@ 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 (default expanded), kept here for the same
// reason as tabs: panels are replaced wholesale on refetch.
const collapsed = ref({})
function isCollapsed(panel) { return !!collapsed.value[panel.id] }
function toggleCollapse(panel) {
collapsed.value = { ...collapsed.value, [panel.id]: !collapsed.value[panel.id] }
}
// 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
await api.post(toApiPath(info.endpoint, props.assetid), body)
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 }
}
}
// Fetch the declared panels for this asset, then each panel's data endpoint.
async function load() {
panels.value = []
@@ -195,6 +269,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>

View File

@@ -98,6 +98,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 +145,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

View File

@@ -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 = {
@@ -130,3 +131,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')
})
})