778 lines
30 KiB
Vue
778 lines
30 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">▾</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>
|
|
|
|
<!-- operatorreports: a thumbnail grid of operator-submitted reports; a
|
|
card expands to a lightbox with the full screenshot and note, and
|
|
left/right arrows cycle through the reports. -->
|
|
<div v-else-if="panel.render === 'operatorreports'" class="pap-reports">
|
|
<template v-if="rows(panel).length">
|
|
<div class="pap-report-grid">
|
|
<button
|
|
v-for="(report, ri) in rows(panel)"
|
|
:key="report.operatorreportid"
|
|
type="button"
|
|
class="pap-report-card"
|
|
@click="openReport(panel, ri)"
|
|
>
|
|
<div class="pap-report-thumb">
|
|
<img
|
|
v-if="report.hasimage"
|
|
:src="reportImageUrl(panel, report)"
|
|
:alt="'Screenshot for report ' + report.operatorreportid"
|
|
loading="lazy"
|
|
/>
|
|
<span v-else class="pap-report-noimage">No screenshot</span>
|
|
</div>
|
|
<div class="pap-report-preview">
|
|
<span class="pap-report-text">{{ report.details || '(no note)' }}</span>
|
|
<span class="pap-report-when">{{ formatDate(report.createdat) }}</span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
<div v-else class="pap-empty">
|
|
<span class="muted">{{ panel.empty || 'No operator reports.' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Lightbox: full screenshot and note for the open report, with left/right
|
|
cycling. Rendered once outside the panel loop so the overlay covers the
|
|
page rather than a single card. -->
|
|
<div
|
|
v-if="lightbox.panel"
|
|
ref="lightboxEl"
|
|
class="pap-lightbox"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
tabindex="-1"
|
|
@click.self="closeReport"
|
|
@keydown.esc="closeReport"
|
|
@keydown.left="cycleReport(-1)"
|
|
@keydown.right="cycleReport(1)"
|
|
>
|
|
<button type="button" class="pap-lb-close" aria-label="Close" @click="closeReport">×</button>
|
|
<button
|
|
type="button"
|
|
class="pap-lb-nav pap-lb-prev"
|
|
aria-label="Previous report"
|
|
:disabled="rows(lightbox.panel).length < 2"
|
|
@click.stop="cycleReport(-1)"
|
|
>‹</button>
|
|
<div class="pap-lb-body">
|
|
<div class="pap-lb-image">
|
|
<img
|
|
v-if="openReportRow && openReportRow.hasimage"
|
|
:src="reportImageUrl(lightbox.panel, openReportRow)"
|
|
:alt="'Screenshot for report ' + openReportRow.operatorreportid"
|
|
/>
|
|
<span v-else class="pap-report-noimage">No screenshot</span>
|
|
</div>
|
|
<div v-if="openReportRow" class="pap-lb-details">
|
|
<div class="pap-lb-meta">
|
|
<span>{{ openReportRow.reason }}</span>
|
|
<span>{{ formatDate(openReportRow.createdat) }}</span>
|
|
<span v-if="openReportRow.reportedhostname" class="mono">{{ openReportRow.reportedhostname }}</span>
|
|
</div>
|
|
<p class="pap-lb-note">{{ openReportRow.details || '(no note)' }}</p>
|
|
<div class="pap-lb-count">
|
|
{{ lightbox.index + 1 }} / {{ rows(lightbox.panel).length }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="pap-lb-nav pap-lb-next"
|
|
aria-label="Next report"
|
|
:disabled="rows(lightbox.panel).length < 2"
|
|
@click.stop="cycleReport(1)"
|
|
>›</button>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch, onUnmounted, nextTick } 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, formatValue,
|
|
} from './pluginAssetPanels'
|
|
|
|
// Operator report screenshots are served by a JWT-gated route, so a bare
|
|
// <img src> (which sends no Authorization header) would 401. Fetch each image
|
|
// as a blob through the api instance and hand the <img> an object URL instead.
|
|
// Cache by report id so cycling the lightbox does not refetch, and revoke on
|
|
// unmount so the blobs are not leaked.
|
|
const reportImageUrls = ref({})
|
|
|
|
function formatDate(value) {
|
|
return formatValue(value, { format: 'date' })
|
|
}
|
|
|
|
// Called from the template, so it runs on EVERY render. Without the seen-set it
|
|
// re-fired the GET for a report whose fetch was still in flight, and because
|
|
// each resolution reassigns reportImageUrls the whole grid re-rendered and
|
|
// re-fired the rest - N thumbnails cost up to N-squared requests on first load.
|
|
// Two blobs resolving for one key also orphaned the first object URL, which
|
|
// revokeReportImageUrls could not free because only the surviving one was in
|
|
// the map. A failure was never recorded either, so a broken image retried for
|
|
// the life of the page.
|
|
//
|
|
// requestedReportImages tracks "we have asked", separately from the map of
|
|
// resolved URLs, and is cleared alongside it.
|
|
const requestedReportImages = new Set()
|
|
|
|
function reportImageUrl(panel, report) {
|
|
const key = report.operatorreportid
|
|
if (reportImageUrls.value[key]) return reportImageUrls.value[key]
|
|
if (requestedReportImages.has(key)) return ''
|
|
requestedReportImages.add(key)
|
|
const base = panel.imagebase || '/api/endpoints/operatorreports'
|
|
const path = toApiPath(base + '/' + key + '/image', props.assetid)
|
|
api.get(path, { responseType: 'blob' })
|
|
.then((response) => {
|
|
const url = URL.createObjectURL(response.data)
|
|
// Another render may have resolved first; keep one URL per key and free
|
|
// the loser rather than orphaning it.
|
|
if (reportImageUrls.value[key]) {
|
|
try { URL.revokeObjectURL(url) } catch { /* already revoked */ }
|
|
return
|
|
}
|
|
reportImageUrls.value = { ...reportImageUrls.value, [key]: url }
|
|
})
|
|
.catch(() => { /* stays in requestedReportImages: do not retry every render */ })
|
|
return ''
|
|
}
|
|
|
|
// Release every cached object URL. Called on reload and unmount so a long-lived
|
|
// detail page does not leak blob handles as the operator revisits PCs.
|
|
function revokeReportImageUrls() {
|
|
Object.values(reportImageUrls.value).forEach((url) => {
|
|
try { URL.revokeObjectURL(url) } catch { /* already revoked */ }
|
|
})
|
|
reportImageUrls.value = {}
|
|
requestedReportImages.clear()
|
|
}
|
|
|
|
onUnmounted(revokeReportImageUrls)
|
|
|
|
// The open report lightbox: which panel and which row index. A null panel
|
|
// means closed.
|
|
const lightbox = ref({ panel: null, index: 0 })
|
|
|
|
const openReportRow = computed(() => {
|
|
if (!lightbox.value.panel) return null
|
|
return rows(lightbox.value.panel)[lightbox.value.index] || null
|
|
})
|
|
|
|
// The overlay owns Escape and the arrow keys, and a keydown handler on a div
|
|
// only fires while that div has focus. Nothing moved focus to it, so every
|
|
// keyboard binding on the lightbox was dead until the user happened to click
|
|
// the backdrop. Focus it once it has rendered, and hand focus back to the
|
|
// thumbnail that opened it on close, so keyboard users are not dropped at the
|
|
// top of the document.
|
|
const lightboxEl = ref(null)
|
|
let reopenFocus = null
|
|
|
|
function openReport(panel, index) {
|
|
reopenFocus = typeof document !== 'undefined' ? document.activeElement : null
|
|
lightbox.value = { panel, index }
|
|
nextTick(() => lightboxEl.value?.focus())
|
|
}
|
|
|
|
function closeReport() {
|
|
lightbox.value = { panel: null, index: 0 }
|
|
const returnTo = reopenFocus
|
|
reopenFocus = null
|
|
nextTick(() => returnTo?.focus?.())
|
|
}
|
|
|
|
function cycleReport(delta) {
|
|
if (!lightbox.value.panel) return
|
|
const total = rows(lightbox.value.panel).length
|
|
if (total < 2) return
|
|
const next = (lightbox.value.index + delta + total) % total
|
|
lightbox.value = { ...lightbox.value, index: next }
|
|
}
|
|
|
|
// 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 = []
|
|
revokeReportImageUrls()
|
|
closeReport()
|
|
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. The parent
|
|
.content-grid is a CSS grid, so grid-column: 1 / -1 makes the panel span
|
|
every track while 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 { grid-column: 1 / -1; }
|
|
.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); }
|
|
|
|
/* Operator reports: a responsive thumbnail grid, each card a screenshot over a
|
|
one-line note preview. Clicking a card opens the lightbox. */
|
|
.pap-report-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
|
|
gap: 0.75rem;
|
|
}
|
|
.pap-report-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
cursor: pointer;
|
|
overflow: hidden;
|
|
text-align: left;
|
|
}
|
|
.pap-report-card:hover { border-color: var(--primary); }
|
|
.pap-report-thumb {
|
|
aspect-ratio: 16 / 10;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--bg);
|
|
overflow: hidden;
|
|
}
|
|
.pap-report-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
|
.pap-report-noimage { font-size: 0.78rem; color: var(--text-light); }
|
|
.pap-report-preview {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.2rem;
|
|
padding: 0.45rem 0.55rem;
|
|
}
|
|
.pap-report-text {
|
|
font-size: 0.82rem;
|
|
color: var(--text);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.pap-report-when { font-size: 0.72rem; color: var(--text-light); }
|
|
|
|
/* Lightbox: a page-covering overlay with the full screenshot, the note, and
|
|
left/right cycling controls. */
|
|
.pap-lightbox {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 1000;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.5rem;
|
|
padding: 2rem;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
}
|
|
.pap-lb-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
max-width: 90vw;
|
|
max-height: 90vh;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
overflow: auto;
|
|
}
|
|
.pap-lb-image {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 0;
|
|
}
|
|
.pap-lb-image img { max-width: 100%; max-height: 70vh; object-fit: contain; }
|
|
.pap-lb-details { display: flex; flex-direction: column; gap: 0.4rem; }
|
|
.pap-lb-meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.75rem;
|
|
font-size: 0.8rem;
|
|
color: var(--text-light);
|
|
}
|
|
.pap-lb-note { margin: 0; color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
.pap-lb-count { font-size: 0.78rem; color: var(--text-light); font-variant-numeric: tabular-nums; }
|
|
.pap-lb-close {
|
|
position: absolute;
|
|
top: 1rem;
|
|
right: 1rem;
|
|
background: none;
|
|
border: none;
|
|
color: #fff;
|
|
font-size: 1.8rem;
|
|
line-height: 1;
|
|
cursor: pointer;
|
|
}
|
|
.pap-lb-nav {
|
|
flex: 0 0 auto;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border: none;
|
|
color: #fff;
|
|
font-size: 2rem;
|
|
line-height: 1;
|
|
padding: 0.5rem 0.9rem;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
}
|
|
.pap-lb-nav:hover:not(:disabled) { background: rgba(255, 255, 255, 0.25); }
|
|
.pap-lb-nav:disabled { opacity: 0.3; cursor: default; }
|
|
</style>
|