dashboard: convert the last dead widgets, and delete the one that had nothing
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s

Three plugins still declared widgets naming Vue components nobody wrote.
Converting them honestly meant three different answers, not one.

notifications gets a real card: the active notifications themselves, not a
count. "4 active" tells an admin nothing; knowing WHICH message the shop is
looking at is the point, and it is how a stale one gets noticed and taken down.

machines gets machines out of service - anything not In Use, excluding
Inventory, because a spare on a shelf is stock rather than a problem. Someone is
supposed to be chasing each of those and today they are visible only to whoever
thinks to filter the list by status.

network gets NOTHING, and its declaration is deleted rather than converted.
Network devices carry no live status - no polling, no reachability check,
nothing that can be wrong - so the only possible card is a count of how many
exist, which is precisely the always-true number this dashboard exists to get
away from. A comment records that, so the next person does not re-add it. If
reachability is ever collected, that is the card.

Also adds a contract test over every declared card: no component names, a valid
renderer and severity, and - the one that matters - the endpoint must be a REAL
route. A declaration pointing at a route nobody wrote is exactly how the old
widgets rotted unnoticed for months, and now it fails the build instead.
This commit is contained in:
cproudlock
2026-08-11 14:27:29 -04:00
parent 7151b68bdd
commit 5eb84873e8
7 changed files with 174 additions and 53 deletions

View File

@@ -24,8 +24,9 @@ ADR-007 and ADR-002.
four, so the reader had to hunt for the problem inside the row. The report
exists to answer "what needs replacing".
- Dashboard cards for enforcement failures, machine numbers claimed by two PCs,
PCs not reporting, backups that have stopped, printer supplies, and expiring
warranties. The dashboard now renders cards plugins declare, which it never
PCs not reporting, backups that have stopped, printer supplies, expiring
warranties, machines out of service, and the notifications currently showing
on the floor. The dashboard now renders cards plugins declare, which it never
did before - five plugins had been declaring widgets into a void, pointing at
components nobody wrote. Cards are ordered by severity, hide themselves when
there is nothing to report, gate on a permission, and each fetches

View File

@@ -558,3 +558,35 @@ def dashboard_summary():
'bytype': [{'type': t, 'count': c} for t, c in by_type],
'bystatus': [{'status': s, 'count': c} for s, c in by_status]
})
@machines_bp.route('/dashboard/outofservice', methods=['GET'])
@jwt_required()
@require_permission('machines.view')
def dashboard_outofservice():
"""Machines whose status says they are not running.
Anything that is not In Use: In Repair, Lost, Returned, and so on. Someone
is supposed to be chasing each of these, and today they are visible only to
whoever thinks to filter the machines list by status.
Inventory is excluded - a spare on a shelf is not a problem, it is stock.
"""
from shopdb.api import AssetStatus
ignored = ('In Use', 'Inventory')
rows = (db.session.query(Machine, Asset, AssetStatus)
.join(Asset, Asset.assetid == Machine.assetid)
.join(AssetStatus, AssetStatus.statusid == Asset.statusid)
.filter(Asset.isactive.is_(True),
AssetStatus.status.notin_(ignored))
.order_by(Asset.assetnumber)
.limit(50).all())
return success_response([{
'assetid': asset.assetid,
'assetnumber': asset.assetnumber or str(asset.assetid),
'name': asset.name,
'status': status.status,
} for _machine, asset, status in rows])

View File

@@ -196,14 +196,30 @@ class MachinesPlugin(BasePlugin):
return [machinescli]
def get_dashboard_widgets(self) -> List[Dict]:
"""Return dashboard widget definitions."""
"""Dashboard card: machines not in service.
A machine sitting in Repair or marked Lost is a thing someone is
supposed to be chasing, and nothing surfaces it today - it is visible
only to whoever thinks to filter the machines list by status.
Replaces a declaration naming a component nobody wrote.
"""
return [
{
'name': 'Machine Status',
'component': 'MachineStatusWidget',
'endpoint': '/api/machines/dashboard/summary',
'size': 'medium',
'position': 5,
'id': 'machines-outofservice',
'title': 'Machines out of service',
'endpoint': '/api/machines/dashboard/outofservice',
'render': 'exceptions',
'severity': 'warning',
'permission': 'machines.view',
'empty': 'hide',
'position': 45,
'map': {
'title': 'assetnumber',
'detail': 'status',
'meta': [{'key': 'name'}],
'link': '/machines/{assetid}',
},
},
]

View File

@@ -192,17 +192,13 @@ class NetworkPlugin(BasePlugin):
return [networkcli]
def get_dashboard_widgets(self) -> List[Dict]:
"""Return dashboard widget definitions."""
return [
{
'name': 'Network Status',
'component': 'NetworkStatusWidget',
'endpoint': '/api/network/dashboard/summary',
'size': 'medium',
'position': 7,
},
]
# NO dashboard card, deliberately. This plugin declared one for months
# naming a component nobody wrote, so nothing rendered. Converting it would
# have meant inventing a card: network devices carry no live status - there
# is no polling, no reachability check, nothing that can be WRONG - so the
# only card possible is a count of how many exist, which is the kind of
# always-true number this dashboard exists to get away from. If reachability
# is ever collected, that is the card.
def get_navigation_items(self) -> List[Dict]:
"""Return navigation menu items."""

View File

@@ -871,3 +871,36 @@ def get_shopfloor_notifications():
'upcoming': upcoming_data,
'configversion': _config_version(),
})
@notifications_bp.route('/dashboard/active', methods=['GET'])
@jwt_required()
def dashboard_active():
"""Notifications currently showing on the floor.
The items, not a count. "4 active notifications" tells an admin nothing;
knowing WHICH message the shop is looking at right now is the point,
particularly when one is stale and nobody has taken it down.
A null start or end means open-ended, matching how the boards read them.
"""
now = datetime.now(timezone.utc).replace(tzinfo=None)
rows = (Notification.query
.filter(db.or_(Notification.starttime.is_(None),
Notification.starttime <= now),
db.or_(Notification.endtime.is_(None),
Notification.endtime >= now))
.order_by(Notification.starttime.desc())
.limit(25).all())
out = []
for row in rows:
notificationtype = getattr(row, 'notificationtype', None)
out.append({
'notificationid': row.notificationid,
'message': (row.notification or '').strip()[:120],
'typename': notificationtype.typename if notificationtype else None,
'endtime': row.endtime.isoformat() + 'Z' if row.endtime else None,
})
return success_response(out)

View File

@@ -193,14 +193,30 @@ class NotificationsPlugin(BasePlugin):
return [notifications_cli]
def get_dashboard_widgets(self) -> List[Dict]:
"""Return dashboard widget definitions."""
"""Dashboard card: what the floor is being shown right now.
The items, not a count. "4 active notifications" tells an admin
nothing; knowing WHICH message is on the boards is the point, and it is
how a stale one gets noticed and taken down.
Replaces a declaration naming a component nobody wrote.
"""
return [
{
'name': 'Active Notifications',
'component': 'NotificationsWidget',
'endpoint': '/api/notifications/dashboard/summary',
'size': 'small',
'position': 1,
'id': 'notifications-active',
'title': 'Active notifications',
'endpoint': '/api/notifications/dashboard/active',
'render': 'list',
'severity': 'info',
'empty': 'hide',
'position': 60,
'map': {
'title': 'typename',
'detail': 'message',
'meta': [{'key': 'endtime', 'label': 'until',
'format': 'date'}],
'link': '/notifications/{notificationid}',
},
},
]

View File

@@ -1,38 +1,65 @@
"""Tests for the dashboard-widgets hook consumer (/api/dashboard/widgets).
"""Every declared dashboard card obeys the contract.
Pins the wiring added for the BasePlugin.get_dashboard_widgets hook: the
endpoint aggregates enabled plugins' widgets and skips disabled ones.
Plugin enabled-state is monkeypatched (not persisted) so these tests do not
mutate the shared instance/plugins.json registry file.
Five plugins once declared widgets naming Vue components that were never
written, and nothing rendered - the frontend did not call the endpoint either,
so the mismatch went unnoticed for months. These tests make that class of
mistake impossible to repeat quietly: a declaration is checked against the
contract, and its endpoint must actually exist.
"""
import pytest
def _widget_plugins(client, headers):
response = client.get('/api/dashboard/widgets', headers=headers)
assert response.status_code == 200, response.get_json()
widgets = response.get_json()['data']
assert isinstance(widgets, list)
return widgets, {w.get('plugin') for w in widgets}
RENDERERS = ('exceptions', 'metric', 'list')
def test_widgets_endpoint_aggregates_enabled_plugins(app, client, auth_headers,
monkeypatch):
"""An enabled plugin that implements the hook contributes a widget."""
def _declared(app):
pm = app.extensions['plugin_manager']
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
widgets, plugins = _widget_plugins(client, auth_headers)
assert 'computers' in plugins # computers implements get_dashboard_widgets
positions = [w.get('position', 99) for w in widgets]
assert positions == sorted(positions)
cards = []
for name, plugin in pm.get_all_plugins().items():
for card in plugin.get_dashboard_widgets() or []:
cards.append((name, card))
return cards
def test_widgets_endpoint_skips_disabled_plugin(app, client, auth_headers,
monkeypatch):
"""A disabled plugin's widgets drop out of the aggregate."""
pm = app.extensions['plugin_manager']
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'computers')
def test_there_are_cards_to_check(app):
with app.app_context():
assert _declared(app), 'no plugin declares a dashboard card'
_, plugins = _widget_plugins(client, auth_headers)
assert 'computers' not in plugins
def test_no_card_names_a_frontend_component(app):
"""The old contract. It cannot survive a lean build, where a plugin's
component may never be staged into the bundle."""
with app.app_context():
named = [(p, c.get('id') or c.get('name')) for p, c in _declared(app)
if 'component' in c]
assert named == []
def test_every_card_declares_the_fields_core_renders_from(app):
with app.app_context():
for pluginname, card in _declared(app):
assert card.get('id'), pluginname
assert card.get('title'), card
assert card.get('endpoint'), card
assert card.get('render') in RENDERERS, card
assert card.get('severity') in ('critical', 'warning', 'info'), card
def test_every_card_endpoint_is_a_real_route(app):
"""A declaration pointing at a route nobody wrote is exactly how the old
widgets rotted."""
with app.app_context():
rules = {str(rule) for rule in app.url_map.iter_rules()}
for pluginname, card in _declared(app):
endpoint = card['endpoint'].split('?')[0]
assert endpoint in rules, '{}: {} is not a route'.format(
pluginname, endpoint)
def test_every_card_links_somewhere(app):
"""A row that names a problem without linking to it is a worse report."""
with app.app_context():
for pluginname, card in _declared(app):
if card['render'] == 'metric':
continue
assert (card.get('map') or {}).get('link'), card