From c34815b87e08ff1c10901e0f455cb1976d67ec9e Mon Sep 17 00:00:00 2001 From: cproudlock Date: Tue, 11 Aug 2026 15:28:29 -0400 Subject: [PATCH] dashboard: overflow links somewhere, tiles say what they count, rows stay inside Four fixes, all from looking at the real board. "and N more" now links to a page showing them all. Telling someone 35 more PCs are silent and leaving them to find the list is worse than not saying it. Each card names its own destination and a test checks it against the routes that actually exist - a viewall pointing at a route nobody wrote is the same rot the endpoint check already guards, just failing in the browser instead of the API. PRINTER ROWS ESCAPED THE CARD. A flex child will not shrink below its content width unless told to, so text-overflow never engaged and a row carrying three cartridge readings plus a location simply ran past the border. min-width:0 on the row parts is what enables the ellipsis; meta shrinks first because it matters least, and the card clips as a backstop. THE STAT TILES WERE INCOHERENT. Two counted asset TYPES, two counted asset STATUSES, and nothing said which - with the status one labelled "Active", which reads as "not deleted" but meant status = In Use across every type. Each tile now counts one thing and its label says so. PCs GONE SILENT IS NARROWER, and better for it. A PC that never reported at all is usually a hand-made or imported record rather than a bay that broke, and a PC that is not In Use is silent ON PURPOSE - that is the status doing its job. Both were burying the real signal: a machine that was working, is not now, and nobody has marked as anything else. --- frontend/src/components/DashboardCards.vue | 32 ++++++++- frontend/src/views/Dashboard.vue | 37 +++++++--- plugins/computers/api/routes.py | 70 +++++++++---------- plugins/computers/plugin.py | 8 ++- plugins/geenforce/plugin.py | 1 + plugins/machines/plugin.py | 1 + plugins/notifications/plugin.py | 1 + plugins/printers/plugin.py | 1 + plugins/warranty/plugin.py | 1 + tests/test_core/test_dashboard_widgets.py | 18 +++++ .../test_plugins/test_computers_dashboard.py | 56 +++++++++------ 11 files changed, 150 insertions(+), 76 deletions(-) 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):