USB external import, and filter chips on health-dot cards

This commit is contained in:
2026-08-29 13:02:45 -04:00
parent b08ae79989
commit b26bbd0456
10 changed files with 610 additions and 7 deletions

View File

@@ -31,8 +31,25 @@
<!-- list: items each with a title, optional status badge, meta lines -->
<div v-if="panel.render === 'list'" class="pap-list">
<template v-if="rows(panel).length">
<div v-for="(item, index) in rows(panel)" :key="index" class="pap-item">
<!-- 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"
@@ -59,6 +76,9 @@
{{ 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">
@@ -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

View File

@@ -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] : ''

View File

@@ -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([])
})
})