dashboard: render plugin-declared cards, starting with enforcement failures
The frontend now calls /api/dashboard/widgets. It never had, which is why five plugins have been declaring widgets into a void for months, pointing at components nobody ever wrote. Core owns three generic renderers - exceptions, metric, list - and a plugin declares data, a shape and a link template. The mapping logic lives in a plain module beside the component, the same split as pluginAssetPanels.js, so it is unit tested without mounting anything: 16 tests covering row mapping, empty handling, ordering and gating. The behaviours worth naming, because each is a decision rather than an implementation detail: Cards fetch INDEPENDENTLY and a failure becomes null. One hung endpoint - a Zabbix call, a plugin mid-upgrade - cannot blank the board. A card whose fetch failed HIDES rather than drawing empty, because "nothing wrong" and "I could not tell" must not look the same. Empty cards disappear by default. A card reporting nothing every day teaches people to stop reading the page, which is precisely how a fleet log reached 3,234 lines with 17 that mattered. A card opts into a one-line presence only when its absence is itself news. Severity outranks position, so an info card can never sit above a failure. Permission filtering happens BEFORE fetching: no point firing a request that would only 403, and the dashboard must not become a way around RBAC. An unknown render mode is skipped, so a plugin built against a newer core degrades instead of leaving a hole. A row whose link substitution is missing keeps the row and drops the link - a PC shopdb does not know still reports its failure, and that is the bay most likely to be misconfigured. Cards sit ABOVE the totals: what needs a person first, context second. The existing stat cards are untouched for now.
This commit is contained in:
137
frontend/src/components/dashboardCards.spec.js
Normal file
137
frontend/src/components/dashboardCards.spec.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
toApiPath, rows, mapMeta, mapLink, cardRows, metricValue,
|
||||
cardVisible, sortCards, permittedCards, renderableCards,
|
||||
} from './dashboardCards'
|
||||
|
||||
const failuresCard = {
|
||||
id: 'geenforce-failures',
|
||||
render: 'exceptions',
|
||||
severity: 'critical',
|
||||
position: 10,
|
||||
map: {
|
||||
title: 'hostname',
|
||||
detail: 'entryname',
|
||||
meta: [{ key: 'message' }, { key: 'exitcode', label: 'exit' }],
|
||||
link: '/pcs/{computerid}',
|
||||
},
|
||||
}
|
||||
|
||||
describe('endpoint paths', () => {
|
||||
it('strips the api prefix the instance already carries', () => {
|
||||
expect(toApiPath('/api/geenforce/dashboard/failures'))
|
||||
.toBe('/geenforce/dashboard/failures')
|
||||
})
|
||||
|
||||
it('leaves a relative endpoint alone', () => {
|
||||
expect(toApiPath('/geenforce/x')).toBe('/geenforce/x')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reading rows from whatever shape the endpoint returns', () => {
|
||||
it('accepts a bare array, {rows} or {items}', () => {
|
||||
expect(rows({ _data: [1, 2] })).toEqual([1, 2])
|
||||
expect(rows({ _data: { rows: [1] } })).toEqual([1])
|
||||
expect(rows({ _data: { items: [1, 2, 3] } })).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('treats a failed fetch as no rows rather than throwing', () => {
|
||||
expect(rows({ _data: null })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapping a row', () => {
|
||||
it('builds title, detail, meta and link from the declaration', () => {
|
||||
const card = { ...failuresCard, _data: [{
|
||||
hostname: 'WJSF1234', entryname: 'Install OpenText',
|
||||
exitcode: 1603, message: 'Fatal error', computerid: 42,
|
||||
}] }
|
||||
const [row] = cardRows(card)
|
||||
expect(row.title).toBe('WJSF1234')
|
||||
expect(row.detail).toBe('Install OpenText')
|
||||
expect(row.meta.map((m) => m.text)).toEqual(['Fatal error', 'exit 1603'])
|
||||
expect(row.link).toBe('/pcs/42')
|
||||
})
|
||||
|
||||
it('drops empty meta values instead of rendering a stray label', () => {
|
||||
const card = { ...failuresCard, _data: [{ hostname: 'X', message: '', exitcode: null }] }
|
||||
expect(cardRows(card)[0].meta).toEqual([])
|
||||
})
|
||||
|
||||
it('omits the link when the substitution value is missing, keeping the row', () => {
|
||||
// A PC shopdb does not know still reports its failure - that is the bay
|
||||
// most likely to be misconfigured. It must not link to /pcs/undefined.
|
||||
const card = { ...failuresCard, _data: [{ hostname: 'GHOSTPC', computerid: null }] }
|
||||
const [row] = cardRows(card)
|
||||
expect(row.title).toBe('GHOSTPC')
|
||||
expect(row.link).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty handling', () => {
|
||||
it('hides a card with nothing to report by default', () => {
|
||||
// The whole point: a card saying "nothing wrong" daily trains people to
|
||||
// stop reading the page.
|
||||
expect(cardVisible({ render: 'exceptions', _data: [] })).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a card whose absence is itself news when it opts in', () => {
|
||||
expect(cardVisible({ render: 'exceptions', _data: [], empty: 'line' })).toBe(true)
|
||||
})
|
||||
|
||||
it('shows a card that has findings', () => {
|
||||
expect(cardVisible({ render: 'exceptions', _data: [{ a: 1 }] })).toBe(true)
|
||||
})
|
||||
|
||||
it('hides a card whose fetch failed rather than drawing it empty', () => {
|
||||
expect(cardVisible({ render: 'exceptions', _data: null })).toBe(false)
|
||||
})
|
||||
|
||||
it('hides a zero metric but shows a non-zero one', () => {
|
||||
expect(cardVisible({ render: 'metric', _data: { value: 0 } })).toBe(false)
|
||||
expect(cardVisible({ render: 'metric', _data: { value: 3 } })).toBe(true)
|
||||
expect(metricValue({ render: 'metric', _data: { value: 3 } })).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ordering', () => {
|
||||
it('puts severity before position, so info never sits above a failure', () => {
|
||||
const ordered = sortCards([
|
||||
{ id: 'info-recent', severity: 'info', position: 1 },
|
||||
{ id: 'crit-failures', severity: 'critical', position: 90 },
|
||||
{ id: 'warn-toner', severity: 'warning', position: 50 },
|
||||
])
|
||||
expect(ordered.map((c) => c.id))
|
||||
.toEqual(['crit-failures', 'warn-toner', 'info-recent'])
|
||||
})
|
||||
|
||||
it('falls back to position within one severity', () => {
|
||||
const ordered = sortCards([
|
||||
{ id: 'b', severity: 'critical', position: 20 },
|
||||
{ id: 'a', severity: 'critical', position: 10 },
|
||||
])
|
||||
expect(ordered.map((c) => c.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('gating', () => {
|
||||
it('drops a card the user lacks permission for', () => {
|
||||
// The dashboard must not become a way around RBAC.
|
||||
const has = (name) => name === 'printers.view'
|
||||
const kept = permittedCards([
|
||||
{ id: 'toner', permission: 'printers.view' },
|
||||
{ id: 'enforce', permission: 'geenforce.manage' },
|
||||
{ id: 'open', permission: null },
|
||||
], has)
|
||||
expect(kept.map((c) => c.id)).toEqual(['toner', 'open'])
|
||||
})
|
||||
|
||||
it('skips a render mode this core does not have', () => {
|
||||
// A plugin built against a newer core degrades instead of leaving a hole.
|
||||
const kept = renderableCards([
|
||||
{ id: 'ok', render: 'exceptions' },
|
||||
{ id: 'future', render: 'sparkline' },
|
||||
])
|
||||
expect(kept.map((c) => c.id)).toEqual(['ok'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user