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:
@@ -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,
|
||||
|
||||
@@ -88,7 +88,14 @@
|
||||
</span>
|
||||
</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 class="muted">{{ report.skipped }}</td>
|
||||
<td :class="{ 'fail-count': report.failed }">{{ report.failed }}</td>
|
||||
@@ -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] || ''
|
||||
|
||||
@@ -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})")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user