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.
This commit is contained in:
2026-08-29 21:35:03 -04:00
parent 330fd31524
commit 870e068c44
17 changed files with 1191 additions and 306 deletions

View File

@@ -1,71 +1,136 @@
<template>
<div v-if="visibleCards.length" class="dc-grid">
<section
v-for="card in visibleCards"
:key="card.id"
class="dc-card"
:class="'dc-' + (card.severity || 'info')"
>
<header class="dc-head">
<span class="dc-dot" aria-hidden="true"></span>
<h3 class="dc-title">{{ card.title }}</h3>
<span v-if="card.render !== 'metric'" class="dc-count">{{ countOf(card) }}</span>
</header>
<div v-if="hasBoard" class="dc-board">
<!-- metric: the count IS the story -->
<p v-if="card.render === 'metric'" class="dc-metric">{{ metricValue(card) }}</p>
<!-- The rollup. Counts ROWS, not cards, and each count filters the queue
below rather than navigating away: landing on a number that tells you
whether to keep reading is the one thing an RMM board gets right. -->
<div v-if="totalRows" class="dc-attention">
<button
v-for="level in attentionLevels"
:key="level.key"
type="button"
class="dc-att"
:class="['dc-att-' + level.key, { 'dc-att-on': filter === level.key }]"
:aria-pressed="filter === level.key"
@click="toggleFilter(level.key)"
>
<span class="dc-att-n">{{ level.count }}</span>
<span class="dc-att-label">{{ level.label }}</span>
</button>
<button v-if="filter || sourcefilter" type="button" class="dc-att-clear"
@click="clearFilters">
Clear filter
</button>
</div>
<!-- exceptions / list: one line per thing, each linking to itself -->
<ul v-else class="dc-rows">
<li v-for="(row, index) in visibleRows(card)" :key="index" class="dc-row"
:class="{ 'dc-row-stacked': card.layout === 'stacked' }">
<!-- With coordinates, the name carries the same floor-plan preview
the asset's own page uses. Without them, a plain link. -->
<LocationMapTooltip v-if="row.link && row.maphover"
:left="row.maphover.x" :top="row.maphover.y" :levelid="row.maphover.levelid"
:machineName="row.maphover.label">
<router-link :to="row.link" class="dc-row-title"
:title="row.titletip || undefined">
{{ row.title }}
</router-link>
</LocationMapTooltip>
<router-link v-else-if="row.link" :to="row.link" class="dc-row-title"
:title="row.titletip || undefined">
{{ row.title }}
<!-- 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">
<!-- ONE queue, worst first, instead of a card per source. Reading order
is work order. -->
<section v-if="queue.length || overflow.length" class="dc-panel dc-queue">
<header class="dc-panel-head">
<h3>Needs attention</h3>
<span class="dc-panel-count">{{ queueTotal }}</span>
</header>
<ul class="dc-rows">
<DashboardRow
v-for="row in queue"
:key="row.key"
:row="row"
:severity="row.severity"
showsource
/>
</ul>
<!-- What the board could not fit, per source and with somewhere to go.
A bare "and 45 more" that leads nowhere is worse than silence. -->
<p v-for="entry in overflow" :key="entry.id" class="dc-more">
<router-link v-if="entry.viewall" :to="entry.viewall" class="dc-more-link">
{{ entry.hidden }} more {{ entry.title.toLowerCase() }}
</router-link>
<span v-else class="dc-row-title dc-row-nolink"
:title="row.titletip || undefined">{{ row.title }}</span>
<span v-if="row.detail" class="dc-row-detail"
:title="row.detailtip || undefined">{{ row.detail }}</span>
<span v-for="(chip, c) in row.chips" :key="c" class="dc-chip"
:class="'dc-chip-' + (chip.level || 'low')" :title="chip.title">
{{ chip.text }}
</span>
<span v-if="row.meta.length" class="dc-row-meta">
{{ row.meta.map((m) => m.text).join(' / ') }}
</span>
</li>
</ul>
<span v-else>{{ entry.hidden }} more {{ entry.title.toLowerCase() }}</span>
</p>
</section>
<!-- The overflow line goes somewhere. Telling someone 35 more PCs are
quiet and leaving them to find the list is worse than not saying it.
A card without a viewall still says the count, because the number
itself is information. -->
<router-link v-if="overflowCount(card) && card.viewall"
:to="card.viewall" class="dc-more dc-more-link">
and {{ overflowCount(card) }} more
</router-link>
<p v-else-if="overflowCount(card)" class="dc-more">
and {{ overflowCount(card) }} more
</p>
</section>
<section v-else class="dc-panel dc-clear">
<p class="dc-clear-line">Nothing needs attention.</p>
</section>
<aside class="dc-rail">
<!-- The page owns what sits above the context cards: inventory is not
an exception, so it is not this component's to declare. -->
<slot name="rail"></slot>
<section v-for="card in context" :key="card.id" class="dc-panel">
<header class="dc-panel-head">
<h3>{{ card.title }}</h3>
<span class="dc-panel-count">{{ countOf(card) }}</span>
</header>
<p v-if="card.render === 'metric'" class="dc-metric">{{ metricValue(card) }}</p>
<ul v-else class="dc-rows">
<DashboardRow
v-for="(row, index) in visibleRows(card)"
:key="index"
:row="row"
:stacked="card.layout === 'stacked'"
/>
</ul>
<router-link v-if="overflowCount(card) && card.viewall"
:to="card.viewall" class="dc-more dc-more-link">
and {{ overflowCount(card) }} more
</router-link>
<p v-else-if="overflowCount(card)" class="dc-more">
and {{ overflowCount(card) }} more
</p>
</section>
</aside>
</div>
</div>
</template>
<script setup>
// Renders the dashboard cards plugins declare via get_dashboard_widgets
// (GET /api/dashboard/widgets). Core owns the renderers; a plugin declares data
// and shape. See docs/proposals/dashboard-live-fleet.md.
// The fleet dashboard. Plugins declare cards via get_dashboard_widgets
// (GET /api/dashboard/widgets); core owns the drawing. See
// docs/proposals/dashboard-live-fleet.md.
//
// LAYOUT: this used to be one card per source in a fixed grid, and both halves
// hurt. Every source got the same small box whatever it had to say, so
// warranties showed 5 of 50 while a one-row card beside it left a hole; and a
// box per source flattened priority, because an enforcement failure and a
// warranty notice look equally urgent when each owns a card. Now the
// critical and warning rows merge into one worst-first queue, and info cards
// sit in a rail as context. The severity driving it was always declared - the
// old layout just threw it away.
//
// Each card fetches INDEPENDENTLY and a failure is swallowed to null, so one
// slow or broken endpoint - a hung Zabbix call, a plugin mid-upgrade - cannot
@@ -74,16 +139,85 @@
import { ref, computed, onMounted } from 'vue'
import api from '../api'
import { useAuthStore } from '@/stores/auth'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
import DashboardRow from '@/components/DashboardRow.vue'
import {
toApiPath, visibleRows, overflowCount, metricValue, cardVisible, sortCards,
toApiPath, visibleRows, overflowCount, metricValue, cardVisible,
permittedCards, renderableCards, rows as cardData,
queueRows, queueOverflow, contextCards, severityTotals,
sourceFacets, unavailableCards, cardUnavailable,
} from './dashboardCards'
const auth = useAuthStore()
const cards = ref([])
const visibleCards = computed(() => sortCards(cards.value.filter(cardVisible)))
// Severity filter from the rollup. Empty means show everything.
const filter = ref('')
const liveCards = computed(() => cards.value.filter(cardVisible))
const totals = computed(() => severityTotals(liveCards.value))
const attentionLevels = computed(() => {
const totalcounts = totals.value
return [
{ key: 'critical', label: 'Critical', count: totalcounts.critical },
{ key: 'warning', label: 'Needs a look', count: totalcounts.warning },
{ key: 'info', label: 'For information', count: totalcounts.info },
].filter((level) => level.count > 0)
})
const totalRows = computed(
() => attentionLevels.value.reduce((sum, level) => sum + level.count, 0))
// The filter narrows what is already on the board rather than refetching: the
// counts and the rows then cannot disagree.
const filteredCards = computed(() => (filter.value
? liveCards.value.filter((card) => (card.severity || 'info') === filter.value)
: liveCards.value))
// Which check to show, on top of the severity filter. Both narrow what is
// already loaded, so no combination can disagree with the counts.
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(
() => queue.value.length + overflow.value.reduce((sum, e) => sum + e.hidden, 0))
const hasBoard = computed(() => liveCards.value.length > 0)
function toggleFilter(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) {
return cardData(card).length
@@ -118,154 +252,191 @@ defineExpose({ load })
</script>
<style scoped>
.dc-grid {
display: grid;
/* Four across on a wide screen. A card holds a hostname, a machine number
and a state on one line, so the track cannot go much below this without
truncating the rows that matter most - but 28rem only fits three once the
sidebar takes its share at 1920, which is the common case here. auto-fit
rather than auto-fill so two cards fill the width instead of leaving
empty tracks. */
grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
max-width: 120rem;
gap: 1rem;
margin-bottom: 1.75rem;
align-items: start;
.dc-board { margin-bottom: 1.75rem; }
/* ---- rollup ---- */
.dc-attention {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-bottom: 1rem;
}
/* Four is the ceiling, not a consequence of the track width. Left to auto-fit
alone this reaches five on an ultrawide, and a fifth column buys nothing -
the cards get narrower and the rows they hold start truncating again. */
@media (min-width: 96rem) {
.dc-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.dc-att {
display: flex;
align-items: center;
gap: 0.55rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 3px solid var(--border);
border-radius: 8px;
padding: 0.5rem 0.9rem;
cursor: pointer;
font: inherit;
color: inherit;
}
.dc-card {
.dc-att:hover { border-color: var(--text-light); }
.dc-att-on { box-shadow: 0 0 0 2px var(--primary) inset; }
.dc-att-critical { border-left-color: var(--danger); }
.dc-att-warning { border-left-color: var(--warning); }
.dc-att-info { border-left-color: var(--primary); }
.dc-att-n {
font-size: 1.35rem;
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.dc-att-critical .dc-att-n { color: var(--danger); }
.dc-att-warning .dc-att-n { color: var(--warning); }
.dc-att-info .dc-att-n { color: var(--primary); }
.dc-att-label { font-size: 0.85rem; color: var(--text-light); }
.dc-att-clear {
background: none;
border: none;
color: var(--link);
cursor: pointer;
font: inherit;
font-size: 0.85rem;
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 ---- */
.dc-cols {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 120rem;
}
/* Context sits BELOW the queue, in a row, rather than in a side rail. The
queue then gets the full width - which is what "not enough room to relay the
data" was about - and a quiet day leaves no column-shaped void where the
queue ran out of rows. */
.dc-rail {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
gap: 1rem;
align-items: start;
min-width: 0;
}
/* ---- panels ---- */
.dc-panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.85rem 1rem 0.9rem;
/* Nothing escapes the card. A printer row carrying three cartridge readings
plus a location was pushing past the border: a flex child will not shrink
below its content width unless told to, so text-overflow never engaged and
the row simply overflowed. min-width:0 below is what actually enables the
ellipsis; this is the backstop. */
overflow: hidden;
min-width: 0;
/* Nothing escapes the panel: a printer row carrying three cartridge readings
plus a location was pushing past the border, because a flex child will not
shrink below its content unless told to. */
overflow: hidden;
}
/* Severity is a small dot beside the title, not a coloured card or a thick
bar. A card painted by severity reads as an alert even when it holds one
minor row, and six of them read as a crisis - which is how a board stops
being read at all. */
.dc-dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; flex: none; }
.dc-critical .dc-dot { background: var(--danger); }
.dc-warning .dc-dot { background: var(--warning); }
.dc-info .dc-dot { background: var(--primary); }
.dc-head {
.dc-panel-head {
display: flex;
align-items: center;
align-items: baseline;
gap: 0.5rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid var(--border);
margin-bottom: 0.35rem;
}
.dc-title {
.dc-panel-head h3 {
margin: 0;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-light);
flex: 1;
flex: 1 1 auto;
}
.dc-count {
font-size: 0.8rem;
.dc-panel-count {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-light);
font-variant-numeric: tabular-nums;
}
.dc-metric {
margin: 0.5rem 0 0;
font-size: 2rem;
font-weight: 600;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.dc-metric { margin: 0.5rem 0 0; font-size: 2rem; font-weight: 600; color: var(--text); }
/* One row per line, not a wrapped paragraph. Each row is title / detail /
meta on a single line that truncates, so ten rows are ten scannable lines
rather than a block of text that has to be read. */
.dc-rows { list-style: none; margin: 0; padding: 0; }
.dc-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
min-width: 0;
}
.dc-row:last-child { border-bottom: none; }
/* Stacked: a label on one line, prose beneath. Inline, the label is truncated
to make room for text that is then truncated anyway, and neither reads. */
.dc-row-stacked { flex-direction: column; align-items: stretch; gap: 0.15rem; }
.dc-row-stacked .dc-row-title { max-width: 100%; }
.dc-row-stacked .dc-row-detail { white-space: normal; color: var(--text-light); }
.dc-row-stacked .dc-row-meta { margin-left: 0; padding-left: 0; }
.dc-row-title {
font-weight: 600;
color: var(--link);
text-decoration: none;
flex: 0 1 auto;
min-width: 0;
max-width: 45%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dc-row-title:hover { text-decoration: underline; }
.dc-row-nolink { color: var(--text); }
.dc-row-detail {
color: var(--text);
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Meta is pushed right and allowed to disappear first: it is the least
important part of the line, and letting it wrap is what made rows look
like paragraphs. */
.dc-row-meta {
margin-left: auto;
padding-left: 0.5rem;
color: var(--text-light);
font-size: 0.8rem;
white-space: nowrap;
/* Shrinks and truncates before the title or detail do: it is the least
important part of the line, and it was the part running off the edge. */
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* One chip per depleted supply. Bordered rather than filled: a row of solid
red pills reads as an emergency even when a cartridge is merely low. */
.dc-chip {
flex: none;
padding: 0.05rem 0.4rem;
border: 1px solid var(--border);
border-radius: 10px;
font-size: 0.75rem;
color: var(--text);
white-space: nowrap;
cursor: help;
}
.dc-chip-critical { border-color: var(--danger); color: var(--danger); }
.dc-chip-low { border-color: var(--warning); }
.dc-more {
display: block;
margin: 0.5rem 0 0;
font-size: 0.8rem;
font-size: 0.85rem;
color: var(--text-light);
}
.dc-more-link { color: var(--link); text-decoration: none; }
.dc-more-link:hover { text-decoration: underline; }
.dc-clear-line {
margin: 0.25rem 0;
color: var(--text-light);
}
</style>

View File

@@ -0,0 +1,154 @@
<template>
<li class="dc-row" :class="{ 'dc-row-stacked': stacked }">
<span v-if="severity" class="dc-row-dot" :class="'dc-dot-' + severity" aria-hidden="true"></span>
<!-- With coordinates, the name carries the same floor-plan preview the
asset's own page uses. Without them, a plain link. -->
<LocationMapTooltip v-if="row.link && row.maphover"
:left="row.maphover.x" :top="row.maphover.y" :levelid="row.maphover.levelid"
:machineName="row.maphover.label">
<router-link :to="row.link" class="dc-row-title" :title="row.titletip || undefined">
{{ row.title }}
</router-link>
</LocationMapTooltip>
<router-link v-else-if="row.link" :to="row.link" class="dc-row-title"
:title="row.titletip || undefined">
{{ row.title }}
</router-link>
<span v-else class="dc-row-title dc-row-nolink"
:title="row.titletip || undefined">{{ row.title }}</span>
<!-- Which check produced this row. Only in the merged queue, where rows
from different sources sit together and the title alone no longer says
what is wrong with the thing. -->
<span v-if="showsource && row.source" class="dc-row-source"
:title="row.source">{{ row.source }}</span>
<span v-if="row.detail" class="dc-row-detail"
:title="row.detailtip || undefined">{{ row.detail }}</span>
<span v-for="(chip, c) in row.chips" :key="c" class="dc-chip"
:class="'dc-chip-' + (chip.level || 'low')" :title="chip.title">
{{ chip.text }}
</span>
<span v-if="row.meta && row.meta.length" class="dc-row-meta">
{{ row.meta.map((m) => m.text).join(' / ') }}
</span>
</li>
</template>
<script setup>
/**
* One row of the dashboard board.
*
* Extracted from DashboardCards.vue when the six per-source cards became one
* merged queue plus a context rail: both render the same row, and the map
* tooltip, link fallbacks and chips are too easy to get subtly different in a
* second copy.
*/
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
defineProps({
row: { type: Object, required: true },
// Severity dot, drawn only in the merged queue. In a single-source panel the
// panel header already says the severity, and a dot on every row is noise.
severity: { type: String, default: '' },
showsource: { type: Boolean, default: false },
stacked: { type: Boolean, default: false },
})
</script>
<style scoped>
.dc-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--border);
min-width: 0;
}
.dc-row:last-child { border-bottom: none; }
.dc-row-stacked { flex-direction: column; align-items: stretch; gap: 0.15rem; }
.dc-row-stacked .dc-row-title { max-width: 100%; }
.dc-row-stacked .dc-row-detail { white-space: normal; color: var(--text-light); }
.dc-row-stacked .dc-row-meta { margin-left: 0; padding-left: 0; }
.dc-row-dot {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
flex: none;
align-self: center;
}
.dc-dot-critical { background: var(--danger); }
.dc-dot-warning { background: var(--warning); }
.dc-dot-info { background: var(--primary); }
.dc-row-title {
color: var(--link);
text-decoration: none;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 0 1 auto;
min-width: 0;
}
.dc-row-title:hover { text-decoration: underline; }
.dc-row-nolink { color: var(--text); }
/* Which check produced a queued row. Cards declare a `shortname` for this,
because their `title` is a card header - "Endpoints with crashes or
shutdowns" - and as a row badge it ran wider than the hostname it qualified,
putting the badge rather than the asset at the start of every line.
Truncating the header instead was worse: four consecutive rows reading
"ENDPOINTS WITH CR..." carry no information at all, and the badge's one real
job is separating the two checks that read alike, PCs gone silent and
endpoints gone quiet. The cap stays as a guard for a plugin that declares no
shortname. */
.dc-row-source {
flex: 0 1 auto;
max-width: 11rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.68rem;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--text-light);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 0.3rem;
}
.dc-row-detail {
color: var(--text-light);
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1 1 auto;
min-width: 0;
}
.dc-row-meta {
margin-left: auto;
padding-left: 0.5rem;
color: var(--text-light);
font-size: 0.8rem;
white-space: nowrap;
flex: none;
font-variant-numeric: tabular-nums;
}
.dc-chip {
flex: none;
font-size: 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 0.3rem;
color: var(--text-light);
}
.dc-chip-critical { border-color: var(--danger); color: var(--danger); }
.dc-chip-low { border-color: var(--warning); }
</style>

View File

@@ -51,6 +51,26 @@ export function mapDetailTip(card, item) {
return full && full !== mapDetail(card, item) ? full : ''
}
// "silent 1 days". A suffix is written for the common case, which is plural,
// and the card declaring it has no way to know the value. One row saying it was
// a shrug; the merged queue puts every quiet endpoint on one screen, so it now
// says it five times in a column and reads as broken.
//
// Trims a trailing 's' at exactly 1, on the FIRST word only, keeping whatever
// follows it: ' events (48h)' becomes ' event (48h)'.
//
// The length floor is not arbitrary. It stops abbreviated units being mangled,
// since 'ms' and 'hrs' end in s and are not plurals - a test asserting ' ms'
// stays ' ms' is what caught that. The first version also refused multiword
// suffixes outright to avoid chopping the wrong token, which was too blunt:
// it left ' events (48h)' plural, and capturing the word fixes the same hazard
// properly.
export function singularise(suffix, value) {
const text = suffix || ''
if (Number(value) !== 1) return text
return text.replace(/^ ([a-z]{3,})s(?=$|[ (])/, ' $1')
}
export function mapMeta(card, item) {
const meta = (card.map && card.map.meta) || []
return meta
@@ -59,7 +79,7 @@ export function mapMeta(card, item) {
if (value === '') return null
// label prefixes, suffix trails: 'quiet for 3 days' reads as a sentence,
// where 'quiet for 3' reads as a truncation.
const text = `${spec.label ? spec.label + ' ' : ''}${value}${spec.suffix || ''}`
const text = `${spec.label ? spec.label + ' ' : ''}${value}${singularise(spec.suffix, value)}`
return { text, mono: !!spec.mono }
})
.filter(Boolean)
@@ -164,12 +184,30 @@ export function metricValue(card) {
// "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.
// 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) {
if (card._data === null || card._data === undefined) return false // fetch failed
const populated = card.render === 'metric'
? metricValue(card) > 0
: rows(card).length > 0
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'
}
@@ -202,3 +240,138 @@ export const RENDERERS = ['exceptions', 'metric', 'list']
export function renderableCards(cards) {
return cards.filter((card) => RENDERERS.includes(card.render))
}
// ---------------------------------------------------------------------------
// THE MERGED QUEUE
//
// The board used to be one card per source in a fixed grid, and both halves of
// that hurt. Every source got the same small box whatever it had to say, so
// warranties showed 5 of 50 while a one-row card sat beside it leaving a hole;
// and giving each source its own box flattened priority, because an enforcement
// failure and a warranty notice look equally urgent when each owns a card.
//
// Merging them into one list sorted worst-first makes reading order the work
// order. The severity that drives it is already declared per card - this only
// stops the layout from throwing it away.
// ---------------------------------------------------------------------------
// Severities that are somebody's job right now, in the order they are worked.
export const QUEUE_SEVERITIES = ['critical', 'warning']
// Rows one source may contribute to the queue. Without a per-source cap, fifty
// expiring warranties bury two crashed PCs; the cap is what keeps a noisy
// source from crowding out a rare one.
export const QUEUEPERSOURCE = 5
// Total rows on the board. Past this it is a report, not something to scan.
export const QUEUEMAX = 12
function severityRank(severity) {
const rank = SEVERITY_ORDER[severity]
return rank === undefined ? 3 : rank
}
/**
* One flat, worst-first list of rows from every queue-severity card.
*
* 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.
*/
/**
* 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) {
const out = []
for (const card of sortCards(cards.filter(
(card) => QUEUE_SEVERITIES.includes(card.severity)))) {
const taken = cardRows(card).slice(0, QUEUEPERSOURCE)
taken.forEach((row, index) => {
out.push({
...row,
key: card.id + ':' + index,
severity: card.severity || 'info',
// The badge in a merged queue. Cards declare `shortname` for this:
// their `title` is a card HEADER ("Endpoints with crashes or
// shutdowns") and ran wider than the hostname it qualified, putting the
// badge rather than the asset at the start of every line. Falls back to
// the title so a plugin that has not declared one still reads.
source: card.shortname || card.title || '',
sourceid: card.id,
viewall: card.viewall || '',
})
})
}
return out.slice(0, QUEUEMAX)
}
/**
* What the queue could not show, per source: [{id, title, hidden, viewall}].
*
* Kept per source rather than as one number because the number alone is not
* actionable - "and 45 more" with no way to reach them is worse than silence,
* and each source has its own list to link to.
*/
export function queueOverflow(cards) {
const shown = {}
for (const row of queueRows(cards)) {
shown[row.sourceid] = (shown[row.sourceid] || 0) + 1
}
return cards
.filter((card) => QUEUE_SEVERITIES.includes(card.severity))
.map((card) => ({
id: card.id,
title: card.title || '',
hidden: Math.max(0, rows(card).length - (shown[card.id] || 0)),
viewall: card.viewall || '',
}))
.filter((entry) => entry.hidden > 0)
.sort((a, b) => b.hidden - a.hidden)
}
/**
* Cards that are context rather than work - the info severity. They keep their
* own list rendering in a rail beside the queue, because 50 expiring warranties
* are worth knowing about and are not worth pushing a crashed PC down the page.
*/
export function contextCards(cards) {
return sortCards(cards.filter(
(card) => !QUEUE_SEVERITIES.includes(card.severity)))
}
/**
* Row counts by severity, for the rollup above the queue: {critical, warning,
* info}. Counts ROWS, not cards - three cards holding one row each is three
* things to do, not three.
*/
export function severityTotals(cards) {
const totals = { critical: 0, warning: 0, info: 0 }
for (const card of cards) {
const severity = card.severity || 'info'
if (totals[severity] === undefined) continue
totals[severity] += card.render === 'metric'
? metricValue(card)
: rows(card).length
}
return totals
}

View File

@@ -3,6 +3,9 @@ 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 = {
@@ -226,3 +229,242 @@ describe('stacked rows', () => {
.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([])
})
})

View File

@@ -7,147 +7,168 @@
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<!-- What needs a person, before the totals that are true every day.
Cards are declared by plugins (get_dashboard_widgets) and rendered
generically; an empty or failed card hides itself. See
docs/proposals/dashboard-live-fleet.md. -->
<DashboardCards />
<!-- The board: a severity rollup, one worst-first queue of what needs a
person, and a rail of context. Cards are declared by plugins
(get_dashboard_widgets) and rendered generically; an empty or failed
card hides itself. See docs/proposals/dashboard-live-fleet.md.
<!-- Inventory context, below the exceptions.
These used to mix two different things without saying so: two tiles
counted asset TYPES and two counted asset STATUSES, and the status
one was labelled "Active", which reads as "not deleted" when it
actually meant status = In Use across every type. Now every tile
counts one thing and its label says which. -->
<div class="dashboard-grid">
<div class="stat-card">
<div class="label">Machines</div>
<div class="value">{{ stats.totalmachines || 0 }}</div>
</div>
<div class="stat-card">
<div class="label">PCs</div>
<div class="value">{{ stats.totalpc || 0 }}</div>
</div>
<div class="stat-card">
<div class="label">Printers</div>
<div class="value">{{ stats.totalprinter || 0 }}</div>
</div>
<div class="stat-card">
<div class="label">Network devices</div>
<div class="value">{{ stats.totalnetwork || 0 }}</div>
</div>
<div class="stat-card">
<div class="label">All assets</div>
<div class="value">{{ stats.totalassets || 0 }}</div>
</div>
<div class="stat-card success">
<div class="label">All assets in use</div>
<div class="value">{{ stats.activeassets || 0 }}</div>
</div>
<div class="stat-card warning">
<div class="label">All assets in repair</div>
<div class="value">{{ stats.inrepair || 0 }}</div>
</div>
</div>
Inventory goes in the rail through the slot, because a count is not
an exception and DashboardCards has no business declaring one. -->
<DashboardCards>
<template #rail>
<section class="dc-panel estate">
<header class="estate-head">
<h3>Estate</h3>
<router-link to="/reports" class="estate-link">Reports</router-link>
</header>
<!-- Printer Stats -->
<div class="card">
<div class="card-header">
<h3>Printers</h3>
<router-link to="/printers" class="btn btn-secondary btn-sm">View All</router-link>
</div>
<!-- One line per asset type, then the total under a rule.
These used to be seven equal tiles in which four of them summed
to a fifth: machines + PCs + printers + network IS all assets,
shown beside its own addends at the same weight. A total that
looks like a sixth category invites people to add it in again.
Two of the seven also counted STATUS rather than type without
saying so. -->
<div class="estate-row">
<router-link to="/machines" class="estate-k">Machines</router-link>
<span class="estate-v">{{ stats.totalmachines || 0 }}</span>
</div>
<div class="estate-row">
<router-link to="/pcs" class="estate-k">PCs</router-link>
<span class="estate-v">{{ stats.totalpc || 0 }}</span>
</div>
<div class="estate-row">
<router-link to="/printers" class="estate-k">Printers</router-link>
<span class="estate-v">{{ stats.totalprinter || 0 }}</span>
</div>
<div class="estate-row">
<router-link to="/network" class="estate-k">Network devices</router-link>
<span class="estate-v">{{ stats.totalnetwork || 0 }}</span>
</div>
<div class="dashboard-grid">
<div class="stat-card">
<div class="label">Total Printers</div>
<div class="value">{{ printerStats.totalprinters || 0 }}</div>
</div>
<div class="stat-card success">
<div class="label">Online</div>
<div class="value">{{ printerStats.online || 0 }}</div>
</div>
<div class="stat-card warning">
<div class="label">Low Supplies</div>
<div class="value">{{ printerStats.lowsupplies || 0 }}</div>
</div>
<div class="stat-card danger">
<div class="label">Critical</div>
<div class="value">{{ printerStats.criticalsupplies || 0 }}</div>
</div>
</div>
</div>
<div class="estate-row estate-total">
<span class="estate-k">All assets</span>
<span class="estate-v">{{ stats.totalassets || 0 }}</span>
</div>
<!-- Recent Devices -->
<div class="card">
<div class="card-header">
<h3>Recent Devices</h3>
<router-link to="/machines" class="btn btn-secondary btn-sm">View All</router-link>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Asset #</th>
<th>Type</th>
<th>Status</th>
<th>Business Unit</th>
</tr>
</thead>
<tbody>
<tr v-for="machine in recentMachines" :key="machine.assetid">
<td>{{ machine.assetnumber || machine.name || '-' }}</td>
<td>{{ machine.assettypename || '-' }}</td>
<td>{{ machine.statusname || '-' }}</td>
<td>{{ machine.businessunitname || '-' }}</td>
</tr>
<tr v-if="recentMachines.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No devices found
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Status, on one line, and explicitly labelled as status. In
repair is muted at zero rather than given a card of its own to
announce that nothing is wrong. -->
<p class="estate-status">
<span class="estate-inuse">{{ stats.activeassets || 0 }}</span> in use
<span aria-hidden="true"> / </span>
<span :class="{ 'estate-zero': !stats.inrepair }">
{{ stats.inrepair || 0 }} in repair
</span>
</p>
</section>
</template>
</DashboardCards>
</template>
</div>
</template>
<script setup>
// WHAT THIS PAGE DROPPED, and why, so it does not come back by accident:
//
// The printer strip (Total / Online / Low Supplies / Critical). It repeated the
// printer count a third time - the tile row and "Total Printers" were already
// the same number - and "Online" equalled total, so three tiles carried one
// fact. Supplies are already a dashboard card the printers plugin declares
// (printers-supplies, severity warning), so a genuinely low cartridge reaches
// the queue where it can be acted on, instead of sitting in a tile reading 0.
//
// Recent Devices. It was the largest block on the page and listed five machines
// that were all "In Use" with business unit "TBD". Recency is not a reason to
// look at something, and it cost more vertical space than everything that
// needed a person put together. A filtered "recently changed" view belongs in
// Reports.
import DashboardCards from '@/components/DashboardCards.vue'
import { ref, onMounted } from 'vue'
import { dashboardApi, assetsApi, printersApi } from '../api'
import { dashboardApi } from '../api'
const loading = ref(true)
const stats = ref({})
const printerStats = ref({})
const recentMachines = ref([])
onMounted(async () => {
try {
const [dashRes, machinesRes, printersRes] = await Promise.all([
dashboardApi.summary().catch(() => ({ data: { data: {} } })),
assetsApi.list({ perpage: 5 }).catch(() => ({ data: { data: [] } })),
printersApi.dashboardSummary().catch(() => ({ data: { data: {} } }))
])
stats.value = dashRes.data.data || {}
recentMachines.value = machinesRes.data.data || []
printerStats.value = printersRes.data.data || {}
const response = await dashboardApi.summary().catch(() => ({ data: { data: {} } }))
stats.value = response.data.data || {}
} catch (error) {
console.error('Dashboard load error:', error)
} finally {
loading.value = false
}
})
function getStatusClass(status) {
if (!status) return 'badge-info'
const s = status.toLowerCase()
if (s === 'in use' || s === 'active') return 'badge-success'
if (s === 'in repair') return 'badge-warning'
if (s === 'retired') return 'badge-danger'
return 'badge-info'
}
</script>
<style scoped>
/* Matches the panels in DashboardCards; the rail reads as one column rather
than as a page card that happens to sit beside them. */
.estate {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.85rem 1rem 0.9rem;
min-width: 0;
}
.estate-head {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.estate-head h3 {
margin: 0;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-light);
flex: 1 1 auto;
}
.estate-link {
font-size: 0.78rem;
color: var(--link);
text-decoration: none;
}
.estate-link:hover { text-decoration: underline; }
.estate-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
padding: 0.35rem 0;
border-bottom: 1px solid var(--border);
}
.estate-row:last-of-type { border-bottom: none; }
.estate-k {
color: var(--link);
text-decoration: none;
font-size: 0.9rem;
}
a.estate-k:hover { text-decoration: underline; }
span.estate-k { color: var(--text); font-weight: 600; }
.estate-v {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.estate-total {
margin-top: 0.35rem;
border-top: 2px solid var(--border);
border-bottom: none;
}
.estate-status {
margin: 0.5rem 0 0;
font-size: 0.85rem;
color: var(--text-light);
}
.estate-inuse { color: var(--success); font-weight: 600; font-variant-numeric: tabular-nums; }
.estate-zero { color: var(--text-light); }
</style>

View File

@@ -237,6 +237,7 @@ class BackupsPlugin(BasePlugin):
{
'id': 'backups-stale',
'title': 'Backups stopped',
'shortname': 'Backups',
'endpoint': '/api/backups/dashboard/stale',
'render': 'exceptions',
'severity': 'warning',

View File

@@ -1934,6 +1934,7 @@ class ComputersPlugin(BasePlugin):
'id': 'computers-quiet',
'viewall': '/pcs',
'title': 'PCs gone silent',
'shortname': 'PC silent',
'endpoint': '/api/computers/dashboard/quiet',
'render': 'exceptions',
'severity': 'warning',
@@ -1952,6 +1953,7 @@ class ComputersPlugin(BasePlugin):
'id': 'computers-sharedmachines',
'viewall': '/machines',
'title': 'Machine numbers on two PCs',
'shortname': 'Duplicate machine no.',
'endpoint': '/api/computers/dashboard/sharedmachines',
'render': 'exceptions',
'severity': 'critical',

View File

@@ -387,6 +387,7 @@ class EndpointsPlugin(BasePlugin):
'id': 'endpoints-quiet',
'viewall': '/pcs',
'title': 'Endpoints gone quiet',
'shortname': 'Agent quiet',
'endpoint': '/api/endpoints/dashboard/quiet',
'render': 'exceptions',
'severity': 'warning',
@@ -405,6 +406,7 @@ class EndpointsPlugin(BasePlugin):
'id': 'endpoints-stability',
'viewall': '/pcs',
'title': 'Endpoints with crashes or shutdowns',
'shortname': 'Crashes',
'endpoint': '/api/endpoints/dashboard/stability',
'render': 'exceptions',
'severity': 'critical',

View File

@@ -86,6 +86,7 @@ class GeEnforcePlugin(BasePlugin):
'id': 'geenforce-failures',
'viewall': '/geenforce',
'title': 'Enforcement failures',
'shortname': 'Enforcement',
'endpoint': '/api/geenforce/dashboard/failures',
'render': 'exceptions',
'severity': 'critical',

View File

@@ -209,6 +209,7 @@ class MachinesPlugin(BasePlugin):
'id': 'machines-outofservice',
'viewall': '/machines',
'title': 'Machines out of service',
'shortname': 'Out of service',
'endpoint': '/api/machines/dashboard/outofservice',
'render': 'exceptions',
'severity': 'warning',

View File

@@ -206,6 +206,7 @@ class NotificationsPlugin(BasePlugin):
'id': 'notifications-active',
'viewall': '/notifications',
'title': 'Active notifications',
'shortname': 'Notification',
'endpoint': '/api/notifications/dashboard/active',
'render': 'list',
'severity': 'info',

View File

@@ -2124,8 +2124,21 @@ def _get_low_supplies_data():
return cached
service = ZabbixService()
if not service.isconfigured or not service.isreachable:
# "No printer is low" and "we cannot tell whether any printer is low" are
# 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:
# Enabled plus a URL plus a token. Absent any of those the site has not
# asked for supply monitoring, and a permanent "not configured" line
# would be a card reporting nothing wrong every day - which is how a
# board teaches people to stop reading it. Nothing to say: stay hidden.
return {'printers': [], 'summary': {'total_checked': 0, 'low': 0, 'critical': 0}}
if not service.isreachable:
# Configured and unanswerable is a different thing: something meant to
# work is not working, and the cartridges are NOT known to be fine.
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
rows = (
@@ -2655,7 +2668,10 @@ def dashboard_supplies():
})
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')})
# =============================================================================

View File

@@ -354,6 +354,7 @@ class PrintersPlugin(BasePlugin):
'id': 'printers-supplies',
'viewall': '/reports/toner',
'title': 'Printer supplies',
'shortname': 'Supplies',
'endpoint': '/api/printers/dashboard/supplies',
'render': 'exceptions',
'severity': 'warning',

View File

@@ -128,6 +128,7 @@ class WarrantyPlugin(BasePlugin):
'id': 'warranty-expiring',
'viewall': '/reports/warranty',
'title': 'Warranties expiring',
'shortname': 'Warranty',
'endpoint': '/api/warranty/dashboard/expiring',
'render': 'exceptions',
'severity': 'info',

View File

@@ -88,7 +88,12 @@ from .plugins import plugin_manager
# emission never consulted the caller, so a read-only viewer was shown controls
# over a shop-floor PC that 403 on click. Guests are included, since a signed-out
# visitor holds whatever the guest role was granted. Additive name, minor bump.
__contract_version__ = '0.25.0'
# 0.26.0: dashboard cards may declare 'shortname'. The dashboard merged its
# per-source cards into one worst-first queue, and a card's 'title' is written
# as a heading - "Endpoints with crashes or shutdowns" - which as a row badge
# ran wider than the hostname it qualified. Optional, defaults to the title, so
# a plugin that does not declare one is unaffected. Additive field, minor bump.
__contract_version__ = '0.26.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent

View File

@@ -238,6 +238,9 @@ class BasePlugin(ABC):
Each card: {
'id': str, # stable, unique across plugins
'title': str, # heading
'shortname': str, # optional: label for this card's rows in the
# merged queue. A title reads as a header and is
# too long as a row badge; defaults to title.
'endpoint': str, # API path the card fetches its own data from
'render': str, # core renderer: 'exceptions' | 'list' | 'stat'
'severity': str, # 'critical' | 'warning' | 'info' - orders cards

View File

@@ -0,0 +1,90 @@
"""The supplies dashboard card must not report "nothing low" when it cannot see.
_get_low_supplies_data returned the same empty result for three different
situations - Zabbix not configured, Zabbix unreachable, and every cartridge
full - so the dashboard displayed "Low Supplies 0 / Critical 0" while the
monitoring was dead. That is not a missing feature; it is a wrong statement, and
it reads as reassurance.
These pin the three cases apart.
"""
from unittest.mock import patch
import pytest
from shopdb.api import db
from shopdb.core.models import Setting
def _call(client, auth_headers):
response = client.get('/api/printers/dashboard/supplies', headers=auth_headers)
assert response.status_code == 200
return response.get_json()['data']
class FakeService:
def __init__(self, configured, reachable):
self.isconfigured = configured
self.isreachable = reachable
@pytest.fixture(autouse=True)
def _clear_cache(app):
from shopdb.extensions import cache
cache.delete('printers_low_supplies')
yield
cache.delete('printers_low_supplies')
def test_unreachable_says_so_rather_than_reporting_zero(client, auth_headers):
"""Configured and unanswerable: the cartridges are NOT known to be fine."""
with patch('plugins.printers.api.asset_routes.ZabbixService',
return_value=FakeService(configured=True, reachable=False)):
data = _call(client, auth_headers)
assert data['rows'] == []
assert data['unavailable'] == 'Supply monitoring is not reachable'
def test_unconfigured_stays_quiet(client, auth_headers):
"""The site did not ask for supply monitoring.
A permanent "not configured" line would be a card reporting nothing wrong
every day, which is how a board teaches people to stop reading it. It has
nothing to say, so it says nothing and the card hides.
"""
with patch('plugins.printers.api.asset_routes.ZabbixService',
return_value=FakeService(configured=False, reachable=False)):
data = _call(client, auth_headers)
assert data['rows'] == []
assert data.get('unavailable') is None
def test_reachable_with_nothing_low_is_not_flagged_as_blind(client, auth_headers):
"""Zabbix answered and every cartridge is fine. That is a real zero."""
with patch('plugins.printers.api.asset_routes._get_low_supplies_data',
return_value={'printers': [],
'summary': {'total_checked': 3, 'low': 0, 'critical': 0}}):
data = _call(client, auth_headers)
assert data['rows'] == []
assert data.get('unavailable') is None
def test_the_envelope_carries_rows_so_the_card_can_still_render(client, auth_headers):
"""The response became an envelope to carry the reason; rows must survive."""
printer = {
'printerid': 1, 'assetnumber': 'PRN-1', 'printername': 'Front office',
'location': 'Bay 3', 'mapx': None, 'mapy': None, 'levelid': None,
'supplies': [{'name': 'Black', 'remaining': 2, 'status': 'critical'}],
}
with patch('plugins.printers.api.asset_routes._get_low_supplies_data',
return_value={'printers': [printer],
'summary': {'total_checked': 1, 'low': 1, 'critical': 1}}):
data = _call(client, auth_headers)
assert data.get('unavailable') is None
assert len(data['rows']) == 1
assert data['rows'][0]['printername'] == 'Front office'
assert data['rows'][0]['iscritical'] is True