+
+ Nothing matches that filter. {{ rows(panel).length }} row(s) hidden.
+
{{ panel.empty || 'Nothing to show.' }}
@@ -135,9 +155,32 @@ import { colorStyle } from '@/utils/colorStyle'
import {
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
- rowAction,
+ 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 },
})
@@ -302,6 +345,33 @@ defineExpose({ refresh: load })
.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
diff --git a/frontend/src/components/pluginAssetPanels.js b/frontend/src/components/pluginAssetPanels.js
index 28fa9c52..310e2485 100644
--- a/frontend/src/components/pluginAssetPanels.js
+++ b/frontend/src/components/pluginAssetPanels.js
@@ -45,6 +45,73 @@ export function rows(panel) {
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) {
const key = panel.map && panel.map.title
return key ? item[key] : ''
diff --git a/frontend/src/components/pluginAssetPanels.spec.js b/frontend/src/components/pluginAssetPanels.spec.js
index 184bbe42..5ef5b255 100644
--- a/frontend/src/components/pluginAssetPanels.spec.js
+++ b/frontend/src/components/pluginAssetPanels.spec.js
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
import {
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink,
keyvalueFields, tableColumns, badges, panelVisible, tabSections, rollupStatus,
- rowAction,
+ rowAction, facets, facetedRows,
} from './pluginAssetPanels'
const warrantyPanel = {
@@ -228,3 +228,68 @@ describe('rowAction (inline MTConnect toggle)', () => {
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([])
+ })
+})