Files
shopdb-flask/frontend/src/components/PluginAssetPanels.vue
cproudlock 2feb569686
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 43s
CI / backend (push) Successful in 4m17s
Rename Metrics to Monitors and move it second
"Metrics" promised numbers. The tab holds tracked services, tracked processes
and network interfaces - things being WATCHED and their current state, only
one of which is a measurement. Monitors is what the panels actually are, and
it matches the word the agent's own config already uses for them.

Moved to second, ahead of Software. What is being watched on this box is the
question asked right after "what is it", and well before its application
inventory - which is reference data somebody goes looking for deliberately.

Tab order is now Details, Monitors, Software, Activity.

Contract 0.29.0 keeps its number. It is unreleased, so the group vocabulary it
introduces is still being settled and nothing can yet depend on the old name.
Both the server and the client fall back to 'details' for a group they do not
recognise, so a site that deploys these halves out of step gets a misplaced
card rather than a missing one.

Scope: product
2026-08-31 15:21:46 -04:00

480 lines
20 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 groupedPanels" :key="panel.id">
<div v-if="panelVisible(panel)" class="section-card" :class="{ 'pap-span': panel.render === 'table' }">
<!-- 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">
<!-- Filter chips for any list panel that shows a health dot. Client
side over rows already fetched, so the counts cannot disagree with
what is on screen. -->
<div v-if="facets(panel).length > 1" class="pap-facets">
<button
v-for="facet in facets(panel)"
:key="facet.value"
type="button"
class="pap-facet"
:class="{ on: isFacetOn(panel, facet.value) }"
@click="toggleFacet(panel, facet.value)"
>{{ facet.label }} <span class="pap-facet-count">{{ facet.count }}</span></button>
<button v-if="facetSelection[panel.id] && facetSelection[panel.id].length"
type="button" class="pap-facet pap-facet-clear"
@click="facetSelection[panel.id] = []">Clear</button>
</div>
<template v-if="visibleRows(panel).length">
<div v-for="(item, index) in visibleRows(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-if="rows(panel).length" class="pap-empty">
Nothing matches that filter. {{ rows(panel).length }} row(s) hidden.
</div>
<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, computed, 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, facets, facetedRows,
} from './pluginAssetPanels'
// Which filter chips are active, per panel id. Deliberately NOT reset when the
// data refreshes: a viewer who filtered to criticals and then hit refresh means
// to still be looking at criticals.
const facetSelection = ref({})
function isFacetOn(panel, value) {
return (facetSelection.value[panel.id] || []).includes(value)
}
function toggleFacet(panel, value) {
const current = facetSelection.value[panel.id] || []
facetSelection.value = {
...facetSelection.value,
[panel.id]: current.includes(value)
? current.filter((v) => v !== value)
: [...current, value],
}
}
function visibleRows(panel) {
return facetedRows(panel, facetSelection.value[panel.id])
}
const props = defineProps({
assetid: { type: [Number, String], default: null },
// Which detail-page tab to render. Null renders every panel, which is what
// the detail pages that have not adopted tabs still do - so this stays
// additive and no existing page changes behaviour.
group: { type: String, default: null },
})
const panels = ref([])
// The groups this build renders. A panel in any other group would be fetched,
// held, and shown on no tab at all.
//
// The server normalises group before we see it, so a version-matched pair
// never lands here. A MISMATCHED pair does: deploy a new frontend against a
// server still serving an older vocabulary and every panel in a group that
// has since been renamed or removed silently disappears. That is what happened
// to tracked services when it moved out of 'actions' - the backend still said
// 'actions', this build had no such tab, and the card was simply gone with
// nothing logged. The same applies to the 'metrics' to 'monitors' rename.
// Fall back the same way the server does rather than drop it.
const RENDERED_GROUPS = ['details', 'monitors', 'software', 'activity']
function groupOf(panel) {
return RENDERED_GROUPS.includes(panel.group) ? panel.group : 'details'
}
const groupedPanels = computed(() =>
props.group ? panels.value.filter((p) => groupOf(p) === props.group) : panels.value
)
// Which groups actually have a panel with data, so a parent can hide a tab
// rather than offering an empty one.
const populatedGroups = computed(() => {
const seen = new Set()
panels.value.forEach((p) => { if (panelVisible(p)) seen.add(groupOf(p)) })
return seen
})
// 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, populatedGroups })
</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-facets {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.6rem;
}
.pap-facet {
font: inherit;
font-size: 0.78rem;
line-height: 1.6;
padding: 0.05rem 0.5rem;
border: 1px solid var(--border);
border-radius: 10px;
background: transparent;
color: var(--text-light);
cursor: pointer;
}
.pap-facet:hover { border-color: var(--primary); }
.pap-facet.on { border-color: var(--primary); color: var(--primary); }
.pap-facet-clear { border-style: dashed; }
/* The count is the point: a red dot over a list of info rows is confusing
until the chip says "Critical 2" and one click shows them. */
.pap-facet-count { font-variant-numeric: tabular-nums; opacity: 0.75; }
.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;
}
/* A table panel SPANS BOTH COLUMNS rather than scrolling inside one.
Network interfaces is the case that forced this: adapter, address, MAC,
speed and state at half page width either squeezed every cell to three
lines or handed the reader a horizontal scrollbar, and a scrollbar for
data that simply needs room is a layout failure, not a feature. column-span
is the multi-column layout's own answer, so the short fact cards stay two-up
around it.
The wrapper keeps overflow-x as a floor for the genuinely too-wide case on
a narrow viewport, where nothing else can help. */
.pap-span { column-span: all; }
.pap-table-wrap { overflow-x: auto; max-width: 100%; }
.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>