Dashboard fixes: staleness, toner, layout

This commit is contained in:
2026-08-11 14:45:29 -04:00
parent 3b37603691
commit 9ae9879b90
7 changed files with 188 additions and 37 deletions

View File

@@ -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>

View File

@@ -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),

View File

@@ -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

View File

@@ -0,0 +1,38 @@
"""backups: clear the backfilled lastseenat - it was a guess, and it showed.
0002 added lastseenat and backfilled it from collectedat, reasoning that the
last change was the last moment the config could be PROVEN current. On a fleet
that was wrong in practice: an unchanged config writes no revision, so a machine
whose settings last changed nine months ago got a nine-month-old lastseenat and
was immediately reported as a stopped backup. Every chain lit up at once, which
is worse than no card - it says the fleet is broken when it is fine.
The honest value is NULL: before this column existed, nothing recorded when a
config was last confirmed, and inventing a date does not change that. A chain
becomes measurable the first time its PC posts after the upgrade, which for
NTLARS is within a day.
So staleness now IGNORES a chain whose lastseenat is NULL, rather than falling
back to timestamps that mean something else.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'backups0003clearlastseen'
down_revision = 'backups0002lastseenat'
branch_labels = None
depends_on = None
def upgrade():
columns = {c['name'] for c in
sa.inspect(op.get_bind()).get_columns('backuprevisions')}
if 'lastseenat' in columns:
op.execute('UPDATE backuprevisions SET lastseenat = NULL')
def downgrade():
# Nothing to restore: the backfilled values were derived, not recorded.
pass

View File

@@ -65,10 +65,14 @@ def stalechains(days=None, limit=50):
rows = []
for revision in latestperchain().values():
# Rows written before lastseenat existed fall back to the timestamps
# that do exist, so an old install reports something sane on day one
# rather than every chain at once.
seen = revision.lastseenat or revision.collectedat or revision.createdat
# NULL means never confirmed since the column existed, and it is NOT
# substituted with collectedat. That substitution is what the first
# version did, and on a real fleet it reported every machine whose
# config had simply been stable for months as a stopped backup - the
# whole board lit up and said the site was broken when it was fine.
# A chain becomes measurable the first time its PC posts; until then
# this card says nothing about it, which is the truth.
seen = revision.lastseenat
if seen is None or seen >= cutoff:
continue
asset = db.session.get(Asset, revision.assetid)

View File

@@ -1395,6 +1395,18 @@ def delete_model_supply(modelsupplyid: int):
return success_response(message='Supply deleted')
def _shortsupplyname(name):
"""'Black Toner Level' -> 'Black'. The card has one line per printer, and
the words Toner and Level carry no information when every row is a toner
level."""
text = (name or 'supply').strip()
for noise in (' Cartridge Level', ' Toner Level', ' Level', ' Cartridge'):
if text.endswith(noise):
text = text[:-len(noise)]
break
return text or 'supply'
@printers_asset_bp.route('/dashboard/supplies', methods=['GET'])
@jwt_required()
@require_permission('printers.view')
@@ -1426,9 +1438,11 @@ def dashboard_supplies():
'printername': printer['printername'] or printer['assetnumber'],
'location': printer['location'],
'status': 'critical' if criticals else 'low',
# 'remaining' is the percent left. There is no 'percent' key -
# reading one rendered every cartridge as "None%" on the board.
'supplies': ', '.join(
'{} {}%'.format(s.get('name') or s.get('type') or 'supply',
s.get('percent'))
'{} {}%'.format(_shortsupplyname(s.get('name')),
s.get('remaining'))
for s in worst),
'iscritical': bool(criticals),
})

View File

@@ -49,7 +49,7 @@ EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline'
# backups is also post-cutover: its 0001 really creates backuprevisions, and
# 0002 adds lastseenat - when a config was last CONFIRMED unchanged, which dedup
# otherwise throws away.
EXPECTED_HEAD_REVISION['backups'] = 'backups0002lastseenat'
EXPECTED_HEAD_REVISION['backups'] = 'backups0003clearlastseen'
# geenforce adds the content-addressed blob store (manifestblobs) on top of its
# baseline.
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0002blobs'