geenforce: a PC that has gone quiet stops reading as healthy

A report records how ONE cycle went. Nothing ages it, so a PC that stops
reporting keeps the status of its last good cycle: switch a machine off after a
clean run and it shows 'ok' indefinitely. One had been offline more than a day
and still read 'ok'.

Silence is a different fact from the last cycle's outcome, so it is computed
separately rather than by rewriting the stored status. receivedat - the server's
own clock, not anything a client asserts - is compared against
geenforce_reportstaleminutes, default 30, which is roughly two missed cycles at
the usual cadence. Set it to 0 to turn the check off.

In the table 'stale' takes the badge, because a status from a machine that has
not spoken since is not evidence of anything. What it last reported stays in the
tooltip with the time it was heard. The stored status is untouched: it is still
a true record of that cycle, just not proof the PC is alive.

A site whose scope enforces less often than the threshold will read stale while
healthy, which is what the setting is for.
This commit is contained in:
cproudlock
2026-08-13 09:28:37 -04:00
parent 20a95013ad
commit 962979d483
4 changed files with 169 additions and 2 deletions

View File

@@ -11,7 +11,7 @@ Two audiences:
import ipaddress import ipaddress
import os import os
import time import time
from datetime import datetime from datetime import datetime, timedelta
from functools import wraps from functools import wraps
from flask import Blueprint, request, Response, send_file, current_app, g 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) 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']) @geenforce_bp.route('/reports', methods=['GET'])
@jwt_required() @jwt_required()
@require_permission('geenforce.manage') @require_permission('geenforce.manage')
@@ -1084,6 +1107,7 @@ def list_reports():
facts = _asset_facts({report.hostname for report in reports facts = _asset_facts({report.hostname for report in reports
if report.hostname}) if report.hostname})
stalecutoff, stalafter = _report_stale_cutoff()
latest_cache = {} latest_cache = {}
data = [] data = []
for report in reports: for report in reports:
@@ -1092,6 +1116,9 @@ def list_reports():
latest_cache[key] = _current_published_version(*key) latest_cache[key] = _current_published_version(*key)
latest = latest_cache[key] latest = latest_cache[key]
known = facts.get((report.hostname or '').lower(), {}) known = facts.get((report.hostname or '').lower(), {})
isstale = (stalecutoff is not None
and (report.receivedat is None
or report.receivedat < stalecutoff))
data.append({ data.append({
'reportid': report.reportid, 'reportid': report.reportid,
'hostname': report.hostname, 'hostname': report.hostname,
@@ -1118,7 +1145,14 @@ def list_reports():
'receivedlatest': (latest is not None 'receivedlatest': (latest is not None
and report.appliedversion == latest), and report.appliedversion == latest),
'enforcerversion': report.enforcerversion, '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, 'status': report.status,
'isstale': isstale,
'staleafterminutes': stalafter,
'installed': report.installedcount, 'installed': report.installedcount,
'skipped': report.skippedcount, 'skipped': report.skippedcount,
'failed': report.failedcount, 'failed': report.failedcount,

View File

@@ -88,7 +88,14 @@
</span> </span>
</td> </td>
<td class="muted">{{ report.appliedversion ?? '-' }} / {{ report.latestversion ?? '-' }}</td> <td class="muted">{{ report.appliedversion ?? '-' }} / {{ report.latestversion ?? '-' }}</td>
<td><span class="badge" :class="statusClass(report.status)">{{ report.status }}</span></td> <!-- A PC that stops reporting keeps the status of its last good
cycle, so silence has to outrank it: 'stale' shows instead,
with what it last reported kept in the tooltip. -->
<td>
<span v-if="report.isstale" class="badge badge-warning"
:title="staleTitle(report)">stale</span>
<span v-else class="badge" :class="statusClass(report.status)">{{ report.status }}</span>
</td>
<td>{{ report.installed }}</td> <td>{{ report.installed }}</td>
<td class="muted">{{ report.skipped }}</td> <td class="muted">{{ report.skipped }}</td>
<td :class="{ 'fail-count': report.failed }">{{ report.failed }}</td> <td :class="{ 'fail-count': report.failed }">{{ report.failed }}</td>
@@ -183,6 +190,12 @@ async function openDetail(reportid) {
function statusClass(status) { function statusClass(status) {
return { ok: 'badge-success', selfhealed: 'badge-info', failed: 'badge-danger' }[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) { function actionClass(action) {
return { installed: 'badge-info', skipped: 'badge-success', failed: 'badge-danger', return { installed: 'badge-info', skipped: 'badge-success', failed: 'badge-danger',
filtered: '' }[action] || '' filtered: '' }[action] || ''

View File

@@ -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: def init_app(self, app: Flask, db_instance) -> None:
logger.info(f"GE-Enforce plugin initialized (v{self.meta.version})") logger.info(f"GE-Enforce plugin initialized (v{self.meta.version})")

View File

@@ -185,3 +185,103 @@ def test_report_missing_hostname_rejected(client, db, app, auth_headers):
resp = client.post('/api/geenforce/report', json={'scopename': 'x'}, resp = client.post('/api/geenforce/report', json={'scopename': 'x'},
headers={'X-API-Key': secret}) headers={'X-API-Key': secret})
assert resp.status_code == 400 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