From 1ca8a9b8e871f4fbbd10564fc1b15765ff78e9b1 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Tue, 11 Aug 2026 13:40:41 -0400 Subject: [PATCH] dashboard: PCs not reporting, and the card styling standard it broke Second wave-one card. GET /api/computers/dashboard/quiet lists two populations and deliberately does not merge them into one count. 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 - the same shape as the bay that carried a wrong machine number for weeks. Never-reported sorts above the merely quiet, then longest silence first: the order someone should work down the list, not the order rows left the table. A soft-deleted PC is excluded - a decommissioned machine is silent on purpose, and listing it would train people to ignore the card, which is the failure this whole board exists to avoid. The window is computers_quietreporthours, default 24, because every site will disagree with any number picked here (ADR-015). A malformed value falls back rather than failing the card. This also replaces the computers plugin's old widget declaration, which named a component nobody ever wrote. Four such declarations remain and will convert as their cards arrive. Two fixes to the renderer found while wiring this up. Meta specs now support a trailing unit, so a row reads 'quiet for 3 days' rather than 'quiet for 3'. And the card styles hardcoded hex colours against the frontend standard, including a var(--card-bg) that DOES NOT EXIST - the variable is --bg-card - so the fallback would have painted every card white and broken dark mode entirely. Now --bg-card, --border, --danger, --warning, --primary and --link throughout. --- frontend/src/components/DashboardCards.vue | 12 +- frontend/src/components/dashboardCards.js | 5 +- .../src/components/dashboardCards.spec.js | 9 ++ plugins/computers/api/routes.py | 60 +++++++++ plugins/computers/plugin.py | 36 +++++- .../test_plugins/test_computers_dashboard.py | 120 ++++++++++++++++++ 6 files changed, 229 insertions(+), 13 deletions(-) create mode 100644 tests/test_plugins/test_computers_dashboard.py diff --git a/frontend/src/components/DashboardCards.vue b/frontend/src/components/DashboardCards.vue index bd414b1..9ef787c 100644 --- a/frontend/src/components/DashboardCards.vue +++ b/frontend/src/components/DashboardCards.vue @@ -98,17 +98,17 @@ defineExpose({ load }) margin-bottom: 1.5rem; } .dc-card { - background: var(--card-bg, #fff); - border: 1px solid var(--border, #e3e3e3); + background: var(--bg-card); + border: 1px solid var(--border); border-left-width: 4px; border-radius: 8px; padding: 0.9rem 1rem; } /* 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: #dc3545; } -.dc-warning { border-left-color: #ffc107; } -.dc-info { border-left-color: #0d6efd; } +.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; } .dc-title { margin: 0; font-size: 0.95rem; font-weight: 600; color: var(--text); } @@ -117,7 +117,7 @@ defineExpose({ load }) .dc-rows { list-style: none; margin: 0.6rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; } .dc-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem; font-size: 0.85rem; } -.dc-row-title { font-weight: 600; color: var(--link, #0d6efd); text-decoration: none; } +.dc-row-title { font-weight: 600; color: var(--link); text-decoration: none; } .dc-row-title:hover { text-decoration: underline; } .dc-row-nolink { color: var(--text); } .dc-row-detail { color: var(--text); } diff --git a/frontend/src/components/dashboardCards.js b/frontend/src/components/dashboardCards.js index 9c26aab..55c87d5 100644 --- a/frontend/src/components/dashboardCards.js +++ b/frontend/src/components/dashboardCards.js @@ -49,7 +49,10 @@ export function mapMeta(card, item) { .map((spec) => { const value = formatValue(item[spec.key], spec) if (value === '') return null - return { text: spec.label ? `${spec.label} ${value}` : value, mono: !!spec.mono } + // label prefixes, suffix trails: 'quiet for 3 days' reads as a sentence, + // where 'quiet for 3' reads as a truncation. + const text = `${spec.label ? spec.label + ' ' : ''}${value}${spec.suffix || ''}` + return { text, mono: !!spec.mono } }) .filter(Boolean) } diff --git a/frontend/src/components/dashboardCards.spec.js b/frontend/src/components/dashboardCards.spec.js index 1aa0cd9..c88e0fd 100644 --- a/frontend/src/components/dashboardCards.spec.js +++ b/frontend/src/components/dashboardCards.spec.js @@ -53,6 +53,15 @@ describe('mapping a row', () => { expect(row.link).toBe('/pcs/42') }) + it('supports a trailing unit so the text reads as a sentence', () => { + const card = { + render: 'exceptions', + map: { title: 'hostname', meta: [{ key: 'quietdays', label: 'quiet for', suffix: ' days' }] }, + _data: [{ hostname: 'QUIETPC', quietdays: 3 }], + } + expect(cardRows(card)[0].meta[0].text).toBe('quiet for 3 days') + }) + it('drops empty meta values instead of rendering a stray label', () => { const card = { ...failuresCard, _data: [{ hostname: 'X', message: '', exitcode: null }] } expect(cardRows(card)[0].meta).toEqual([]) diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py index aac1eb3..0be067c 100644 --- a/plugins/computers/api/routes.py +++ b/plugins/computers/api/routes.py @@ -934,3 +934,63 @@ def dashboard_summary(): 'shopfloor': shopfloor_count, 'nonshopfloor': total - shopfloor_count }) + + +@computers_bp.route('/dashboard/quiet', methods=['GET']) +@jwt_required() +@require_permission('computers.view') +def dashboard_quiet(): + """PCs that have stopped reporting, and PCs that never started. + + 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. + + 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. + """ + from datetime import datetime, timedelta, timezone + + hours = 24 + setting = Setting.query.filter_by(key='computers_quietreporthours').first() + if setting and (setting.value or '').strip(): + try: + hours = int(setting.value) + except (TypeError, ValueError): + pass + cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=hours) + + query = (db.session.query(Computer, Asset) + .join(Asset, Asset.assetid == Computer.assetid) + .filter(Asset.isactive.is_(True))) + + 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, + }) + + # 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]) diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index 5b849f9..ff21e05 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -163,6 +163,14 @@ class ComputersPlugin(BasePlugin): site that installed an earlier one. """ return [ + { + 'key': 'computers_quietreporthours', + 'value': '24', + 'valuetype': 'integer', + 'category': 'computers', + 'description': 'Hours without a collector report before a PC ' + 'is listed as not reporting on the dashboard.', + }, { 'key': 'computers_machinelink_alerts', 'value': 'false', @@ -1247,14 +1255,30 @@ class ComputersPlugin(BasePlugin): return [computerscli] def get_dashboard_widgets(self) -> List[Dict]: - """Return dashboard widget definitions.""" + """Dashboard cards this plugin contributes. + + REPLACES a declaration that named a Vue component which was never + written - as did four other plugins - so nothing rendered and nobody + noticed, because the frontend never called the widgets endpoint either. + The contract now carries data and a renderer; core draws it. + """ return [ { - 'name': 'Computer Status', - 'component': 'ComputerStatusWidget', - 'endpoint': '/api/computers/dashboard/summary', - 'size': 'medium', - 'position': 6, + 'id': 'computers-quiet', + 'title': 'PCs not reporting', + 'endpoint': '/api/computers/dashboard/quiet', + 'render': 'exceptions', + 'severity': 'warning', + 'permission': 'computers.view', + 'empty': 'hide', + 'position': 20, + 'map': { + 'title': 'hostname', + 'detail': 'state', + 'meta': [{'key': 'quietdays', 'label': 'quiet for', + 'suffix': ' days'}], + 'link': '/pcs/{computerid}', + }, }, ] diff --git a/tests/test_plugins/test_computers_dashboard.py b/tests/test_plugins/test_computers_dashboard.py new file mode 100644 index 0000000..b93f5bc --- /dev/null +++ b/tests/test_plugins/test_computers_dashboard.py @@ -0,0 +1,120 @@ +"""Dashboard card: PCs that stopped reporting, and PCs that never started. + +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. +""" + +from datetime import datetime, timedelta, timezone + +from shopdb.core.models import Asset, AssetType, Setting + +URL = '/api/computers/dashboard/quiet' + + +def _now(): + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _pc(db, hostname, lastreported='now', isactive=True): + from plugins.computers.models import Computer + + atype = AssetType.query.filter_by(assettype='computer').first() + if not atype: + atype = AssetType(assettype='computer') + db.session.add(atype) + db.session.flush() + asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid, + statusid=1, isactive=isactive) + db.session.add(asset) + db.session.flush() + comp = Computer(assetid=asset.assetid, hostname=hostname) + if lastreported == 'now': + comp.lastreporteddate = _now() + elif lastreported is None: + comp.lastreporteddate = None + else: + comp.lastreporteddate = _now() - timedelta(hours=lastreported) + db.session.add(comp) + db.session.commit() + return comp + + +def test_a_reporting_pc_is_not_listed(client, db, auth_headers): + _pc(db, 'BUSYPC') + assert client.get(URL, headers=auth_headers).get_json()['data'] == [] + + +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.""" + _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 + + +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_the_longest_silence_comes_first(client, db, auth_headers): + _pc(db, 'DAY2', lastreported=48) + _pc(db, 'DAY9', lastreported=216) + rows = client.get(URL, headers=auth_headers).get_json()['data'] + assert [r['hostname'] for r in rows] == ['DAY9', 'DAY2'] + + +def test_the_window_is_a_setting_not_a_hardcode(client, db, auth_headers): + """Every site will disagree with 24 hours (ADR-015).""" + _pc(db, 'OVERNIGHT', lastreported=10) + assert client.get(URL, headers=auth_headers).get_json()['data'] == [] + + db.session.add(Setting(key='computers_quietreporthours', value='4')) + db.session.commit() + rows = client.get(URL, headers=auth_headers).get_json()['data'] + assert [r['hostname'] for r in rows] == ['OVERNIGHT'] + + +def test_a_bad_setting_falls_back_rather_than_failing_the_card(client, db, + auth_headers): + db.session.add(Setting(key='computers_quietreporthours', value='soon')) + db.session.commit() + _pc(db, 'QUIETPC', lastreported=72) + assert client.get(URL, headers=auth_headers).status_code == 200 + + +def test_a_retired_pc_is_not_nagged_about(client, db, auth_headers): + """A decommissioned PC is silent on purpose. Listing it would train people + to ignore the card, which is the failure mode this whole board avoids.""" + _pc(db, 'RETIRED', lastreported=None, isactive=False) + assert client.get(URL, headers=auth_headers).get_json()['data'] == [] + + +def test_the_card_is_permission_gated(client, db, member_headers): + assert client.get(URL, headers=member_headers).status_code == 403 + + +def test_the_widget_uses_the_new_contract(app): + from plugins.computers.plugin import ComputersPlugin + + widget = ComputersPlugin().get_dashboard_widgets()[0] + assert 'component' not in widget + assert widget['render'] == 'exceptions' + assert widget['permission'] == 'computers.view' + assert widget['empty'] == 'hide'