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.
This commit is contained in:
@@ -98,17 +98,17 @@ defineExpose({ load })
|
|||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
.dc-card {
|
.dc-card {
|
||||||
background: var(--card-bg, #fff);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border, #e3e3e3);
|
border: 1px solid var(--border);
|
||||||
border-left-width: 4px;
|
border-left-width: 4px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.9rem 1rem;
|
padding: 0.9rem 1rem;
|
||||||
}
|
}
|
||||||
/* Severity is carried by the left edge only. A fully coloured card reads as an
|
/* 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. */
|
alert even when it holds one minor row, and six of them read as a crisis. */
|
||||||
.dc-critical { border-left-color: #dc3545; }
|
.dc-critical { border-left-color: var(--danger); }
|
||||||
.dc-warning { border-left-color: #ffc107; }
|
.dc-warning { border-left-color: var(--warning); }
|
||||||
.dc-info { border-left-color: #0d6efd; }
|
.dc-info { border-left-color: var(--primary); }
|
||||||
|
|
||||||
.dc-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; }
|
.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); }
|
.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-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 { 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-title:hover { text-decoration: underline; }
|
||||||
.dc-row-nolink { color: var(--text); }
|
.dc-row-nolink { color: var(--text); }
|
||||||
.dc-row-detail { color: var(--text); }
|
.dc-row-detail { color: var(--text); }
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ export function mapMeta(card, item) {
|
|||||||
.map((spec) => {
|
.map((spec) => {
|
||||||
const value = formatValue(item[spec.key], spec)
|
const value = formatValue(item[spec.key], spec)
|
||||||
if (value === '') return null
|
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)
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,15 @@ describe('mapping a row', () => {
|
|||||||
expect(row.link).toBe('/pcs/42')
|
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', () => {
|
it('drops empty meta values instead of rendering a stray label', () => {
|
||||||
const card = { ...failuresCard, _data: [{ hostname: 'X', message: '', exitcode: null }] }
|
const card = { ...failuresCard, _data: [{ hostname: 'X', message: '', exitcode: null }] }
|
||||||
expect(cardRows(card)[0].meta).toEqual([])
|
expect(cardRows(card)[0].meta).toEqual([])
|
||||||
|
|||||||
@@ -934,3 +934,63 @@ def dashboard_summary():
|
|||||||
'shopfloor': shopfloor_count,
|
'shopfloor': shopfloor_count,
|
||||||
'nonshopfloor': total - 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])
|
||||||
|
|||||||
@@ -163,6 +163,14 @@ class ComputersPlugin(BasePlugin):
|
|||||||
site that installed an earlier one.
|
site that installed an earlier one.
|
||||||
"""
|
"""
|
||||||
return [
|
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',
|
'key': 'computers_machinelink_alerts',
|
||||||
'value': 'false',
|
'value': 'false',
|
||||||
@@ -1247,14 +1255,30 @@ class ComputersPlugin(BasePlugin):
|
|||||||
return [computerscli]
|
return [computerscli]
|
||||||
|
|
||||||
def get_dashboard_widgets(self) -> List[Dict]:
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
'name': 'Computer Status',
|
'id': 'computers-quiet',
|
||||||
'component': 'ComputerStatusWidget',
|
'title': 'PCs not reporting',
|
||||||
'endpoint': '/api/computers/dashboard/summary',
|
'endpoint': '/api/computers/dashboard/quiet',
|
||||||
'size': 'medium',
|
'render': 'exceptions',
|
||||||
'position': 6,
|
'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}',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
120
tests/test_plugins/test_computers_dashboard.py
Normal file
120
tests/test_plugins/test_computers_dashboard.py
Normal file
@@ -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'
|
||||||
Reference in New Issue
Block a user