diff --git a/frontend/src/components/DashboardCards.vue b/frontend/src/components/DashboardCards.vue
index 9e61600..35752f8 100644
--- a/frontend/src/components/DashboardCards.vue
+++ b/frontend/src/components/DashboardCards.vue
@@ -29,7 +29,15 @@
-
+
+
+ and {{ overflowCount(card) }} more
+
+
and {{ overflowCount(card) }} more
@@ -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; }
diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue
index a3b6a30..7600b9d 100644
--- a/frontend/src/views/Dashboard.vue
+++ b/frontend/src/views/Dashboard.vue
@@ -13,24 +13,41 @@
docs/proposals/dashboard-live-fleet.md. -->
-
+
-
Total Machines
+
Machines
{{ stats.totalmachines || 0 }}
-
-
Active
-
{{ stats.activeassets || 0 }}
-
-
-
In Repair
-
{{ stats.inrepair || 0 }}
-
PCs
{{ stats.totalpc || 0 }}
+
+
Printers
+
{{ stats.totalprinter || 0 }}
+
+
+
Network devices
+
{{ stats.totalnetwork || 0 }}
+
+
+
All assets
+
{{ stats.totalassets || 0 }}
+
+
+
All assets in use
+
{{ stats.activeassets || 0 }}
+
+
+
All assets in repair
+
{{ stats.inrepair || 0 }}
+
diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py
index 3f2e2f8..5d5b43c 100644
--- a/plugins/computers/api/routes.py
+++ b/plugins/computers/api/routes.py
@@ -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'])
diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py
index 92c7bb3..a8d1105 100644
--- a/plugins/computers/plugin.py
+++ b/plugins/computers/plugin.py
@@ -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',
diff --git a/plugins/geenforce/plugin.py b/plugins/geenforce/plugin.py
index 79ac84c..24a2c6a 100644
--- a/plugins/geenforce/plugin.py
+++ b/plugins/geenforce/plugin.py
@@ -84,6 +84,7 @@ class GeEnforcePlugin(BasePlugin):
return [
{
'id': 'geenforce-failures',
+ 'viewall': '/geenforce',
'title': 'Enforcement failures',
'endpoint': '/api/geenforce/dashboard/failures',
'render': 'exceptions',
diff --git a/plugins/machines/plugin.py b/plugins/machines/plugin.py
index e9b26c9..77258bf 100644
--- a/plugins/machines/plugin.py
+++ b/plugins/machines/plugin.py
@@ -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',
diff --git a/plugins/notifications/plugin.py b/plugins/notifications/plugin.py
index 8178a2b..a13be3a 100644
--- a/plugins/notifications/plugin.py
+++ b/plugins/notifications/plugin.py
@@ -204,6 +204,7 @@ class NotificationsPlugin(BasePlugin):
return [
{
'id': 'notifications-active',
+ 'viewall': '/notifications',
'title': 'Active notifications',
'endpoint': '/api/notifications/dashboard/active',
'render': 'list',
diff --git a/plugins/printers/plugin.py b/plugins/printers/plugin.py
index fd6cf8a..85a75cd 100644
--- a/plugins/printers/plugin.py
+++ b/plugins/printers/plugin.py
@@ -296,6 +296,7 @@ class PrintersPlugin(BasePlugin):
return [
{
'id': 'printers-supplies',
+ 'viewall': '/reports/toner',
'title': 'Printer supplies',
'endpoint': '/api/printers/dashboard/supplies',
'render': 'exceptions',
diff --git a/plugins/warranty/plugin.py b/plugins/warranty/plugin.py
index b36caa6..70c1bc4 100644
--- a/plugins/warranty/plugin.py
+++ b/plugins/warranty/plugin.py
@@ -124,6 +124,7 @@ class WarrantyPlugin(BasePlugin):
return [
{
'id': 'warranty-expiring',
+ 'viewall': '/reports/warranty',
'title': 'Warranties expiring',
'endpoint': '/api/warranty/dashboard/expiring',
'render': 'exceptions',
diff --git a/tests/test_core/test_dashboard_widgets.py b/tests/test_core/test_dashboard_widgets.py
index 8910b65..72545ee 100644
--- a/tests/test_core/test_dashboard_widgets.py
+++ b/tests/test_core/test_dashboard_widgets.py
@@ -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)
diff --git a/tests/test_plugins/test_computers_dashboard.py b/tests/test_plugins/test_computers_dashboard.py
index fe2bc94..95d6a6d 100644
--- a/tests/test_plugins/test_computers_dashboard.py
+++ b/tests/test_plugins/test_computers_dashboard.py
@@ -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):