dashboard: fix what a real fleet showed, which tests could not
Three faults, visible only once the board ran against production data. BACKUPS SAID THE WHOLE FLEET HAD STOPPED. The lastseenat backfill was wrong. It seeded from collectedat, reasoning that the last change was the last provable moment - but an unchanged config writes no revision, so a machine whose settings last changed nine months ago got a nine-month-old lastseenat and was instantly reported as a dead backup. Every chain lit up at once, which is worse than no card: it says the site is broken when it is fine. The honest value is NULL. Before the column existed nothing recorded when a config was last confirmed, and inventing a date does not change that. Migration 0003 clears the backfill, and staleness now IGNORES a NULL chain rather than substituting timestamps that mean something else. A chain becomes measurable the first time its PC posts, which for NTLARS is within a day. TONER READ "None%". The supply dict has no 'percent' key - it is 'remaining'. Supply names are also shortened, because "Black Toner Level 4%" spends three words saying what the card already says. THE CARDS READ AS WALLS OF TEXT. Rows wrapped into paragraphs and a card with forty PCs pushed everything below it off the screen. Now: at most five rows with "and N more", one line per row that truncates rather than wraps, meta pushed right and dropped first since it matters least, and severity reduced to a small dot beside an uppercase label instead of a coloured card - six severity-painted cards read as a crisis, which is how a board stops being read. Worth recording that none of this could fail in a test. Every one needed real data on a real fleet.
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
: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>
|
||||
@@ -14,20 +15,23 @@
|
||||
<!-- metric: the count IS the story -->
|
||||
<p v-if="card.render === 'metric'" class="dc-metric">{{ metricValue(card) }}</p>
|
||||
|
||||
<!-- exceptions / list: things that need a person, each linking to itself -->
|
||||
<!-- exceptions / list: one line per thing, each linking to itself -->
|
||||
<ul v-else class="dc-rows">
|
||||
<li v-for="(row, index) in cardRows(card)" :key="index" class="dc-row">
|
||||
<li v-for="(row, index) in visibleRows(card)" :key="index" class="dc-row">
|
||||
<router-link v-if="row.link" :to="row.link" class="dc-row-title">
|
||||
{{ row.title }}
|
||||
</router-link>
|
||||
<span v-else class="dc-row-title dc-row-nolink">{{ row.title }}</span>
|
||||
<span v-if="row.detail" class="dc-row-detail">{{ row.detail }}</span>
|
||||
<span v-for="(meta, m) in row.meta" :key="m" class="dc-row-meta"
|
||||
:class="{ mono: meta.mono }">{{ meta.text }}</span>
|
||||
<span v-if="row.meta.length" class="dc-row-meta">
|
||||
{{ row.meta.map((m) => m.text).join(' / ') }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-if="!hasRows(card)" class="dc-clear">Nothing to action.</p>
|
||||
<p v-if="overflowCount(card)" class="dc-more">
|
||||
and {{ overflowCount(card) }} more
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -45,7 +49,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import api from '../api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
toApiPath, cardRows, metricValue, cardVisible, sortCards,
|
||||
toApiPath, visibleRows, overflowCount, metricValue, cardVisible, sortCards,
|
||||
permittedCards, renderableCards, rows as cardData,
|
||||
} from './dashboardCards'
|
||||
|
||||
@@ -54,10 +58,6 @@ const cards = ref([])
|
||||
|
||||
const visibleCards = computed(() => sortCards(cards.value.filter(cardVisible)))
|
||||
|
||||
function hasRows(card) {
|
||||
return card.render === 'metric' ? metricValue(card) > 0 : cardData(card).length > 0
|
||||
}
|
||||
|
||||
function countOf(card) {
|
||||
return cardData(card).length
|
||||
}
|
||||
@@ -93,35 +93,97 @@ defineExpose({ load })
|
||||
<style scoped>
|
||||
.dc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 1.75rem;
|
||||
align-items: start;
|
||||
}
|
||||
.dc-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left-width: 4px;
|
||||
border-radius: 8px;
|
||||
padding: 0.9rem 1rem;
|
||||
padding: 0.85rem 1rem 0.9rem;
|
||||
}
|
||||
/* Severity is carried by the left edge only. A fully coloured card reads as an
|
||||
alert even when it holds one minor row, and six of them read as a crisis. */
|
||||
.dc-critical { border-left-color: var(--danger); }
|
||||
.dc-warning { border-left-color: var(--warning); }
|
||||
.dc-info { border-left-color: var(--primary); }
|
||||
|
||||
.dc-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; }
|
||||
.dc-title { margin: 0; font-size: 0.95rem; font-weight: 600; color: var(--text); }
|
||||
.dc-count { font-size: 0.8rem; color: var(--text-light); }
|
||||
.dc-metric { margin: 0.4rem 0 0; font-size: 2rem; font-weight: 600; color: var(--text); }
|
||||
/* 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-rows { list-style: none; margin: 0.6rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.dc-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem; font-size: 0.85rem; }
|
||||
.dc-row-title { font-weight: 600; color: var(--link); text-decoration: none; }
|
||||
.dc-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.55rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.dc-title {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-light);
|
||||
flex: 1;
|
||||
}
|
||||
.dc-count {
|
||||
font-size: 0.8rem;
|
||||
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; }
|
||||
.dc-row-title {
|
||||
font-weight: 600;
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
flex: none;
|
||||
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); }
|
||||
.dc-row-meta { color: var(--text-light); }
|
||||
.dc-row-meta.mono { font-family: ui-monospace, Menlo, Consolas, monospace; }
|
||||
.dc-clear { margin: 0.5rem 0 0; font-size: 0.85rem; color: var(--text-light); }
|
||||
.dc-row-detail {
|
||||
color: var(--text);
|
||||
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;
|
||||
flex: none;
|
||||
}
|
||||
.dc-more {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -75,6 +75,20 @@ export function mapLink(card, item) {
|
||||
return missing ? null : href
|
||||
}
|
||||
|
||||
// How many rows a card shows before collapsing the rest behind a count. A card
|
||||
// listing forty PCs is a report someone has to read, not a board someone can
|
||||
// scan - and it pushes every card below it off the screen. Five is enough to
|
||||
// see the shape of the problem; the link goes to the full list.
|
||||
export const MAXROWS = 5
|
||||
|
||||
export function visibleRows(card) {
|
||||
return cardRows(card).slice(0, MAXROWS)
|
||||
}
|
||||
|
||||
export function overflowCount(card) {
|
||||
return Math.max(0, rows(card).length - MAXROWS)
|
||||
}
|
||||
|
||||
export function cardRows(card) {
|
||||
return rows(card).map((item) => ({
|
||||
title: mapTitle(card, item),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
toApiPath, rows, mapMeta, mapLink, cardRows, metricValue,
|
||||
cardVisible, sortCards, permittedCards, renderableCards,
|
||||
visibleRows, overflowCount,
|
||||
} from './dashboardCards'
|
||||
|
||||
const failuresCard = {
|
||||
@@ -77,6 +78,24 @@ describe('mapping a row', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user