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:
@@ -4,7 +4,30 @@
|
||||
on any asset detail page, so a plugin adds detail-page UI with no Vue. -->
|
||||
<template v-for="panel in panels" :key="panel.id">
|
||||
<div v-if="panelVisible(panel)" class="section-card">
|
||||
<h3 class="section-title">{{ panel.title }}</h3>
|
||||
<!-- collapsible panels get a clickable header with a caret and, when the
|
||||
panel declares a status rollup, a green/yellow/red dot for the worst
|
||||
row (a stopped service or a warning/critical event). --><h3
|
||||
v-if="panel.collapsible"
|
||||
class="section-title pap-header"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-expanded="!isCollapsed(panel)"
|
||||
@click="toggleCollapse(panel)"
|
||||
@keydown.enter.prevent="toggleCollapse(panel)"
|
||||
@keydown.space.prevent="toggleCollapse(panel)"
|
||||
>
|
||||
<span class="pap-caret" :class="{ collapsed: isCollapsed(panel) }" aria-hidden="true">▾</span>
|
||||
<span class="pap-header-title">{{ panel.title }}</span>
|
||||
<span
|
||||
v-if="rollupStatus(panel)"
|
||||
class="pap-status-dot"
|
||||
:style="{ backgroundColor: rollupStatus(panel).color }"
|
||||
:title="rollupStatus(panel).level"
|
||||
></span>
|
||||
</h3>
|
||||
<h3 v-else class="section-title">{{ panel.title }}</h3>
|
||||
|
||||
<div v-show="!panel.collapsible || !isCollapsed(panel)" class="pap-body">
|
||||
|
||||
<!-- list: items each with a title, optional status badge, meta lines -->
|
||||
<div v-if="panel.render === 'list'" class="pap-list">
|
||||
@@ -16,6 +39,13 @@
|
||||
:style="colorStyle(mapBadge(panel, item).color)">
|
||||
{{ mapBadge(panel, item).label }}
|
||||
</span>
|
||||
<button
|
||||
v-if="rowAction(panel, item)"
|
||||
type="button"
|
||||
class="btn btn-sm pap-row-action"
|
||||
:disabled="actionBusy[panel.id]"
|
||||
@click="runRowAction(panel, item)"
|
||||
>{{ rowAction(panel, item).toggleLabel }}</button>
|
||||
</div>
|
||||
<div v-if="mapMeta(panel, item).length" class="pap-meta">
|
||||
<span v-for="(m, mi) in mapMeta(panel, item)" :key="mi"
|
||||
@@ -25,6 +55,9 @@
|
||||
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
|
||||
{{ panel.manage.label || 'Manage' }}
|
||||
</router-link>
|
||||
<div v-if="actionError[panel.id]" class="pap-action-error">
|
||||
{{ actionError[panel.id] }}
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="pap-empty">
|
||||
<span class="muted">{{ panel.empty || 'Nothing to show.' }}</span>
|
||||
@@ -90,6 +123,7 @@
|
||||
<span v-for="(b, bi) in badges(panel)" :key="bi" class="status-badge"
|
||||
:style="colorStyle(b.color)">{{ b.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -100,7 +134,8 @@ import api from '../api'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import {
|
||||
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
|
||||
keyvalueFields, tableColumns, cell, badges, tabSections,
|
||||
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
|
||||
rowAction,
|
||||
} from './pluginAssetPanels'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -115,6 +150,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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user