Dashboard polish
This commit is contained in:
@@ -29,7 +29,15 @@
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-if="overflowCount(card)" class="dc-more">
|
||||
<!-- 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>
|
||||
@@ -103,6 +111,13 @@ defineExpose({ load })
|
||||
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;
|
||||
}
|
||||
|
||||
/* Severity is a small dot beside the title, not a coloured card or a thick
|
||||
@@ -156,7 +171,8 @@ defineExpose({ load })
|
||||
font-weight: 600;
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
flex: none;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 45%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -166,6 +182,8 @@ defineExpose({ load })
|
||||
.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;
|
||||
@@ -179,11 +197,19 @@ defineExpose({ load })
|
||||
color: var(--text-light);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
flex: none;
|
||||
/* 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;
|
||||
}
|
||||
.dc-more {
|
||||
display: block;
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.dc-more-link { color: var(--link); text-decoration: none; }
|
||||
.dc-more-link:hover { text-decoration: underline; }
|
||||
</style>
|
||||
|
||||
@@ -13,24 +13,41 @@
|
||||
docs/proposals/dashboard-live-fleet.md. -->
|
||||
<DashboardCards />
|
||||
|
||||
<!-- Main Stats -->
|
||||
<!-- 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">Total Machines</div>
|
||||
<div class="label">Machines</div>
|
||||
<div class="value">{{ stats.totalmachines || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card success">
|
||||
<div class="label">Active</div>
|
||||
<div class="value">{{ stats.activeassets || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card warning">
|
||||
<div class="label">In Repair</div>
|
||||
<div class="value">{{ stats.inrepair || 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>
|
||||
|
||||
<!-- Printer Stats -->
|
||||
|
||||
@@ -940,21 +940,25 @@ def dashboard_summary():
|
||||
@jwt_required()
|
||||
@require_permission('computers.view')
|
||||
def dashboard_quiet():
|
||||
"""PCs that have stopped reporting, and PCs that never started.
|
||||
"""PCs that WERE reporting, have stopped, and are still meant to be in use.
|
||||
|
||||
Two populations, deliberately distinguished rather than merged into one
|
||||
count. A PC that reported and went quiet is probably switched off, moved or
|
||||
broken. A PC that has NEVER reported is worse: it is either not enrolled, or
|
||||
enrolled against the wrong pc-type, so nothing enforces anything on it and
|
||||
no backup of it exists. That one hides indefinitely because nothing about it
|
||||
is failing loudly - and it is exactly the shape of the bay that carried a
|
||||
wrong machine number for weeks.
|
||||
Three filters, and each removes a population that would otherwise be noise:
|
||||
|
||||
Silence is the only signal available here. There is no heartbeat separate
|
||||
from the report, so 'stopped reporting' is measured against the collector's
|
||||
own last write.
|
||||
Never reported at all is EXCLUDED. Those are usually records created by
|
||||
hand or imported, not bays that broke - listing them buries the PCs that
|
||||
actually changed state behind rows nobody is going to act on.
|
||||
|
||||
Not In Use is EXCLUDED. A PC in Repair, Inventory or Retired is silent on
|
||||
purpose; that is the status doing its job, not a fault.
|
||||
|
||||
Soft-deleted is excluded for the same reason.
|
||||
|
||||
What is left is the real signal: a machine that was working, is not now, and
|
||||
nobody has marked as anything else. Silence is the only evidence available -
|
||||
there is no heartbeat separate from the report.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from shopdb.api import AssetStatus
|
||||
|
||||
hours = 24
|
||||
setting = Setting.query.filter_by(key='computers_quietreporthours').first()
|
||||
@@ -964,36 +968,26 @@ def dashboard_quiet():
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=hours)
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
query = (db.session.query(Computer, Asset)
|
||||
.join(Asset, Asset.assetid == Computer.assetid)
|
||||
.filter(Asset.isactive.is_(True)))
|
||||
rows = (db.session.query(Computer, Asset)
|
||||
.join(Asset, Asset.assetid == Computer.assetid)
|
||||
.join(AssetStatus, AssetStatus.statusid == Asset.statusid)
|
||||
.filter(Asset.isactive.is_(True),
|
||||
AssetStatus.status == 'In Use',
|
||||
Computer.lastreporteddate.isnot(None),
|
||||
Computer.lastreporteddate < cutoff)
|
||||
.all())
|
||||
|
||||
rows = []
|
||||
for comp, asset in query.all():
|
||||
last = comp.lastreporteddate
|
||||
if last is None:
|
||||
rows.append({
|
||||
'computerid': comp.computerid,
|
||||
'hostname': comp.hostname,
|
||||
'state': 'never reported',
|
||||
'lastreported': None,
|
||||
'quietdays': None,
|
||||
})
|
||||
elif last < cutoff:
|
||||
rows.append({
|
||||
'computerid': comp.computerid,
|
||||
'hostname': comp.hostname,
|
||||
'state': 'stopped reporting',
|
||||
'lastreported': last.isoformat() + 'Z',
|
||||
'quietdays': (datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
- last).days,
|
||||
})
|
||||
out = [{
|
||||
'computerid': comp.computerid,
|
||||
'hostname': comp.hostname,
|
||||
'lastreported': comp.lastreporteddate.isoformat() + 'Z',
|
||||
'quietdays': (now - comp.lastreporteddate).days,
|
||||
} for comp, _asset in rows]
|
||||
|
||||
# Never-reported first, then longest silence: the order someone should work
|
||||
# down the list, not the order the rows came out of the table.
|
||||
rows.sort(key=lambda r: (r['quietdays'] is not None, -(r['quietdays'] or 0)))
|
||||
return success_response(rows[:50])
|
||||
out.sort(key=lambda r: -r['quietdays'])
|
||||
return success_response(out[:50])
|
||||
|
||||
|
||||
@computers_bp.route('/dashboard/sharedmachines', methods=['GET'])
|
||||
|
||||
@@ -1265,7 +1265,8 @@ class ComputersPlugin(BasePlugin):
|
||||
return [
|
||||
{
|
||||
'id': 'computers-quiet',
|
||||
'title': 'PCs not reporting',
|
||||
'viewall': '/pcs',
|
||||
'title': 'PCs gone silent',
|
||||
'endpoint': '/api/computers/dashboard/quiet',
|
||||
'render': 'exceptions',
|
||||
'severity': 'warning',
|
||||
@@ -1274,14 +1275,15 @@ class ComputersPlugin(BasePlugin):
|
||||
'position': 20,
|
||||
'map': {
|
||||
'title': 'hostname',
|
||||
'detail': 'state',
|
||||
'meta': [{'key': 'quietdays', 'label': 'quiet for',
|
||||
'detail': '',
|
||||
'meta': [{'key': 'quietdays', 'label': 'silent',
|
||||
'suffix': ' days'}],
|
||||
'link': '/pcs/{computerid}',
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': 'computers-sharedmachines',
|
||||
'viewall': '/machines',
|
||||
'title': 'Machine numbers on two PCs',
|
||||
'endpoint': '/api/computers/dashboard/sharedmachines',
|
||||
'render': 'exceptions',
|
||||
|
||||
@@ -84,6 +84,7 @@ class GeEnforcePlugin(BasePlugin):
|
||||
return [
|
||||
{
|
||||
'id': 'geenforce-failures',
|
||||
'viewall': '/geenforce',
|
||||
'title': 'Enforcement failures',
|
||||
'endpoint': '/api/geenforce/dashboard/failures',
|
||||
'render': 'exceptions',
|
||||
|
||||
@@ -207,6 +207,7 @@ class MachinesPlugin(BasePlugin):
|
||||
return [
|
||||
{
|
||||
'id': 'machines-outofservice',
|
||||
'viewall': '/machines',
|
||||
'title': 'Machines out of service',
|
||||
'endpoint': '/api/machines/dashboard/outofservice',
|
||||
'render': 'exceptions',
|
||||
|
||||
@@ -204,6 +204,7 @@ class NotificationsPlugin(BasePlugin):
|
||||
return [
|
||||
{
|
||||
'id': 'notifications-active',
|
||||
'viewall': '/notifications',
|
||||
'title': 'Active notifications',
|
||||
'endpoint': '/api/notifications/dashboard/active',
|
||||
'render': 'list',
|
||||
|
||||
@@ -296,6 +296,7 @@ class PrintersPlugin(BasePlugin):
|
||||
return [
|
||||
{
|
||||
'id': 'printers-supplies',
|
||||
'viewall': '/reports/toner',
|
||||
'title': 'Printer supplies',
|
||||
'endpoint': '/api/printers/dashboard/supplies',
|
||||
'render': 'exceptions',
|
||||
|
||||
@@ -124,6 +124,7 @@ class WarrantyPlugin(BasePlugin):
|
||||
return [
|
||||
{
|
||||
'id': 'warranty-expiring',
|
||||
'viewall': '/reports/warranty',
|
||||
'title': 'Warranties expiring',
|
||||
'endpoint': '/api/warranty/dashboard/expiring',
|
||||
'render': 'exceptions',
|
||||
|
||||
@@ -63,3 +63,21 @@ def test_every_card_links_somewhere(app):
|
||||
if card['render'] == 'metric':
|
||||
continue
|
||||
assert (card.get('map') or {}).get('link'), card
|
||||
|
||||
|
||||
def test_a_viewall_target_is_one_of_the_known_pages(app):
|
||||
"""A card's overflow line links here. Pointing it at a route nobody wrote is
|
||||
the same rot the endpoint check exists to stop - it just fails in the
|
||||
browser instead of the API."""
|
||||
known = {
|
||||
'/pcs', '/machines', '/printers', '/network', '/notifications',
|
||||
'/geenforce', '/reports/toner', '/reports/warranty',
|
||||
'/reports/pc-relationships', '/reports/calibration', '/reports',
|
||||
}
|
||||
with app.app_context():
|
||||
for pluginname, card in _declared(app):
|
||||
target = card.get('viewall')
|
||||
if target is None:
|
||||
continue
|
||||
assert target in known, '{}: {} is not a known page'.format(
|
||||
pluginname, target)
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
"""Dashboard card: PCs that stopped reporting, and PCs that never started.
|
||||
"""Dashboard card: PCs that were reporting and have stopped.
|
||||
|
||||
Two populations, deliberately not merged. A PC that reported and went quiet is
|
||||
probably off, moved or broken. A PC that has NEVER reported is worse - not
|
||||
enrolled, or enrolled against the wrong pc-type, so nothing enforces anything on
|
||||
it and no backup of it exists. That one hides indefinitely because nothing about
|
||||
it fails loudly.
|
||||
Deliberately narrow. A PC that never reported at all is usually a record made by
|
||||
hand or imported, not a bay that broke, and a PC that is not In Use is silent on
|
||||
purpose - that is the status doing its job. Both would bury the real signal: a
|
||||
machine that was working, is not now, and nobody has marked as anything else.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from shopdb.core.models import Asset, AssetType, Setting
|
||||
from shopdb.core.models import Asset, AssetStatus, AssetType, Setting
|
||||
|
||||
URL = '/api/computers/dashboard/quiet'
|
||||
|
||||
@@ -18,6 +17,16 @@ def _now():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _inuse(db):
|
||||
"""The card joins on the status NAME, so a bare statusid will not do."""
|
||||
status = AssetStatus.query.filter_by(status='In Use').first()
|
||||
if not status:
|
||||
status = AssetStatus(status='In Use', isactive=True)
|
||||
db.session.add(status)
|
||||
db.session.flush()
|
||||
return status
|
||||
|
||||
|
||||
def _pc(db, hostname, lastreported='now', isactive=True):
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
@@ -27,7 +36,7 @@ def _pc(db, hostname, lastreported='now', isactive=True):
|
||||
db.session.add(atype)
|
||||
db.session.flush()
|
||||
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
|
||||
statusid=1, isactive=isactive)
|
||||
statusid=_inuse(db).statusid, isactive=isactive)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
comp = Computer(assetid=asset.assetid, hostname=hostname)
|
||||
@@ -51,26 +60,29 @@ def test_a_pc_gone_quiet_is_listed_with_how_long(client, db, auth_headers):
|
||||
_pc(db, 'QUIETPC', lastreported=72)
|
||||
[row] = client.get(URL, headers=auth_headers).get_json()['data']
|
||||
assert row['hostname'] == 'QUIETPC'
|
||||
assert row['state'] == 'stopped reporting'
|
||||
assert row['quietdays'] == 3
|
||||
|
||||
|
||||
def test_a_pc_that_never_reported_is_called_out_separately(client, db,
|
||||
auth_headers):
|
||||
"""Never-reported is a different fault from gone-quiet: nothing is
|
||||
enforcing anything on that PC and no backup of it exists."""
|
||||
def test_a_pc_that_never_reported_is_not_listed(client, db, auth_headers):
|
||||
"""Usually a hand-made or imported record rather than a bay that broke.
|
||||
Listing them buries the PCs that actually changed state."""
|
||||
_pc(db, 'NEVERPC', lastreported=None)
|
||||
[row] = client.get(URL, headers=auth_headers).get_json()['data']
|
||||
assert row['state'] == 'never reported'
|
||||
assert row['lastreported'] is None
|
||||
assert client.get(URL, headers=auth_headers).get_json()['data'] == []
|
||||
|
||||
|
||||
def test_never_reported_sorts_above_the_merely_quiet(client, db, auth_headers):
|
||||
"""The order someone should work down the list."""
|
||||
_pc(db, 'QUIETPC', lastreported=48)
|
||||
_pc(db, 'NEVERPC', lastreported=None)
|
||||
rows = client.get(URL, headers=auth_headers).get_json()['data']
|
||||
assert [r['hostname'] for r in rows] == ['NEVERPC', 'QUIETPC']
|
||||
def test_a_pc_not_in_use_is_not_listed(client, db, auth_headers):
|
||||
"""A PC in Repair or Retired is silent on purpose - that is the status
|
||||
doing its job, not a fault to chase."""
|
||||
from shopdb.core.models import AssetStatus
|
||||
repair = AssetStatus.query.filter_by(status='In Repair').first()
|
||||
if not repair:
|
||||
repair = AssetStatus(status='In Repair', isactive=True)
|
||||
db.session.add(repair)
|
||||
db.session.flush()
|
||||
comp = _pc(db, 'SHELVED', lastreported=200)
|
||||
comp.asset.statusid = repair.statusid
|
||||
db.session.commit()
|
||||
assert client.get(URL, headers=auth_headers).get_json()['data'] == []
|
||||
|
||||
|
||||
def test_the_longest_silence_comes_first(client, db, auth_headers):
|
||||
|
||||
Reference in New Issue
Block a user