437 lines
17 KiB
Vue
437 lines
17 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">
|
|
<!-- 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, 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 },
|
|
})
|
|
|
|
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-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;
|
|
}
|
|
|
|
.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>
|