add operator issue reports for endpoint agent
Operators had no way to flag a problem PC from the tray. This adds a report path: the tray captures a reason, free-text note, and optional screenshot and the agent uploads it on the collector token to a new operatorreports table linked to the asset. Ships the endpoints 0009 migration so the table exists before the code queries it; the running DB had drifted behind the code and returned HTTP 500 on upload.
This commit is contained in:
@@ -25,7 +25,10 @@ from shopdb.api import (
|
||||
Application, AppVersion, Script,
|
||||
)
|
||||
|
||||
from ..models import EndpointState, EndpointEvent, EndpointCommand, EndpointApplication
|
||||
from ..models import (
|
||||
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
|
||||
OperatorReport,
|
||||
)
|
||||
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for
|
||||
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
|
||||
|
||||
@@ -1766,3 +1769,174 @@ def download_script_body(script_id: int):
|
||||
return send_upload(script_dir, script.filename, as_attachment=True)
|
||||
|
||||
|
||||
# Operator issue reports (tray screenshot + free-text notes)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Retention groundwork: how many days an operator report (and its screenshot
|
||||
# file) is kept before the purge sweep removes it. Read from the Setting store
|
||||
# so Agent Configuration can expose a toggle later without a code change; the
|
||||
# default is 7 days. A value of 0 or less means "keep forever" (purge disabled).
|
||||
OPERATOR_REPORT_RETENTION_SETTING = 'endpoints.operatorreport.retentiondays'
|
||||
OPERATOR_REPORT_RETENTION_DEFAULT_DAYS = 7
|
||||
|
||||
# Screenshots are small PNGs from a virtual-screen grab. Cap the upload so a
|
||||
# malformed or hostile agent cannot fill the disk with one request.
|
||||
OPERATOR_REPORT_MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def _operator_report_dir():
|
||||
return os.path.join(current_app.instance_path, 'operatorreports')
|
||||
|
||||
|
||||
def _operator_report_retention_days():
|
||||
"""Configured retention window in whole days (default 7, <=0 disables)."""
|
||||
raw = Setting.get(OPERATOR_REPORT_RETENTION_SETTING)
|
||||
if raw is None:
|
||||
return OPERATOR_REPORT_RETENTION_DEFAULT_DAYS
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return OPERATOR_REPORT_RETENTION_DEFAULT_DAYS
|
||||
|
||||
|
||||
def _purge_expired_operator_reports():
|
||||
"""Delete operator reports older than the retention window, files and all.
|
||||
|
||||
Best-effort and idempotent: called opportunistically on upload so the feed
|
||||
stays bounded without a scheduler. A retention of <=0 disables the sweep.
|
||||
"""
|
||||
days = _operator_report_retention_days()
|
||||
if days <= 0:
|
||||
return
|
||||
|
||||
cutoff = _naive_utcnow() - timedelta(days=days)
|
||||
stale = OperatorReport.query.filter(OperatorReport.createdat < cutoff).all()
|
||||
if not stale:
|
||||
return
|
||||
|
||||
report_dir = _operator_report_dir()
|
||||
for report in stale:
|
||||
if report.imagefilename:
|
||||
path = os.path.join(report_dir, report.imagefilename)
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
db.session.delete(report)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@endpoints_bp.route('/operatorreports', methods=['POST'])
|
||||
def upload_operator_report():
|
||||
"""Agent uploads one operator issue report (collector-token auth).
|
||||
|
||||
The tray captures the operator's free-text note and an optional screenshot,
|
||||
then hands them to the service, which posts here as multipart/form-data on
|
||||
the same endpoints collector token used for telemetry. The reported hostname
|
||||
resolves to an asset; an unresolved hostname is a validation error. The
|
||||
screenshot bytes are written to the operator-reports uploads dir and the row
|
||||
holds only metadata plus the stored filename. One retention sweep runs
|
||||
opportunistically so the feed stays bounded.
|
||||
"""
|
||||
from werkzeug.utils import secure_filename
|
||||
import uuid
|
||||
|
||||
error = _collector_auth_or_error()
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
hostname = (request.form.get('hostname') or '').strip()
|
||||
if not hostname:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'hostname is required')
|
||||
|
||||
asset_id = _resolve_asset_for_hostname(hostname)
|
||||
if asset_id is None:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'could not resolve hostname to a computer: {!r}'.format(hostname))
|
||||
|
||||
reason = (request.form.get('reason') or 'Other (free text)').strip()[:128]
|
||||
details = (request.form.get('details') or '').strip() or None
|
||||
|
||||
imagefilename = None
|
||||
imagemimetype = None
|
||||
imagebytesize = None
|
||||
|
||||
upload = request.files.get('screenshot')
|
||||
if upload is not None and upload.filename:
|
||||
data = upload.read()
|
||||
if len(data) > OPERATOR_REPORT_MAX_IMAGE_BYTES:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'screenshot exceeds the {} byte limit'.format(
|
||||
OPERATOR_REPORT_MAX_IMAGE_BYTES))
|
||||
if data:
|
||||
ext = os.path.splitext(secure_filename(upload.filename))[1].lower()
|
||||
if ext != '.png':
|
||||
ext = '.png'
|
||||
imagefilename = 'report-{}{}'.format(uuid.uuid4().hex, ext)
|
||||
imagemimetype = 'image/png'
|
||||
imagebytesize = len(data)
|
||||
report_dir = _operator_report_dir()
|
||||
os.makedirs(report_dir, exist_ok=True)
|
||||
with open(os.path.join(report_dir, imagefilename), 'wb') as handle:
|
||||
handle.write(data)
|
||||
|
||||
report = OperatorReport(
|
||||
assetid=asset_id,
|
||||
reason=reason,
|
||||
details=details,
|
||||
imagefilename=imagefilename,
|
||||
imagemimetype=imagemimetype,
|
||||
imagebytesize=imagebytesize,
|
||||
reportedhostname=hostname,
|
||||
)
|
||||
db.session.add(report)
|
||||
db.session.commit()
|
||||
|
||||
# Keep the feed bounded without a scheduler. Best-effort: a purge failure
|
||||
# must not fail the upload the operator already completed.
|
||||
try:
|
||||
_purge_expired_operator_reports()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
return success_response(report.to_dict(),
|
||||
message='Operator report received', http_code=201)
|
||||
|
||||
|
||||
@endpoints_bp.route('/panel/<int:asset_id>/operatorreports', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_operator_reports(asset_id: int):
|
||||
"""Operator Reports panel: this PC's recent operator issue reports.
|
||||
|
||||
Newest first. Each row carries a hasimage flag so the viewer knows whether
|
||||
to request the screenshot from the serve route below. Runs a retention
|
||||
sweep opportunistically so a viewed PC does not accumulate stale rows.
|
||||
"""
|
||||
try:
|
||||
_purge_expired_operator_reports()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
reports = (OperatorReport.query
|
||||
.filter_by(assetid=asset_id)
|
||||
.order_by(OperatorReport.createdat.desc())
|
||||
.all())
|
||||
return success_response({'reports': [r.to_dict() for r in reports]})
|
||||
|
||||
|
||||
@endpoints_bp.route('/operatorreports/<int:operatorreportid>/image',
|
||||
methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def serve_operator_report_image(operatorreportid: int):
|
||||
"""Serve one operator report's screenshot (user JWT + endpoints.view)."""
|
||||
from shopdb.api import send_upload
|
||||
|
||||
report = db.session.get(OperatorReport, operatorreportid)
|
||||
if report is None or not report.imagefilename:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'No screenshot for this report', http_code=404)
|
||||
return send_upload(_operator_report_dir(), report.imagefilename)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user