Files
shopdb-flask/frontend/src/components/dashboardCards.spec.js
Cam Proudlock 870e068c44 Rebuild the dashboard around what needs a person
The board was one card per source in a fixed grid: every source got the same
small box whatever it had to say (warranties showed 5 of 50), and a box per
source flattened priority, while the largest type on the page belonged to counts
that need nobody.

Now a severity rollup, one worst-first queue of everything needing a person, and
a row of context beneath. The queue is full width, capped per source so a noisy
check cannot bury a rare one, and names what did not fit with a link to it. Chips
filter the queue by check as well as by severity; both narrow what is already
loaded, so the counts and rows cannot disagree.

Dropped: an 'All assets' tile shown beside its own four addends, a printer strip
carrying the same count a third time, three cards whose job was to display 0, and
a Recent Devices table listing five machines all 'In Use'.

Contract 0.26.0 adds optional 'shortname' on dashboard cards - a title is written
as a heading and ran wider than the hostname it qualified as a row badge.

Also: the supplies card reported 'Low Supplies 0' when Zabbix was unreachable,
which reads as reassurance rather than the truth that nothing could see the
cartridges. It now distinguishes unreachable (says so) from unconfigured (stays
quiet - the site declined the feature) from a real zero.

Full suite 2182 passed, frontend 314 passed.
2026-08-29 21:35:03 -04:00

471 lines
17 KiB
JavaScript

import { describe, it, expect } from 'vitest'
import {
toApiPath, rows, mapMeta, mapLink, cardRows, metricValue,
cardVisible, sortCards, permittedCards, renderableCards,
visibleRows, overflowCount,
queueRows, queueOverflow, contextCards, severityTotals,
QUEUEPERSOURCE, QUEUEMAX, singularise,
cardUnavailable, unavailableCards, sourceFacets,
} 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('supports a trailing unit so the text reads as a sentence', () => {
const card = {
render: 'exceptions',
map: { title: 'hostname', meta: [{ key: 'quietdays', label: 'quiet for', suffix: ' days' }] },
_data: [{ hostname: 'QUIETPC', quietdays: 3 }],
}
expect(cardRows(card)[0].meta[0].text).toBe('quiet for 3 days')
})
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('long lists', () => {
const many = (n) => ({
render: 'exceptions',
map: { title: 'hostname' },
_data: Array.from({ length: n }, (_v, i) => ({ hostname: `PC${i}` })),
})
it('shows at most five rows so one card cannot bury the rest', () => {
expect(visibleRows(many(40))).toHaveLength(5)
expect(overflowCount(many(40))).toBe(35)
})
it('does not claim an overflow when everything fits', () => {
expect(visibleRows(many(3))).toHaveLength(3)
expect(overflowCount(many(3))).toBe(0)
})
})
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'])
})
})
describe('chips and tooltips', () => {
const printerCard = {
render: 'exceptions',
map: {
title: 'printername',
titletooltip: 'location',
chips: 'supplies',
link: '/printers/{printerid}',
},
_data: [{
printerid: 7,
printername: 'WJ-HP-402',
location: 'Cell B, north wall',
supplies: [
{ text: 'Black 4%', title: 'CF226X (high)', level: 'critical' },
{ text: 'Cyan 9%', title: 'No part number on file for this model', level: 'low' },
],
}],
}
it('builds one chip per depleted supply, each with its reorder tooltip', () => {
const [row] = cardRows(printerCard)
expect(row.chips.map((c) => c.text)).toEqual(['Black 4%', 'Cyan 9%'])
expect(row.chips[0].title).toBe('CF226X (high)')
expect(row.chips[0].level).toBe('critical')
})
it('puts the location on the title as a tooltip, not in the line', () => {
const [row] = cardRows(printerCard)
expect(row.titletip).toBe('Cell B, north wall')
expect(row.detail).toBe('')
})
it('has no chips when a card declares none', () => {
expect(cardRows({ render: 'exceptions', map: { title: 'x' }, _data: [{ x: 'y' }] })[0].chips)
.toEqual([])
})
})
describe('stacked rows', () => {
const card = {
render: 'list',
layout: 'stacked',
map: { title: 'typename', detail: 'message', detailtooltip: 'fullmessage' },
_data: [{ typename: 'Recertification', message: 'Short text', fullmessage: 'Short text' }],
}
it('offers no tooltip when the detail is already whole', () => {
// A tooltip repeating what is on screen is noise, and it makes the cursor
// change for no reason.
expect(cardRows(card)[0].detailtip).toBe('')
})
it('offers the full text when the detail was trimmed', () => {
const trimmed = { ...card, _data: [{
typename: 'General', message: 'Start of a long message...',
fullmessage: 'Start of a long message that continues well past the card',
}] }
expect(cardRows(trimmed)[0].detailtip)
.toBe('Start of a long message that continues well past the card')
})
})
// ---------------------------------------------------------------------------
// The merged queue. These exist because the old grid failed in ways that were
// invisible: a card truncated at 5 of 50 and nothing said where the rest went,
// and severity was declared but the layout ignored it.
// ---------------------------------------------------------------------------
const critA = {
id: 'endpoints-stability', title: 'Endpoints with crashes', render: 'exceptions',
severity: 'critical', position: 20, viewall: '/pcs',
map: { title: 'hostname' },
_data: [{ hostname: 'PC-CMM-TEST' }, { hostname: 'HOST-7' }],
}
const critB = {
id: 'geenforce-failures', title: 'Enforcement failures', render: 'exceptions',
severity: 'critical', position: 10, viewall: '/geenforce',
map: { title: 'hostname' },
_data: [{ hostname: 'WJPC0615' }],
}
const warnA = {
id: 'computers-quiet', title: 'PCs gone silent', render: 'exceptions',
severity: 'warning', position: 30,
map: { title: 'hostname' },
_data: [{ hostname: 'FFBWTH63' }],
}
const infoA = {
id: 'warranty-expiring', title: 'Warranties expiring', render: 'exceptions',
severity: 'info', position: 40, viewall: '/reports/warranty',
map: { title: 'assetnumber' },
_data: Array.from({ length: 50 }, (_, i) => ({ assetnumber: 'A' + i })),
}
describe('queueRows', () => {
it('merges every queue card into one list, worst first', () => {
const queue = queueRows([warnA, critA, critB])
expect(queue.map((r) => r.severity)).toEqual(
['critical', 'critical', 'critical', 'warning'])
})
it('orders by declared position within a severity', () => {
// geenforce is position 10, endpoints 20: a failure outranks a crash.
const queue = queueRows([critA, critB])
expect(queue[0].title).toBe('WJPC0615')
})
it('keeps the source on every row, so a merged row can still say why', () => {
const queue = queueRows([critB])
expect(queue[0].source).toBe('Enforcement failures')
expect(queue[0].sourceid).toBe('geenforce-failures')
expect(queue[0].viewall).toBe('/geenforce')
})
it('leaves info cards out - they are context, not work', () => {
const queue = queueRows([critB, infoA])
expect(queue).toHaveLength(1)
expect(queue.every((r) => r.severity !== 'info')).toBe(true)
})
it('caps one source so a noisy check cannot bury a rare one', () => {
const noisy = { ...critA, _data: Array.from({ length: 40 }, (_, i) => ({ hostname: 'H' + i })) }
const queue = queueRows([noisy, warnA])
expect(queue.filter((r) => r.sourceid === 'endpoints-stability')).toHaveLength(QUEUEPERSOURCE)
// the warning still made it onto the board
expect(queue.some((r) => r.sourceid === 'computers-quiet')).toBe(true)
})
it('caps the whole board', () => {
const many = Array.from({ length: 6 }, (_, i) => ({
...critA, id: 'c' + i, position: i,
_data: Array.from({ length: 5 }, (_, j) => ({ hostname: 'H' + i + j })),
}))
expect(queueRows(many)).toHaveLength(QUEUEMAX)
})
it('gives every row a stable key', () => {
const keys = queueRows([critA, critB]).map((r) => r.key)
expect(new Set(keys).size).toBe(keys.length)
})
})
describe('queueOverflow', () => {
it('reports what was hidden, per source, with somewhere to go', () => {
const noisy = { ...critA, _data: Array.from({ length: 12 }, (_, i) => ({ hostname: 'H' + i })) }
const [entry] = queueOverflow([noisy])
expect(entry.hidden).toBe(7) // 12 rows, 5 shown
expect(entry.viewall).toBe('/pcs')
expect(entry.title).toBe('Endpoints with crashes')
})
it('says nothing when everything is on the board', () => {
expect(queueOverflow([critB, warnA])).toEqual([])
})
it('ignores info cards, which are not in the queue to overflow from', () => {
expect(queueOverflow([infoA])).toEqual([])
})
it('lists the biggest backlog first', () => {
const a = { ...critA, id: 'a', _data: Array.from({ length: 8 }, (_, i) => ({ hostname: 'x' + i })) }
const b = { ...critA, id: 'b', _data: Array.from({ length: 20 }, (_, i) => ({ hostname: 'y' + i })) }
expect(queueOverflow([a, b])[0].id).toBe('b')
})
})
describe('contextCards', () => {
it('is the info cards, sorted', () => {
const context = contextCards([critA, infoA, warnA])
expect(context.map((c) => c.id)).toEqual(['warranty-expiring'])
})
})
describe('severityTotals', () => {
it('counts ROWS, not cards', () => {
// Three cards holding one row each is three things to do, not three cards.
const totals = severityTotals([critA, critB, warnA, infoA])
expect(totals).toEqual({ critical: 3, warning: 1, info: 50 })
})
it('counts a metric card by its value', () => {
const metric = { id: 'm', render: 'metric', severity: 'warning', _data: { value: 7 } }
expect(severityTotals([metric]).warning).toBe(7)
})
it('treats an undeclared severity as info, matching the sort', () => {
const bare = { id: 'b', render: 'exceptions', _data: [{}, {}] }
expect(severityTotals([bare]).info).toBe(2)
})
})
describe('singularise', () => {
it('drops the s at exactly one', () => {
expect(singularise(' days', 1)).toBe(' day')
expect(singularise(' events', 1)).toBe(' event')
})
it('leaves plurals alone', () => {
expect(singularise(' days', 0)).toBe(' days')
expect(singularise(' days', 2)).toBe(' days')
expect(singularise(' days', 19)).toBe(' days')
})
it('singularises the first word and keeps what follows', () => {
// The card that made this matter: ' events (48h)' read "1 events (48h)"
// five rows running once the queue merged.
expect(singularise(' events (48h)', 1)).toBe(' event (48h)')
expect(singularise(' events (48h)', 2)).toBe(' events (48h)')
})
it('handles a trailing word rather than refusing it', () => {
// "1 day ago" is correct; an earlier version left this plural out of
// caution about chopping the wrong token.
expect(singularise(' days ago', 1)).toBe(' day ago')
})
it('leaves a unit that merely ends in s', () => {
expect(singularise(' ms', 1)).toBe(' ms')
expect(singularise('%', 1)).toBe('%')
})
it('handles no suffix', () => {
expect(singularise('', 1)).toBe('')
expect(singularise(undefined, 1)).toBe('')
})
})
describe('cardUnavailable', () => {
// "Nothing is low" and "we cannot tell whether anything is low" are different
// answers. The printer supplies card returned the same empty for both, so a
// dead Zabbix rendered as "Low Supplies 0" - reassurance, not information.
it('reads the reason a card gives for having no rows', () => {
const card = { _data: { rows: [], unavailable: 'Supply monitoring is not reachable' } }
expect(cardUnavailable(card)).toBe('Supply monitoring is not reachable')
})
it('is empty for a card that simply has nothing to report', () => {
expect(cardUnavailable({ _data: { rows: [] } })).toBe('')
expect(cardUnavailable({ _data: [] })).toBe('')
expect(cardUnavailable({ _data: null })).toBe('')
})
it('ignores a non-string, so bad data cannot render as a reason', () => {
expect(cardUnavailable({ _data: { rows: [], unavailable: true } })).toBe('')
})
it('shows an unavailable card even when its empty rule says hide', () => {
const card = {
empty: 'hide', render: 'exceptions',
_data: { rows: [], unavailable: 'Supply monitoring is not reachable' },
}
expect(cardVisible(card)).toBe(true)
})
it('still hides a card that is merely empty', () => {
expect(cardVisible({ empty: 'hide', render: 'exceptions', _data: { rows: [] } })).toBe(false)
})
it('still hides a card whose fetch failed, which is not the same thing', () => {
expect(cardVisible({ empty: 'hide', render: 'exceptions', _data: null })).toBe(false)
})
it('picks out the unavailable ones', () => {
const blind = { id: 'a', _data: { rows: [], unavailable: 'not reachable' } }
const fine = { id: 'b', _data: { rows: [{}] } }
expect(unavailableCards([blind, fine]).map((c) => c.id)).toEqual(['a'])
})
})
describe('sourceFacets', () => {
it('is one chip per contributing check, worst first', () => {
const facets = sourceFacets([warnA, critA, critB])
expect(facets.map((f) => f.key)).toEqual(
['geenforce-failures', 'endpoints-stability', 'computers-quiet'])
})
it('labels with the shortname, since the title is a heading', () => {
const card = { ...critA, shortname: 'Crashes' }
expect(sourceFacets([card])[0].label).toBe('Crashes')
})
it('falls back to the title when no shortname is declared', () => {
expect(sourceFacets([critA])[0].label).toBe('Endpoints with crashes')
})
it('counts every row, not just the ones the queue had room for', () => {
// The chip says how many there are; the queue caps what it shows. A chip
// reading 5 above a "3 more" line would be its own small lie.
const noisy = { ...critA, _data: Array.from({ length: 12 }, (_, i) => ({ hostname: 'H' + i })) }
expect(sourceFacets([noisy])[0].count).toBe(12)
})
it('leaves out checks with nothing to show', () => {
const empty = { ...critA, id: 'empty', _data: [] }
expect(sourceFacets([empty])).toEqual([])
})
it('leaves out info cards, which are not in the queue', () => {
expect(sourceFacets([infoA])).toEqual([])
})
})