Dashboard fixes: staleness, toner, layout
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
:class="'dc-' + (card.severity || 'info')"
|
:class="'dc-' + (card.severity || 'info')"
|
||||||
>
|
>
|
||||||
<header class="dc-head">
|
<header class="dc-head">
|
||||||
|
<span class="dc-dot" aria-hidden="true"></span>
|
||||||
<h3 class="dc-title">{{ card.title }}</h3>
|
<h3 class="dc-title">{{ card.title }}</h3>
|
||||||
<span v-if="card.render !== 'metric'" class="dc-count">{{ countOf(card) }}</span>
|
<span v-if="card.render !== 'metric'" class="dc-count">{{ countOf(card) }}</span>
|
||||||
</header>
|
</header>
|
||||||
@@ -14,20 +15,23 @@
|
|||||||
<!-- metric: the count IS the story -->
|
<!-- metric: the count IS the story -->
|
||||||
<p v-if="card.render === 'metric'" class="dc-metric">{{ metricValue(card) }}</p>
|
<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">
|
<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">
|
<router-link v-if="row.link" :to="row.link" class="dc-row-title">
|
||||||
{{ row.title }}
|
{{ row.title }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<span v-else class="dc-row-title dc-row-nolink">{{ row.title }}</span>
|
<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-if="row.detail" class="dc-row-detail">{{ row.detail }}</span>
|
||||||
<span v-for="(meta, m) in row.meta" :key="m" class="dc-row-meta"
|
<span v-if="row.meta.length" class="dc-row-meta">
|
||||||
:class="{ mono: meta.mono }">{{ meta.text }}</span>
|
{{ row.meta.map((m) => m.text).join(' / ') }}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -45,7 +49,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import api from '../api'
|
import api from '../api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import {
|
import {
|
||||||
toApiPath, cardRows, metricValue, cardVisible, sortCards,
|
toApiPath, visibleRows, overflowCount, metricValue, cardVisible, sortCards,
|
||||||
permittedCards, renderableCards, rows as cardData,
|
permittedCards, renderableCards, rows as cardData,
|
||||||
} from './dashboardCards'
|
} from './dashboardCards'
|
||||||
|
|
||||||
@@ -54,10 +58,6 @@ const cards = ref([])
|
|||||||
|
|
||||||
const visibleCards = computed(() => sortCards(cards.value.filter(cardVisible)))
|
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) {
|
function countOf(card) {
|
||||||
return cardData(card).length
|
return cardData(card).length
|
||||||
}
|
}
|
||||||
@@ -93,35 +93,97 @@ defineExpose({ load })
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.dc-grid {
|
.dc-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.75rem;
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
.dc-card {
|
.dc-card {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-left-width: 4px;
|
|
||||||
border-radius: 8px;
|
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; }
|
/* Severity is a small dot beside the title, not a coloured card or a thick
|
||||||
.dc-title { margin: 0; font-size: 0.95rem; font-weight: 600; color: var(--text); }
|
bar. A card painted by severity reads as an alert even when it holds one
|
||||||
.dc-count { font-size: 0.8rem; color: var(--text-light); }
|
minor row, and six of them read as a crisis - which is how a board stops
|
||||||
.dc-metric { margin: 0.4rem 0 0; font-size: 2rem; font-weight: 600; color: var(--text); }
|
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-head {
|
||||||
.dc-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem; font-size: 0.85rem; }
|
display: flex;
|
||||||
.dc-row-title { font-weight: 600; color: var(--link); text-decoration: none; }
|
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-title:hover { text-decoration: underline; }
|
||||||
.dc-row-nolink { color: var(--text); }
|
.dc-row-nolink { color: var(--text); }
|
||||||
.dc-row-detail { color: var(--text); }
|
.dc-row-detail {
|
||||||
.dc-row-meta { color: var(--text-light); }
|
color: var(--text);
|
||||||
.dc-row-meta.mono { font-family: ui-monospace, Menlo, Consolas, monospace; }
|
overflow: hidden;
|
||||||
.dc-clear { margin: 0.5rem 0 0; font-size: 0.85rem; color: var(--text-light); }
|
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>
|
</style>
|
||||||
|
|||||||
@@ -75,6 +75,20 @@ export function mapLink(card, item) {
|
|||||||
return missing ? null : href
|
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) {
|
export function cardRows(card) {
|
||||||
return rows(card).map((item) => ({
|
return rows(card).map((item) => ({
|
||||||
title: mapTitle(card, item),
|
title: mapTitle(card, item),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
toApiPath, rows, mapMeta, mapLink, cardRows, metricValue,
|
toApiPath, rows, mapMeta, mapLink, cardRows, metricValue,
|
||||||
cardVisible, sortCards, permittedCards, renderableCards,
|
cardVisible, sortCards, permittedCards, renderableCards,
|
||||||
|
visibleRows, overflowCount,
|
||||||
} from './dashboardCards'
|
} from './dashboardCards'
|
||||||
|
|
||||||
const failuresCard = {
|
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', () => {
|
describe('empty handling', () => {
|
||||||
it('hides a card with nothing to report by default', () => {
|
it('hides a card with nothing to report by default', () => {
|
||||||
// The whole point: a card saying "nothing wrong" daily trains people to
|
// The whole point: a card saying "nothing wrong" daily trains people to
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -65,10 +65,14 @@ def stalechains(days=None, limit=50):
|
|||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
for revision in latestperchain().values():
|
for revision in latestperchain().values():
|
||||||
# Rows written before lastseenat existed fall back to the timestamps
|
# NULL means never confirmed since the column existed, and it is NOT
|
||||||
# that do exist, so an old install reports something sane on day one
|
# substituted with collectedat. That substitution is what the first
|
||||||
# rather than every chain at once.
|
# version did, and on a real fleet it reported every machine whose
|
||||||
seen = revision.lastseenat or revision.collectedat or revision.createdat
|
# 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:
|
if seen is None or seen >= cutoff:
|
||||||
continue
|
continue
|
||||||
asset = db.session.get(Asset, revision.assetid)
|
asset = db.session.get(Asset, revision.assetid)
|
||||||
|
|||||||
@@ -1395,6 +1395,18 @@ def delete_model_supply(modelsupplyid: int):
|
|||||||
return success_response(message='Supply deleted')
|
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'])
|
@printers_asset_bp.route('/dashboard/supplies', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
@require_permission('printers.view')
|
@require_permission('printers.view')
|
||||||
@@ -1426,9 +1438,11 @@ def dashboard_supplies():
|
|||||||
'printername': printer['printername'] or printer['assetnumber'],
|
'printername': printer['printername'] or printer['assetnumber'],
|
||||||
'location': printer['location'],
|
'location': printer['location'],
|
||||||
'status': 'critical' if criticals else 'low',
|
'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(
|
'supplies': ', '.join(
|
||||||
'{} {}%'.format(s.get('name') or s.get('type') or 'supply',
|
'{} {}%'.format(_shortsupplyname(s.get('name')),
|
||||||
s.get('percent'))
|
s.get('remaining'))
|
||||||
for s in worst),
|
for s in worst),
|
||||||
'iscritical': bool(criticals),
|
'iscritical': bool(criticals),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline'
|
|||||||
# backups is also post-cutover: its 0001 really creates backuprevisions, and
|
# backups is also post-cutover: its 0001 really creates backuprevisions, and
|
||||||
# 0002 adds lastseenat - when a config was last CONFIRMED unchanged, which dedup
|
# 0002 adds lastseenat - when a config was last CONFIRMED unchanged, which dedup
|
||||||
# otherwise throws away.
|
# 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
|
# geenforce adds the content-addressed blob store (manifestblobs) on top of its
|
||||||
# baseline.
|
# baseline.
|
||||||
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0002blobs'
|
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0002blobs'
|
||||||
|
|||||||
Reference in New Issue
Block a user