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.
This commit is contained in:
@@ -17,11 +17,39 @@
|
|||||||
<span class="dc-att-n">{{ level.count }}</span>
|
<span class="dc-att-n">{{ level.count }}</span>
|
||||||
<span class="dc-att-label">{{ level.label }}</span>
|
<span class="dc-att-label">{{ level.label }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="filter" type="button" class="dc-att-clear" @click="filter = ''">
|
<button v-if="filter || sourcefilter" type="button" class="dc-att-clear"
|
||||||
|
@click="clearFilters">
|
||||||
Clear filter
|
Clear filter
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- What KIND, next to how bad. When five endpoints go quiet at once,
|
||||||
|
setting those aside to look at the crashes is the difference between a
|
||||||
|
board and a list. Chips reflect the severity filter above them, so the
|
||||||
|
two read as one control rather than two that can contradict. -->
|
||||||
|
<div v-if="facets.length > 1" class="dc-facets">
|
||||||
|
<button
|
||||||
|
v-for="facet in facets"
|
||||||
|
:key="facet.key"
|
||||||
|
type="button"
|
||||||
|
class="dc-facet"
|
||||||
|
:class="['dc-facet-' + facet.severity, { 'dc-facet-on': sourcefilter === facet.key }]"
|
||||||
|
:aria-pressed="sourcefilter === facet.key"
|
||||||
|
@click="toggleSource(facet.key)"
|
||||||
|
>
|
||||||
|
{{ facet.label }}
|
||||||
|
<span class="dc-facet-n">{{ facet.count }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- A check that cannot answer. 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. -->
|
||||||
|
<p v-for="card in unavailable" :key="card.id" class="dc-unavailable">
|
||||||
|
<span class="dc-unavailable-dot" aria-hidden="true"></span>
|
||||||
|
{{ card.title }}: {{ reasonFor(card) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="dc-cols">
|
<div class="dc-cols">
|
||||||
|
|
||||||
<!-- ONE queue, worst first, instead of a card per source. Reading order
|
<!-- ONE queue, worst first, instead of a card per source. Reading order
|
||||||
@@ -116,6 +144,7 @@ import {
|
|||||||
toApiPath, visibleRows, overflowCount, metricValue, cardVisible,
|
toApiPath, visibleRows, overflowCount, metricValue, cardVisible,
|
||||||
permittedCards, renderableCards, rows as cardData,
|
permittedCards, renderableCards, rows as cardData,
|
||||||
queueRows, queueOverflow, contextCards, severityTotals,
|
queueRows, queueOverflow, contextCards, severityTotals,
|
||||||
|
sourceFacets, unavailableCards, cardUnavailable,
|
||||||
} from './dashboardCards'
|
} from './dashboardCards'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -146,9 +175,22 @@ const filteredCards = computed(() => (filter.value
|
|||||||
? liveCards.value.filter((card) => (card.severity || 'info') === filter.value)
|
? liveCards.value.filter((card) => (card.severity || 'info') === filter.value)
|
||||||
: liveCards.value))
|
: liveCards.value))
|
||||||
|
|
||||||
const queue = computed(() => queueRows(filteredCards.value))
|
// Which check to show, on top of the severity filter. Both narrow what is
|
||||||
const overflow = computed(() => queueOverflow(filteredCards.value))
|
// already loaded, so no combination can disagree with the counts.
|
||||||
const context = computed(() => contextCards(filteredCards.value))
|
const sourcefilter = ref('')
|
||||||
|
|
||||||
|
const facets = computed(() => sourceFacets(filteredCards.value))
|
||||||
|
|
||||||
|
const sourceFiltered = computed(() => (sourcefilter.value
|
||||||
|
? filteredCards.value.filter((card) => card.id === sourcefilter.value)
|
||||||
|
: filteredCards.value))
|
||||||
|
|
||||||
|
const unavailable = computed(() => unavailableCards(liveCards.value))
|
||||||
|
|
||||||
|
const queue = computed(() => queueRows(sourceFiltered.value))
|
||||||
|
const overflow = computed(() => queueOverflow(sourceFiltered.value))
|
||||||
|
const context = computed(
|
||||||
|
() => contextCards(filteredCards.value).filter((card) => !cardUnavailable(card)))
|
||||||
|
|
||||||
const queueTotal = computed(
|
const queueTotal = computed(
|
||||||
() => queue.value.length + overflow.value.reduce((sum, e) => sum + e.hidden, 0))
|
() => queue.value.length + overflow.value.reduce((sum, e) => sum + e.hidden, 0))
|
||||||
@@ -157,6 +199,24 @@ const hasBoard = computed(() => liveCards.value.length > 0)
|
|||||||
|
|
||||||
function toggleFilter(level) {
|
function toggleFilter(level) {
|
||||||
filter.value = filter.value === level ? '' : level
|
filter.value = filter.value === level ? '' : level
|
||||||
|
// A source chip that the new severity filter excludes would leave the queue
|
||||||
|
// empty with two controls both looking active and neither obviously wrong.
|
||||||
|
if (sourcefilter.value && !facets.value.some((f) => f.key === sourcefilter.value)) {
|
||||||
|
sourcefilter.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSource(key) {
|
||||||
|
sourcefilter.value = sourcefilter.value === key ? '' : key
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
filter.value = ''
|
||||||
|
sourcefilter.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasonFor(card) {
|
||||||
|
return cardUnavailable(card)
|
||||||
}
|
}
|
||||||
|
|
||||||
function countOf(card) {
|
function countOf(card) {
|
||||||
@@ -244,6 +304,64 @@ defineExpose({ load })
|
|||||||
padding: 0 0.5rem;
|
padding: 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- source chips ---- */
|
||||||
|
.dc-facets {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-facet {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.35rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
.dc-facet:hover { border-color: var(--text-light); }
|
||||||
|
.dc-facet-on {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 1px var(--primary) inset;
|
||||||
|
}
|
||||||
|
.dc-facet-critical { border-left: 3px solid var(--danger); }
|
||||||
|
.dc-facet-warning { border-left: 3px solid var(--warning); }
|
||||||
|
|
||||||
|
.dc-facet-n {
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- a check that cannot answer ---- */
|
||||||
|
.dc-unavailable {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--warning);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
.dc-unavailable-dot {
|
||||||
|
width: 0.45rem;
|
||||||
|
height: 0.45rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--warning);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- two columns ---- */
|
/* ---- two columns ---- */
|
||||||
.dc-cols {
|
.dc-cols {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -184,12 +184,30 @@ export function metricValue(card) {
|
|||||||
// "nothing wrong" every day teaches people to stop reading the page - the same
|
// "nothing wrong" every day teaches people to stop reading the page - the same
|
||||||
// way a fleet log reached 3,234 lines of which 3,217 were one repeated line.
|
// way a fleet log reached 3,234 lines of which 3,217 were one repeated line.
|
||||||
// Default is to disappear; 'line' is for the few where absence is itself news.
|
// Default is to disappear; 'line' is for the few where absence is itself news.
|
||||||
|
// Why a card has no rows, when it knows. "Nothing is low" and "we cannot tell
|
||||||
|
// whether anything is low" are different answers, and a card that gives the
|
||||||
|
// same empty for both is not merely unhelpful - it reads as reassurance. The
|
||||||
|
// printer supplies card returns this when Zabbix is unconfigured or
|
||||||
|
// unreachable, which is how a dead monitor used to render as "Low Supplies 0".
|
||||||
|
export function cardUnavailable(card) {
|
||||||
|
const data = card._data
|
||||||
|
if (!data || Array.isArray(data)) return ''
|
||||||
|
return typeof data.unavailable === 'string' ? data.unavailable : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unavailableCards(cards) {
|
||||||
|
return cards.filter((card) => cardUnavailable(card))
|
||||||
|
}
|
||||||
|
|
||||||
export function cardVisible(card) {
|
export function cardVisible(card) {
|
||||||
if (card._data === null || card._data === undefined) return false // fetch failed
|
if (card._data === null || card._data === undefined) return false // fetch failed
|
||||||
const populated = card.render === 'metric'
|
const populated = card.render === 'metric'
|
||||||
? metricValue(card) > 0
|
? metricValue(card) > 0
|
||||||
: rows(card).length > 0
|
: rows(card).length > 0
|
||||||
if (populated) return true
|
if (populated) return true
|
||||||
|
// A card that cannot answer says so, whatever its empty rule: 'hide' means
|
||||||
|
// "nothing to report", and this is not nothing to report.
|
||||||
|
if (cardUnavailable(card)) return true
|
||||||
return card.empty === 'line'
|
return card.empty === 'line'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +277,30 @@ function severityRank(severity) {
|
|||||||
* Each row keeps the fields its own card mapped, plus where it came from, so a
|
* Each row keeps the fields its own card mapped, plus where it came from, so a
|
||||||
* merged row can still say which check produced it.
|
* merged row can still say which check produced it.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The checks currently contributing to the queue, for the filter chips:
|
||||||
|
* [{key, label, count}] ordered worst-first then biggest-first.
|
||||||
|
*
|
||||||
|
* Severity answers "how bad"; this answers "what kind". On a fleet where five
|
||||||
|
* endpoints go quiet at once, being able to set those aside and look at the
|
||||||
|
* crashes is the difference between a board and a list.
|
||||||
|
*/
|
||||||
|
export function sourceFacets(cards) {
|
||||||
|
const seen = new Map()
|
||||||
|
for (const card of sortCards(cards.filter(
|
||||||
|
(card) => QUEUE_SEVERITIES.includes(card.severity)))) {
|
||||||
|
const count = rows(card).length
|
||||||
|
if (!count) continue
|
||||||
|
seen.set(card.id, {
|
||||||
|
key: card.id,
|
||||||
|
label: card.shortname || card.title || card.id,
|
||||||
|
count,
|
||||||
|
severity: card.severity || 'info',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return [...seen.values()]
|
||||||
|
}
|
||||||
|
|
||||||
export function queueRows(cards) {
|
export function queueRows(cards) {
|
||||||
const out = []
|
const out = []
|
||||||
for (const card of sortCards(cards.filter(
|
for (const card of sortCards(cards.filter(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
visibleRows, overflowCount,
|
visibleRows, overflowCount,
|
||||||
queueRows, queueOverflow, contextCards, severityTotals,
|
queueRows, queueOverflow, contextCards, severityTotals,
|
||||||
QUEUEPERSOURCE, QUEUEMAX, singularise,
|
QUEUEPERSOURCE, QUEUEMAX, singularise,
|
||||||
|
cardUnavailable, unavailableCards, sourceFacets,
|
||||||
} from './dashboardCards'
|
} from './dashboardCards'
|
||||||
|
|
||||||
const failuresCard = {
|
const failuresCard = {
|
||||||
@@ -392,3 +393,78 @@ describe('singularise', () => {
|
|||||||
expect(singularise(undefined, 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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -2124,8 +2124,16 @@ def _get_low_supplies_data():
|
|||||||
return cached
|
return cached
|
||||||
|
|
||||||
service = ZabbixService()
|
service = ZabbixService()
|
||||||
if not service.isconfigured or not service.isreachable:
|
# "No printer is low" and "we cannot tell whether any printer is low" are
|
||||||
return {'printers': [], 'summary': {'total_checked': 0, 'low': 0, 'critical': 0}}
|
# different answers, and this used to give the same empty one for both. The
|
||||||
|
# dashboard then showed Low Supplies 0 while the monitoring was dead, which
|
||||||
|
# is not a missing feature but a wrong statement: it reads as reassurance.
|
||||||
|
if not service.isconfigured:
|
||||||
|
return {'printers': [], 'unavailable': 'Supply monitoring is not configured',
|
||||||
|
'summary': {'total_checked': 0, 'low': 0, 'critical': 0}}
|
||||||
|
if not service.isreachable:
|
||||||
|
return {'printers': [], 'unavailable': 'Supply monitoring is not reachable',
|
||||||
|
'summary': {'total_checked': 0, 'low': 0, 'critical': 0}}
|
||||||
|
|
||||||
# active printers with an IP, with vendor and model for waste/part rules
|
# active printers with an IP, with vendor and model for waste/part rules
|
||||||
rows = (
|
rows = (
|
||||||
@@ -2655,7 +2663,10 @@ def dashboard_supplies():
|
|||||||
})
|
})
|
||||||
|
|
||||||
rows.sort(key=lambda r: (not r['iscritical'], r['printername']))
|
rows.sort(key=lambda r: (not r['iscritical'], r['printername']))
|
||||||
return success_response(rows)
|
# Envelope rather than a bare list, so the card can say WHY it has no rows.
|
||||||
|
# An empty card and a blind card must not look the same.
|
||||||
|
return success_response({'rows': rows,
|
||||||
|
'unavailable': data.get('unavailable')})
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user