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)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Operator issue reports (tray screenshot + free-text notes).
|
||||
|
||||
Adds operatorreports: one row per operator-submitted issue report for a PC
|
||||
asset. The screenshot bytes live on disk under the instance uploads dir; this
|
||||
table holds only metadata plus the stored file name so the image can be served
|
||||
back and purged on retention. Reports are transient - a retention window
|
||||
(default 7 days) purges old rows and their files - so this is a rolling
|
||||
operator-signal feed, not a permanent record.
|
||||
|
||||
Idempotent: creation checks the live schema first so a db.create_all() site
|
||||
(which already built the table) re-runs clean on strict MySQL 8.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0009opreports'
|
||||
down_revision = 'endpoints0008drift'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
return inspect(op.get_bind()).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade():
|
||||
if _has_table('operatorreports'):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
'operatorreports',
|
||||
sa.Column('operatorreportid', sa.Integer(), nullable=False),
|
||||
sa.Column('assetid', sa.Integer(), nullable=False),
|
||||
sa.Column('reason', sa.String(length=128), nullable=False,
|
||||
server_default='Other (free text)'),
|
||||
sa.Column('details', sa.Text(), nullable=True),
|
||||
sa.Column('imagefilename', sa.String(length=255), nullable=True),
|
||||
sa.Column('imagemimetype', sa.String(length=64), nullable=True),
|
||||
sa.Column('imagebytesize', sa.Integer(), nullable=True),
|
||||
sa.Column('reportedhostname', sa.String(length=255), nullable=True),
|
||||
sa.Column('createdat', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('operatorreportid'),
|
||||
sa.ForeignKeyConstraint(
|
||||
['assetid'], ['assets.assetid'],
|
||||
name='fk_operatorreports_asset', ondelete='CASCADE'),
|
||||
)
|
||||
op.create_index('idx_operatorreports_asset', 'operatorreports', ['assetid'])
|
||||
op.create_index('idx_operatorreports_createdat', 'operatorreports',
|
||||
['createdat'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
if not _has_table('operatorreports'):
|
||||
return
|
||||
|
||||
op.drop_index('idx_operatorreports_createdat', table_name='operatorreports')
|
||||
op.drop_index('idx_operatorreports_asset', table_name='operatorreports')
|
||||
op.drop_table('operatorreports')
|
||||
@@ -4,8 +4,9 @@ from .endpoint import (
|
||||
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
|
||||
)
|
||||
from .monitor import EndpointMonitor
|
||||
from .operatorreport import OperatorReport
|
||||
|
||||
__all__ = [
|
||||
'EndpointState', 'EndpointEvent', 'EndpointCommand', 'EndpointApplication',
|
||||
'EndpointMonitor',
|
||||
'EndpointMonitor', 'OperatorReport',
|
||||
]
|
||||
|
||||
82
plugins/endpoints/models/operatorreport.py
Normal file
82
plugins/endpoints/models/operatorreport.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Operator issue reports (screenshot + free-text notes from the tray).
|
||||
|
||||
An operator on a shop-floor PC opens the tray "Report an issue" dialog, writes a
|
||||
free-text description, and optionally attaches a screenshot of their screen. The
|
||||
agent uploads that to ShopDB, where it becomes one OperatorReport row linked to
|
||||
the reporting PC's asset. The screenshot bytes live on disk under the instance
|
||||
uploads dir (like employee photos); this row holds only metadata plus the
|
||||
relative file path so the image can be served back and purged on retention.
|
||||
|
||||
Reports are transient by design: a retention window (default 7 days) purges old
|
||||
rows and their image files, so this is a rolling operator-signal feed, not a
|
||||
permanent record.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
def _utciso(value):
|
||||
"""Naive-UTC datetime -> ISO string marked UTC, or None."""
|
||||
return value.isoformat() + 'Z' if value else None
|
||||
|
||||
|
||||
class OperatorReport(db.Model):
|
||||
"""One operator-submitted issue report for a PC asset."""
|
||||
|
||||
__tablename__ = 'operatorreports'
|
||||
|
||||
operatorreportid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# The PC this report is about. ON DELETE CASCADE: when the asset goes, its
|
||||
# operator reports go with it.
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# The dropdown reason. Only 'Other (free text)' is functional today, but the
|
||||
# column is kept so the richer reason routing can return without a migration.
|
||||
reason = db.Column(db.String(128), nullable=False, default='Other (free text)')
|
||||
|
||||
# The operator's free-text description. Nullable: a screenshot alone is a
|
||||
# valid report.
|
||||
details = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Screenshot metadata. imagefilename is the stored file name under the
|
||||
# operator-reports uploads dir; null when the operator declined a screenshot.
|
||||
imagefilename = db.Column(db.String(255), nullable=True)
|
||||
imagemimetype = db.Column(db.String(64), nullable=True)
|
||||
imagebytesize = db.Column(db.Integer, nullable=True)
|
||||
|
||||
# The hostname the agent reported at upload time, kept for traceability even
|
||||
# if the asset link is later re-pointed.
|
||||
reportedhostname = db.Column(db.String(255), nullable=True)
|
||||
|
||||
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_operatorreports_asset', 'assetid'),
|
||||
db.Index('idx_operatorreports_createdat', 'createdat'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<OperatorReport {self.operatorreportid} asset={self.assetid}>"
|
||||
|
||||
def has_image(self):
|
||||
return bool(self.imagefilename)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'operatorreportid': self.operatorreportid,
|
||||
'assetid': self.assetid,
|
||||
'reason': self.reason,
|
||||
'details': self.details,
|
||||
'hasimage': self.has_image(),
|
||||
'imagemimetype': self.imagemimetype,
|
||||
'imagebytesize': self.imagebytesize,
|
||||
'reportedhostname': self.reportedhostname,
|
||||
'createdat': _utciso(self.createdat),
|
||||
}
|
||||
@@ -486,6 +486,23 @@ class EndpointsPlugin(BasePlugin):
|
||||
# the installed-software inventory
|
||||
'group': 'software',
|
||||
},
|
||||
{
|
||||
# Operator-submitted issue reports: a free-text note and an
|
||||
# optional screenshot the operator captured from the tray. Sits
|
||||
# in the Monitors tab beside the other watch-and-observe panels.
|
||||
# A custom render carries the thumbnail grid and the expanding
|
||||
# lightbox with left/right cycling the generic modes cannot.
|
||||
'id': 'endpoints-operator-reports',
|
||||
'title': 'Operator Reports',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/panel/{assetid}/operatorreports',
|
||||
'render': 'operatorreports',
|
||||
'imagebase': '/api/endpoints/operatorreports',
|
||||
'collapsible': True,
|
||||
'empty': 'No operator reports for this endpoint.',
|
||||
'position': 18,
|
||||
'group': 'monitors',
|
||||
},
|
||||
]
|
||||
|
||||
def get_dashboard_widgets(self) -> List[Dict]:
|
||||
|
||||
Reference in New Issue
Block a user