Dashboard cards, backup last-seen
This commit is contained in:
@@ -13,6 +13,7 @@ Two layers:
|
||||
"""
|
||||
|
||||
import base64
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
|
||||
import pytest
|
||||
@@ -862,3 +863,100 @@ def test_the_collection_interval_is_readable_without_a_login(bk_app):
|
||||
BackupsPlugin().get_settings_defaults()}
|
||||
assert declared['backups_intervalhours'].get('public') is True
|
||||
assert not declared['backups_shareroot'].get('public')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dashboard: backups that have STOPPED, which is not the same as unchanged
|
||||
# =============================================================================
|
||||
|
||||
def test_an_unchanged_config_records_the_check(bk_app, bk_plugin):
|
||||
"""The fix that makes the card possible at all. Dedup writes no revision on
|
||||
a no-op, so without recording the check there is no evidence a backup still
|
||||
runs - a config stable for six months looks identical to a backup that died
|
||||
six months ago."""
|
||||
from plugins.backups.models import BackupRevision
|
||||
with bk_app.app_context():
|
||||
first = bk_plugin.apply_collector_payload(_payload())
|
||||
revision = _db.session.get(BackupRevision, first['backuprevisionid'])
|
||||
_db.session.query(BackupRevision).update(
|
||||
{'lastseenat': datetime(2026, 1, 1)})
|
||||
_db.session.commit()
|
||||
|
||||
second = bk_plugin.apply_collector_payload(_payload())
|
||||
assert second['action'] == 'noop'
|
||||
_db.session.refresh(revision)
|
||||
assert revision.lastseenat > datetime(2026, 1, 2)
|
||||
|
||||
|
||||
def test_a_stable_machine_is_not_called_stale(bk_app, bk_plugin):
|
||||
"""A machine whose settings have not changed in months is HEALTHY. Keying
|
||||
the card on revision age would have flagged most of the fleet."""
|
||||
from plugins.backups.models import BackupRevision
|
||||
with bk_app.app_context():
|
||||
bk_plugin.apply_collector_payload(_payload())
|
||||
# Changed long ago, checked just now: the normal steady state.
|
||||
_db.session.query(BackupRevision).update({
|
||||
'collectedat': datetime.utcnow() - timedelta(days=180),
|
||||
'lastseenat': datetime.utcnow(),
|
||||
})
|
||||
_db.session.commit()
|
||||
|
||||
from plugins.backups.services.staleness import stalechains
|
||||
assert stalechains() == []
|
||||
|
||||
|
||||
def test_a_machine_that_stopped_checking_in_is_listed(bk_app, bk_plugin):
|
||||
from plugins.backups.models import BackupRevision
|
||||
with bk_app.app_context():
|
||||
bk_plugin.apply_collector_payload(_payload())
|
||||
_db.session.query(BackupRevision).update(
|
||||
{'lastseenat': datetime.utcnow() - timedelta(days=10)})
|
||||
_db.session.commit()
|
||||
|
||||
from plugins.backups.services.staleness import stalechains
|
||||
[row] = stalechains()
|
||||
assert row['quietdays'] == 10
|
||||
assert row['backupkind'] == 'ntlars'
|
||||
|
||||
|
||||
def test_one_marker_stopping_is_not_hidden_by_its_healthy_sibling(bk_app,
|
||||
bk_plugin):
|
||||
"""Per CHAIN, not per asset. Two part markers share one machine number, and
|
||||
a per-asset view would report the machine as fine while one device has been
|
||||
silent for a week."""
|
||||
from plugins.backups.models import BackupRevision
|
||||
with bk_app.app_context():
|
||||
bk_plugin.apply_collector_payload(
|
||||
_payload(reg=_markerreg('COM3'), sourcehostname='MARKERA'))
|
||||
bk_plugin.apply_collector_payload(
|
||||
_payload(reg=_markerreg('COM4'), sourcehostname='MARKERB'))
|
||||
(_db.session.query(BackupRevision)
|
||||
.filter_by(sourcehostname='MARKERA')
|
||||
.update({'lastseenat': datetime.utcnow() - timedelta(days=9)}))
|
||||
(_db.session.query(BackupRevision)
|
||||
.filter_by(sourcehostname='MARKERB')
|
||||
.update({'lastseenat': datetime.utcnow()}))
|
||||
_db.session.commit()
|
||||
|
||||
from plugins.backups.services.staleness import stalechains
|
||||
rows = stalechains()
|
||||
assert [r['sourcehostname'] for r in rows] == ['MARKERA']
|
||||
|
||||
|
||||
def test_the_threshold_is_a_setting_and_zero_disables_the_card(bk_app, bk_plugin):
|
||||
from shopdb.core.models import Setting
|
||||
from plugins.backups.models import BackupRevision
|
||||
with bk_app.app_context():
|
||||
bk_plugin.apply_collector_payload(_payload())
|
||||
_db.session.query(BackupRevision).update(
|
||||
{'lastseenat': datetime.utcnow() - timedelta(days=2)})
|
||||
_db.session.add(Setting(key='backups_staledays', value='1'))
|
||||
_db.session.commit()
|
||||
|
||||
from plugins.backups.services.staleness import stalechains
|
||||
assert len(stalechains()) == 1
|
||||
|
||||
_db.session.query(Setting).filter_by(key='backups_staledays').update(
|
||||
{'value': '0'})
|
||||
_db.session.commit()
|
||||
assert stalechains() == []
|
||||
|
||||
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'
|
||||
123
tests/test_plugins/test_geenforce_dashboard.py
Normal file
123
tests/test_plugins/test_geenforce_dashboard.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""GE-Enforce dashboard card: what is broken on the floor right now.
|
||||
|
||||
The reports table has always held enforcement failures - the failing entry, its
|
||||
exit code, the engine's message - and the only way to see any of it was to open
|
||||
one PC's report modal, one PC at a time. A bay returned 500 to every collector
|
||||
report for a day and a half before anyone looked. This card is the fix, so
|
||||
these tests pin the behaviour that makes it useful rather than decorative.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from plugins.geenforce.models import (ManifestEnforcementReport,
|
||||
ManifestEnforcementResult)
|
||||
|
||||
URL = '/api/geenforce/dashboard/failures'
|
||||
|
||||
|
||||
def _report(db, hostname, iscurrent=True, results=(), scopename='gea-shopfloor-cmm',
|
||||
receivedat=None):
|
||||
report = ManifestEnforcementReport(
|
||||
hostname=hostname, scopename=scopename, phase='runtime',
|
||||
status='failed', iscurrent=iscurrent,
|
||||
receivedat=receivedat or datetime(2026, 8, 11, 12, 0, 0))
|
||||
db.session.add(report)
|
||||
db.session.flush()
|
||||
for entryname, action, exitcode, message in results:
|
||||
db.session.add(ManifestEnforcementResult(
|
||||
reportid=report.reportid, entryname=entryname, action=action,
|
||||
exitcode=exitcode, message=message))
|
||||
db.session.commit()
|
||||
return report
|
||||
|
||||
|
||||
def test_a_failed_entry_is_listed_with_what_a_person_needs(client, db, auth_headers):
|
||||
"""Per ENTRY, not per report. "Three PCs failed" is a number; "Install
|
||||
OpenText failed with exit 1603 on WJSF1234" is something to act on."""
|
||||
_report(db, 'WJSF1234', results=[
|
||||
('Install OpenText', 'failed', 1603, 'Fatal error during installation')])
|
||||
|
||||
rows = client.get(URL, headers=auth_headers).get_json()['data']
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['hostname'] == 'WJSF1234'
|
||||
assert rows[0]['entryname'] == 'Install OpenText'
|
||||
assert rows[0]['exitcode'] == 1603
|
||||
assert 'Fatal error' in rows[0]['message']
|
||||
|
||||
|
||||
def test_only_failures_appear(client, db, auth_headers):
|
||||
"""A cycle installs, skips and filters far more than it fails. Listing
|
||||
anything but failures is how a card becomes wallpaper."""
|
||||
_report(db, 'WJSF1234', results=[
|
||||
('Install OpenText', 'failed', 1603, 'boom'),
|
||||
('Install Acrobat', 'installed', 0, ''),
|
||||
('Set FMS host', 'skipped', 0, ''),
|
||||
('CMM settings', 'filtered', 0, 'PCTypes filter')])
|
||||
|
||||
rows = client.get(URL, headers=auth_headers).get_json()['data']
|
||||
assert [r['entryname'] for r in rows] == ['Install OpenText']
|
||||
|
||||
|
||||
def test_a_fixed_failure_disappears_on_its_own(client, db, auth_headers):
|
||||
"""Superseded reports are not current. A failure that has since been fixed
|
||||
must clear itself rather than needing someone to dismiss it - otherwise the
|
||||
card accumulates history and stops meaning "right now"."""
|
||||
_report(db, 'WJSF1234', iscurrent=False, results=[
|
||||
('Install OpenText', 'failed', 1603, 'boom')])
|
||||
_report(db, 'WJSF1234', iscurrent=True, results=[
|
||||
('Install OpenText', 'installed', 0, '')])
|
||||
|
||||
assert client.get(URL, headers=auth_headers).get_json()['data'] == []
|
||||
|
||||
|
||||
def test_the_row_links_to_the_pc_when_shopdb_knows_it(client, db, auth_headers):
|
||||
"""A card that names a PC without linking to it is a worse report."""
|
||||
from plugins.computers.models import Computer
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
|
||||
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='WJSF1234',
|
||||
assettypeid=atype.assettypeid, statusid=1)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
comp = Computer(assetid=asset.assetid, hostname='WJSF1234')
|
||||
db.session.add(comp)
|
||||
db.session.commit()
|
||||
|
||||
_report(db, 'WJSF1234', results=[('Install OpenText', 'failed', 1603, 'x')])
|
||||
rows = client.get(URL, headers=auth_headers).get_json()['data']
|
||||
assert rows[0]['computerid'] == comp.computerid
|
||||
|
||||
|
||||
def test_a_pc_shopdb_does_not_know_still_reports_its_failure(client, db,
|
||||
auth_headers):
|
||||
"""The failure is real even when the inventory is behind. Dropping the row
|
||||
would hide exactly the bay most likely to be misconfigured."""
|
||||
_report(db, 'GHOSTPC', results=[('Install OpenText', 'failed', 1603, 'x')])
|
||||
rows = client.get(URL, headers=auth_headers).get_json()['data']
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['computerid'] is None
|
||||
|
||||
|
||||
def test_the_card_is_permission_gated(client, db, member_headers):
|
||||
"""A dashboard card must not become a way around RBAC."""
|
||||
assert client.get(URL, headers=member_headers).status_code == 403
|
||||
|
||||
|
||||
def test_the_widget_declares_data_and_a_renderer_not_a_component(app):
|
||||
"""The old contract named a Vue component per widget, which cannot survive
|
||||
a lean build - which is why five plugins declared widgets pointing at
|
||||
components nobody wrote. A plugin declares data and shape; core renders."""
|
||||
from plugins.geenforce.plugin import GeEnforcePlugin
|
||||
|
||||
widget = GeEnforcePlugin().get_dashboard_widgets()[0]
|
||||
assert 'component' not in widget
|
||||
assert widget['render'] == 'exceptions'
|
||||
assert widget['permission'] == 'geenforce.manage'
|
||||
# Empty cards must shrink, or the board becomes wallpaper.
|
||||
assert widget['empty'] == 'hide'
|
||||
assert widget['map']['link'] == '/pcs/{computerid}'
|
||||
Reference in New Issue
Block a user