Files
shopdb-flask/frontend/src/components/dashboardCards.spec.js
cproudlock b80bd8731f Filter the queue by check, and say when a check cannot answer
TWO CONTROLS, ONE BOARD

Severity answers "how bad". It could not answer "what kind", and on a fleet
where eight endpoints go quiet at once, being able to set those aside and look
at the four crashes is the difference between a board and a list.

Chips per contributing check, coloured by its severity, composing with the
severity rollup above them. Both narrow what is already loaded rather than
refetching, so no combination can disagree with the counts. Picking a severity
that excludes the active chip clears the chip: two controls lit above an empty
queue, neither obviously wrong, is worse than losing the narrower one.

A chip counts every row, not the rows the queue had room for. A chip reading 5
above a "3 more" line would be its own small lie.

"LOW SUPPLIES 0" WAS NOT TRUE

Asked what happened to toner replacement, and the answer was worse than the
layout question that prompted it.

_get_low_supplies_data returned the same empty result for three different
situations: Zabbix not configured, Zabbix unreachable, and every cartridge
full. The dashboard strip therefore displayed "Low Supplies 0 / Critical 0"
while the monitoring was dead - and this instance is exactly that case, with
zabbix_enabled true against a URL nothing answers on.

So the strip removed in the previous commit was not information. It was
reassurance, and it read identically whether the printers were fine or nobody
could see them. That is the failure this codebase already names elsewhere: an
empty card and a broken card must not look the same.

The endpoint now distinguishes the three, and returns an envelope rather than a
bare list so a card can say WHY it has no rows. cardVisible shows such a card
whatever its empty rule, because "I cannot tell" is not "nothing to report".

It renders as a notice line, not a queue row: there is no asset to act on, and
burying "the monitor is dead" among the things it failed to find would be the
same mistake in a new place.

Note what this does NOT fix: the Zabbix URL on this instance is a dev
placeholder. The board will now say so instead of implying the cartridges are
full, but somebody still has to point it at a real server - and it is worth
checking what production points at, since nothing would have shown there either.

Frontend 314 passed (13 new), full suite 2178. The response shape changed from
a list to an envelope; no test depended on the old shape.
2026-08-29 21:16:28 -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([])
})
})