Surface the endpoint inventory, and let an event show its message

The hardware, OS and storage inventory had columns and data but nowhere to
be read: the only per-PC card was the status keyvalue, so a machine's
processor, firmware, disks and listening ports were reachable only by
querying the database.

Adds four Monitors cards - Hardware, Operating system, Storage, Listening
ports - split by the question each answers rather than by which JSON blob
they came from. OS and BIOS share one card deliberately; apart they are
three short rows each. A card yields no fields when its columns are null,
so an endpoint on an older agent hides them rather than showing a grid of
blanks.

User context is declared last on the page. The probe feeding it is not yet
producing output, so it reads empty for now, and being last means an empty
card sits below the populated ones instead of pushing them down.

The Activity feed showed a title and an event id, but the title is the
catalog name for that id - the same text on every occurrence - so two
different faults read identically. Rows now carry the provider name, and
the stored message body expands in place when the severity badge is
clicked. Commands expand to their result message, which is the reason a
failed one failed.

The badge doubles as the expander rather than gaining a control beside it,
and it keeps its severity label when open: swapping that for "Hide" would
cost the at-a-glance severity scan down the feed. Being a button rather
than a span, it is also now keyboard reachable.

map.body is additive in the shared renderer - panels that do not declare
it render exactly as before.
This commit is contained in:
benjamin.baker
2026-09-04 19:14:16 -04:00
parent 862b290545
commit 9ac711ce2d
6 changed files with 580 additions and 17 deletions

View File

@@ -52,7 +52,20 @@
<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"
<!-- On a row carrying a body the severity badge IS the expander:
it already says what the row is, and a second control beside
it only repeated that. Rows with no body keep the plain
non-interactive badge. -->
<button
v-if="mapBadge(panel, item) && mapBody(panel, item)"
type="button"
class="status-badge pap-badge-toggle"
:style="colorStyle(mapBadge(panel, item).color)"
:aria-expanded="isBodyOpen(panel, index) ? 'true' : 'false'"
:title="isBodyOpen(panel, index) ? 'Hide details' : 'Show details'"
@click="toggleBody(panel, index)"
>{{ mapBadge(panel, item).label }}</button>
<span v-else-if="mapBadge(panel, item)" class="status-badge"
:style="colorStyle(mapBadge(panel, item).color)">
{{ mapBadge(panel, item).label }}
</span>
@@ -68,6 +81,9 @@
<span v-for="(m, mi) in mapMeta(panel, item)" :key="mi"
:class="{ mono: m.mono }">{{ m.text }}</span>
</div>
<div v-if="isBodyOpen(panel, index) && mapBody(panel, item)" class="pap-body">
{{ mapBody(panel, item) }}
</div>
</div>
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
{{ panel.manage.label || 'Manage' }}
@@ -240,7 +256,7 @@ 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,
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, mapBody, manageLink,
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
rowAction, facets, facetedRows, formatValue,
} from './pluginAssetPanels'
@@ -365,6 +381,24 @@ function visibleRows(panel) {
return facetedRows(panel, facetSelection.value[panel.id])
}
// Which row bodies are expanded, keyed by panel id and row index. Closed on
// every panel refresh: the row at index 3 after a refresh is not necessarily
// the row that was opened, so keeping it open would expand an unrelated event.
const bodyOpen = ref({})
function bodyKey(panel, index) {
return `${panel.id}:${index}`
}
function isBodyOpen(panel, index) {
return !!bodyOpen.value[bodyKey(panel, index)]
}
function toggleBody(panel, index) {
const key = bodyKey(panel, index)
bodyOpen.value = { ...bodyOpen.value, [key]: !bodyOpen.value[key] }
}
const props = defineProps({
assetid: { type: [Number, String], default: null },
// Which detail-page tab to render. Null renders every panel, which is what
@@ -525,6 +559,7 @@ async function load() {
panels.value = []
revokeReportImageUrls()
closeReport()
bodyOpen.value = {}
if (!props.assetid) return
let declared
try {
@@ -562,6 +597,31 @@ defineExpose({ refresh: load, populatedGroups })
.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); }
/* The badge doubles as the expander, so it needs to look pressable without
becoming a second style of badge: same pill, just a pointer and a hover
lift. margin-left:auto pins it right even on a row that also has an
action button, where space-between alone would centre it. */
.pap-badge-toggle {
margin-left: auto;
border: none;
cursor: pointer;
font-family: inherit;
line-height: inherit;
}
.pap-badge-toggle:hover { filter: brightness(1.1); }
.pap-badge-toggle:focus-visible { outline: 2px solid var(--primary, #4181ff); outline-offset: 2px; }
/* pre-wrap, not normal: the message keeps the line breaks the event was
written with, which is what makes a multi-line fault readable. */
.pap-body {
margin-top: 0.5rem;
padding: 0.5rem 0.6rem;
background: var(--surface-2, rgba(127, 127, 127, 0.08));
border-radius: 4px;
font-size: 0.82rem;
color: var(--text-light);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.pap-empty { display: flex; align-items: center; gap: 0.6rem; }
.pap-facets {

View File

@@ -138,6 +138,14 @@ export function mapMeta(panel, item) {
.filter(Boolean)
}
export function mapBody(panel, item) {
const key = panel.map && panel.map.body
if (!key) return null
const value = item[key]
if (value === null || value === undefined || value === '') return null
return String(value)
}
export function manageLink(panel, assetid) {
return panel.manage ? subst(panel.manage.to, assetid) : null
}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'
import {
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink,
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, mapBody, manageLink,
keyvalueFields, tableColumns, badges, panelVisible, tabSections, rollupStatus,
rowAction, facets, facetedRows,
} from './pluginAssetPanels'
@@ -64,7 +64,6 @@ describe('list render mapping (warranty)', () => {
it('builds the manage link with the assetid', () => {
expect(manageLink(warrantyPanel, 7)).toBe('/warranties?addfor=7')
})
it('is visible when it has rows or an empty message', () => {
expect(panelVisible(warrantyPanel)).toBe(true)
expect(panelVisible({ ...warrantyPanel, _data: [] })).toBe(true) // has empty
@@ -72,6 +71,24 @@ describe('list render mapping (warranty)', () => {
})
})
describe('expandable row body (activity feed)', () => {
const activityPanel = {
id: 'endpoints-activity', render: 'list',
map: { title: 'title', body: 'body' },
}
it('returns the body only for rows that carry one', () => {
expect(mapBody(activityPanel, { title: 'Crash', body: 'Faulting module' }))
.toBe('Faulting module')
expect(mapBody(activityPanel, { title: 'Crash' })).toBeNull()
expect(mapBody(activityPanel, { title: 'Crash', body: '' })).toBeNull()
})
it('returns null when the panel declares no body key', () => {
expect(mapBody(warrantyPanel, { body: 'ignored' })).toBeNull()
})
})
describe('list row filtering (tracked services vs processes)', () => {
const feedPanel = {
id: 'endpoints-tracked-services', render: 'list',