Filter chips on any card that shows a health dot
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 4m4s

Reported from the Activity card: the header dot was red while every visible row
was info, because the critical event was older and sat further down the list.
The dot and the list appeared to contradict each other, and the only way to
find the critical was to scroll.

The dot is computed from all rows the panel received, so a red dot always means
a critical IS in the data. The chips are computed from that same array, which
means the count sits directly under the dot and the two cannot disagree by
construction: the card now reads 'Critical 2  Warning 1  Info 12', and one
click surfaces them wherever they sit in time.

Derived from the panel's EXISTING status declaration rather than a new one per
panel. A card that shows a health dot has already said what it dots on, so
Tracked Services and Tracked Processes get chips with no plugin change, and any
future list panel with a status rollup does too. Two declarations of the same
thing would eventually disagree.

Details that matter: a colour-keyed panel is labelled by meaning, so services
read Critical and OK rather than Red and Green; chips are ordered worst first
rather than alphabetically; and no selection means show everything, not hide
everything - a card that emptied when you deselected the last chip would read
as broken. Filtering to nothing says so, and gives the hidden count.

vitest 267 passed, including the exact reported case.
This commit is contained in:
cproudlock
2026-08-29 13:02:32 -04:00
parent 60c35728e8
commit 828a62063b
3 changed files with 206 additions and 4 deletions

View File

@@ -31,8 +31,25 @@
<!-- list: items each with a title, optional status badge, meta lines --> <!-- list: items each with a title, optional status badge, meta lines -->
<div v-if="panel.render === 'list'" class="pap-list"> <div v-if="panel.render === 'list'" class="pap-list">
<template v-if="rows(panel).length"> <!-- Filter chips for any list panel that shows a health dot. Client
<div v-for="(item, index) in rows(panel)" :key="index" class="pap-item"> 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"> <div class="pap-item-top">
<span class="pap-title">{{ mapTitle(panel, item) }}</span> <span class="pap-title">{{ mapTitle(panel, item) }}</span>
<span v-if="mapBadge(panel, item)" class="status-badge" <span v-if="mapBadge(panel, item)" class="status-badge"
@@ -59,6 +76,9 @@
{{ actionError[panel.id] }} {{ actionError[panel.id] }}
</div> </div>
</template> </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"> <div v-else class="pap-empty">
<span class="muted">{{ panel.empty || 'Nothing to show.' }}</span> <span class="muted">{{ panel.empty || 'Nothing to show.' }}</span>
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage"> <router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
@@ -135,9 +155,32 @@ import { colorStyle } from '@/utils/colorStyle'
import { import {
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink, toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus, keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
rowAction, rowAction, facets, facetedRows,
} from './pluginAssetPanels' } 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({ const props = defineProps({
assetid: { type: [Number, String], default: null }, assetid: { type: [Number, String], default: null },
}) })
@@ -302,6 +345,33 @@ defineExpose({ refresh: load })
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; } .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-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-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; } .pap-manage { font-size: 0.82rem; }
/* Two aligned columns rather than space-between: a ragged right edge is hard /* 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 to scan once a panel carries more than a handful of rows, and a long value

View File

@@ -45,6 +45,73 @@ export function rows(panel) {
return list return list
} }
// Optional in-card filter chips. A list panel declares facets: {key, order,
// labels} and the renderer offers one chip per distinct value of that key,
// with a count. Filtering is client-side over rows the panel already fetched -
// no extra request, and the counts stay honest because they are computed from
// the same array.
//
// Declared rather than hardcoded because the reason it exists is generic: the
// activity feed mixes info, warning and critical, and somebody triaging wants
// the criticals alone. Any list panel with a category column has that problem.
// Colour names carry no meaning to a reader. A panel keyed on statecolor
// (green/yellow/red) is labelled by the LEVEL those map to, while one keyed on
// a word (info/warning/critical) is labelled by the word itself.
const COLOUR_VALUES = ['green', 'yellow', 'red', 'grey', 'gray', 'blue']
const LEVEL_ORDER = ['critical', 'warning', 'ok', 'unknown']
// A panel that shows a health dot has already declared what it is dotting on.
// Reuse that instead of asking every panel to declare filters a second time:
// any card with a status rollup gets chips, and the two cannot disagree.
function facetSpec(panel) {
if (panel.facets && panel.facets.key) return panel.facets
const status = panel.status
if (!status || !status.key) return null
const levels = status.levels || {}
const order = Object.keys(levels).sort(
(a, b) => LEVEL_ORDER.indexOf(levels[a]) - LEVEL_ORDER.indexOf(levels[b])
)
const labels = {}
Object.keys(levels).forEach((value) => {
labels[value] = COLOUR_VALUES.includes(String(value).toLowerCase())
? humanize(levels[value])
: humanize(value)
})
return { key: status.key, order, labels }
}
export function facets(panel) {
const spec = facetSpec(panel)
if (!spec || !spec.key) return []
const all = rows(panel)
const counts = new Map()
all.forEach((item) => {
const value = item[spec.key]
if (value === null || value === undefined || value === '') return
const key = String(value)
counts.set(key, (counts.get(key) || 0) + 1)
})
// Declared order first, so severity reads critical-warning-info rather than
// alphabetically; anything unexpected in the data still gets a chip.
const declared = (spec.order || []).filter((value) => counts.has(value))
const extra = [...counts.keys()].filter((value) => !declared.includes(value)).sort()
return [...declared, ...extra].map((value) => ({
value,
label: (spec.labels && spec.labels[value]) || humanize(value),
count: counts.get(value)
}))
}
// rows() after the viewer's chip selection. An empty selection means "no filter
// applied", NOT "hide everything": a card that vanished when you deselected the
// last chip would read as broken.
export function facetedRows(panel, selected) {
const all = rows(panel)
const spec = facetSpec(panel)
if (!spec || !spec.key || !selected || !selected.length) return all
return all.filter((item) => selected.includes(String(item[spec.key])))
}
export function mapTitle(panel, item) { export function mapTitle(panel, item) {
const key = panel.map && panel.map.title const key = panel.map && panel.map.title
return key ? item[key] : '' return key ? item[key] : ''

View File

@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
import { import {
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink, toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink,
keyvalueFields, tableColumns, badges, panelVisible, tabSections, rollupStatus, keyvalueFields, tableColumns, badges, panelVisible, tabSections, rollupStatus,
rowAction, rowAction, facets, facetedRows,
} from './pluginAssetPanels' } from './pluginAssetPanels'
const warrantyPanel = { const warrantyPanel = {
@@ -228,3 +228,68 @@ describe('rowAction (inline MTConnect toggle)', () => {
expect(info.toggleLabel).toBe('Start') expect(info.toggleLabel).toBe('Start')
}) })
}) })
describe('facet chips on a card that shows a health dot', () => {
// The case that prompted this: the Activity card shows a RED dot because a
// critical event is in the data, while the visible rows are all info because
// the critical is older and sits further down. The dot and the list appear to
// disagree. Chips resolve it: the count sits under the dot, and one click
// brings the critical rows to the surface.
const activity = {
id: 'endpoints-activity',
render: 'list',
status: {
key: 'severity',
levels: { info: 'ok', warning: 'warning', critical: 'critical' },
},
_data: [
{ title: 'Successful logon', severity: 'info' },
{ title: 'Successful logon', severity: 'info' },
{ title: 'Scheduled task created', severity: 'warning' },
{ title: 'Unexpected shutdown', severity: 'critical' },
],
}
it('derives chips from the status declaration, so no panel declares filters twice', () => {
const chips = facets(activity)
expect(chips.map((c) => c.value)).toEqual(['critical', 'warning', 'info'])
})
it('orders worst first rather than alphabetically', () => {
expect(facets(activity)[0].value).toBe('critical')
})
it('counts from the same rows the dot is computed from, so they cannot disagree', () => {
const chips = facets(activity)
expect(chips.find((c) => c.value === 'critical').count).toBe(1)
expect(chips.find((c) => c.value === 'info').count).toBe(2)
expect(rollupStatus(activity).level).toBe('critical')
})
it('labels a colour-keyed panel by its level, not by the colour name', () => {
const services = {
id: 'svc',
render: 'list',
status: { key: 'statecolor', levels: { green: 'ok', red: 'critical' } },
_data: [{ statecolor: 'green' }, { statecolor: 'red' }],
}
const labels = facets(services).map((c) => c.label)
expect(labels).toContain('Critical')
expect(labels).not.toContain('Red')
})
it('shows everything when nothing is selected, rather than hiding the card', () => {
expect(facetedRows(activity, []).length).toBe(4)
expect(facetedRows(activity, undefined).length).toBe(4)
})
it('filters to the selected severities, and allows more than one', () => {
expect(facetedRows(activity, ['critical']).map((r) => r.title))
.toEqual(['Unexpected shutdown'])
expect(facetedRows(activity, ['critical', 'warning']).length).toBe(2)
})
it('offers no chips for a panel with no status rollup', () => {
expect(facets({ id: 'x', render: 'list', _data: [{ a: 1 }] })).toEqual([])
})
})