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):