diff --git a/plugins/geenforce/api/routes.py b/plugins/geenforce/api/routes.py
index 26a7e12..86b8fb2 100644
--- a/plugins/geenforce/api/routes.py
+++ b/plugins/geenforce/api/routes.py
@@ -11,7 +11,7 @@ Two audiences:
import ipaddress
import os
import time
-from datetime import datetime
+from datetime import datetime, timedelta
from functools import wraps
from flask import Blueprint, request, Response, send_file, current_app, g
@@ -1061,6 +1061,29 @@ def _attach_backup_state(facts, hostnames):
if revision.lastseenat else None)
+def _report_stale_cutoff():
+ """(cutoff datetime, threshold minutes) for 'this PC has gone quiet'.
+
+ A report is a record of one cycle, not a heartbeat with an expiry: nothing
+ ages it. So a PC that stops reporting keeps the status of its last good
+ cycle and reads as healthy while it is unplugged. Comparing receivedat - the
+ SERVER's clock, not anything the client asserts - against a threshold is
+ what turns silence into a visible state.
+
+ Returns (None, 0) when the check is disabled, so callers skip it entirely.
+ """
+ from shopdb.api import Setting
+ try:
+ minutes = int(Setting.get('geenforce_reportstaleminutes', 30) or 0)
+ except (TypeError, ValueError):
+ minutes = 30
+ if minutes <= 0:
+ return None, 0
+ # Same clock helper that WROTE receivedat (naive UTC), so the two cannot
+ # drift into comparing an aware datetime against a naive one.
+ return service._utcnow() - timedelta(minutes=minutes), minutes
+
+
@geenforce_bp.route('/reports', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
@@ -1084,6 +1107,7 @@ def list_reports():
facts = _asset_facts({report.hostname for report in reports
if report.hostname})
+ stalecutoff, stalafter = _report_stale_cutoff()
latest_cache = {}
data = []
for report in reports:
@@ -1092,6 +1116,9 @@ def list_reports():
latest_cache[key] = _current_published_version(*key)
latest = latest_cache[key]
known = facts.get((report.hostname or '').lower(), {})
+ isstale = (stalecutoff is not None
+ and (report.receivedat is None
+ or report.receivedat < stalecutoff))
data.append({
'reportid': report.reportid,
'hostname': report.hostname,
@@ -1118,7 +1145,14 @@ def list_reports():
'receivedlatest': (latest is not None
and report.appliedversion == latest),
'enforcerversion': report.enforcerversion,
+ # 'status' is what the PC said about its LAST cycle and is left
+ # exactly as reported. 'isstale' is the server's own judgement that
+ # no cycle has been heard from in too long - a PC switched off after
+ # a clean run reports 'ok' forever, so the two are different facts
+ # and the table shows both.
'status': report.status,
+ 'isstale': isstale,
+ 'staleafterminutes': stalafter,
'installed': report.installedcount,
'skipped': report.skippedcount,
'failed': report.failedcount,
diff --git a/plugins/geenforce/frontend/views/EnforcementReports.vue b/plugins/geenforce/frontend/views/EnforcementReports.vue
index 93d1d0d..9b4cc46 100644
--- a/plugins/geenforce/frontend/views/EnforcementReports.vue
+++ b/plugins/geenforce/frontend/views/EnforcementReports.vue
@@ -88,7 +88,14 @@
{{ report.appliedversion ?? '-' }} / {{ report.latestversion ?? '-' }} |
- {{ report.status }} |
+
+
+ stale
+ {{ report.status }}
+ |
{{ report.installed }} |
{{ report.skipped }} |
{{ report.failed }} |
@@ -183,6 +190,12 @@ async function openDetail(reportid) {
function statusClass(status) {
return { ok: 'badge-success', selfhealed: 'badge-info', failed: 'badge-danger' }[status] || ''
}
+
+function staleTitle(report) {
+ const last = report.receivedat ? formatDate(report.receivedat) : 'never'
+ return `No report for over ${report.staleafterminutes} minutes.`
+ + ` Last heard ${last}, reporting "${report.status}".`
+}
function actionClass(action) {
return { installed: 'badge-info', skipped: 'badge-success', failed: 'badge-danger',
filtered: '' }[action] || ''
diff --git a/plugins/geenforce/plugin.py b/plugins/geenforce/plugin.py
index 31a2115..7dc49e2 100644
--- a/plugins/geenforce/plugin.py
+++ b/plugins/geenforce/plugin.py
@@ -141,6 +141,26 @@ class GeEnforcePlugin(BasePlugin):
},
]
+ def get_settings_defaults(self) -> List[Dict]:
+ """How long a PC may go quiet before the fleet table calls it stale."""
+ return [
+ {
+ 'key': 'geenforce_reportstaleminutes',
+ 'value': '30',
+ 'valuetype': 'integer',
+ 'category': 'geenforce',
+ 'description': 'Minutes without an enforcement report before a '
+ 'PC is shown as stale. A report records how the '
+ 'LAST cycle went, so a PC that stops reporting '
+ 'keeps whatever status it last sent - it reads '
+ 'as healthy while it is switched off. Set to 0 '
+ 'to disable the check.',
+ # Server-side only: nothing on a PC reads this, so it stays off
+ # the public settings surface.
+ 'public': False,
+ },
+ ]
+
def init_app(self, app: Flask, db_instance) -> None:
logger.info(f"GE-Enforce plugin initialized (v{self.meta.version})")
diff --git a/tests/test_plugins/test_geenforce_reporting.py b/tests/test_plugins/test_geenforce_reporting.py
index 0e322b5..db0da61 100644
--- a/tests/test_plugins/test_geenforce_reporting.py
+++ b/tests/test_plugins/test_geenforce_reporting.py
@@ -185,3 +185,103 @@ def test_report_missing_hostname_rejected(client, db, app, auth_headers):
resp = client.post('/api/geenforce/report', json={'scopename': 'x'},
headers={'X-API-Key': secret})
assert resp.status_code == 400
+
+
+# -- going quiet ------------------------------------------------------------
+#
+# A report records how one cycle went; nothing ages it. A PC switched off after
+# a clean run therefore kept 'ok' indefinitely and read as healthy - which is
+# how a machine offline for over a day still showed 'ok'.
+
+def _age_report(db, hostname, minutes):
+ """Push a stored report's server-side receivedat back in time."""
+ from datetime import timedelta
+ from plugins.geenforce.models import ManifestEnforcementReport
+ from plugins.geenforce.service import _utcnow
+ (ManifestEnforcementReport.query
+ .filter_by(hostname=hostname, iscurrent=True)
+ .update({'receivedat': _utcnow() - timedelta(minutes=minutes)}))
+ db.session.commit()
+
+
+def _post_clean_report(client, secret, hostname):
+ return client.post('/api/geenforce/report', json={
+ 'hostname': hostname, 'scopename': 'gea-shopfloor-cmm',
+ 'appliedversion': 1, 'enforcerversion': '2.6',
+ 'counts': {'installed': 1, 'skipped': 0, 'failed': 0, 'filtered': 0},
+ 'results': [{'name': 'Alpha', 'action': 'installed'}],
+ }, headers={'X-API-Key': secret})
+
+
+def test_a_pc_reporting_now_is_not_stale(client, db, app, auth_headers):
+ _seed_and_publish(app)
+ secret = _token(client, auth_headers, ['geenforce.report'])
+ _post_clean_report(client, secret, 'WJCMM01')
+
+ row = client.get('/api/geenforce/reports',
+ headers=auth_headers).get_json()['data'][0]
+ assert row['status'] == 'ok'
+ assert row['isstale'] is False
+ assert row['staleafterminutes'] == 30
+
+
+def test_a_pc_that_stopped_reporting_goes_stale(client, db, app, auth_headers):
+ _seed_and_publish(app)
+ secret = _token(client, auth_headers, ['geenforce.report'])
+ _post_clean_report(client, secret, 'WJCMM01')
+ _age_report(db, 'WJCMM01', minutes=60 * 26) # offline over a day
+
+ row = client.get('/api/geenforce/reports',
+ headers=auth_headers).get_json()['data'][0]
+ assert row['isstale'] is True
+ # The REPORTED status is left alone - it is still a true record of the last
+ # cycle, and the UI shows it in the tooltip behind the stale badge.
+ assert row['status'] == 'ok'
+
+
+def test_stale_boundary_is_the_configured_threshold(client, db, app, auth_headers):
+ _seed_and_publish(app)
+ secret = _token(client, auth_headers, ['geenforce.report'])
+ _post_clean_report(client, secret, 'WJCMM01')
+
+ _age_report(db, 'WJCMM01', minutes=29)
+ row = client.get('/api/geenforce/reports',
+ headers=auth_headers).get_json()['data'][0]
+ assert row['isstale'] is False, 'inside the window is not stale'
+
+ _age_report(db, 'WJCMM01', minutes=31)
+ row = client.get('/api/geenforce/reports',
+ headers=auth_headers).get_json()['data'][0]
+ assert row['isstale'] is True, 'past the window is stale'
+
+
+def test_stale_threshold_is_a_setting(client, db, app, auth_headers):
+ from shopdb.api import Setting
+ _seed_and_publish(app)
+ secret = _token(client, auth_headers, ['geenforce.report'])
+ _post_clean_report(client, secret, 'WJCMM01')
+ _age_report(db, 'WJCMM01', minutes=45)
+
+ Setting.set('geenforce_reportstaleminutes', '120', valuetype='integer',
+ category='geenforce')
+ db.session.commit()
+ row = client.get('/api/geenforce/reports',
+ headers=auth_headers).get_json()['data'][0]
+ assert row['isstale'] is False
+ assert row['staleafterminutes'] == 120
+
+
+def test_zero_disables_the_stale_check(client, db, app, auth_headers):
+ from shopdb.api import Setting
+ _seed_and_publish(app)
+ secret = _token(client, auth_headers, ['geenforce.report'])
+ _post_clean_report(client, secret, 'WJCMM01')
+ _age_report(db, 'WJCMM01', minutes=60 * 24 * 7)
+
+ Setting.set('geenforce_reportstaleminutes', '0', valuetype='integer',
+ category='geenforce')
+ db.session.commit()
+ row = client.get('/api/geenforce/reports',
+ headers=auth_headers).get_json()['data'][0]
+ assert row['isstale'] is False
+ assert row['staleafterminutes'] == 0