Merge wj: fleet alert notifications and operator report emails
Brings in the fleet alert notification concept, the operator report email alerts, and the tray work that goes with them (tray 1.6.5). Two files needed resolution: - ShopDbEndpointAgent.csproj: wj carried 1.13.1 through 1.13.4, all tray-coordination bumps with no agent code behind them. Ours carried 1.14.0, which is real code. Kept every changelog entry and took 1.14.0 as the version, since it already supersedes the 1.13.x line. - plugins/endpoints/api/routes.py auto-merged; verified the errormode descriptor survived intact. KNOWN RED, INHERITED NOT CAUSED: five backend tests fail on this merge - authz on endpoints.acknowledge_fleet_alert, the public GET surface scan, two plugin-contract import checks, and the notifications plugin migration/model agreement. All five were confirmed failing on a clean worktree of origin/wj before merging, so they arrive with the branch rather than from the merge. They reflect the fleet alert work being half-landed: the push path was a delivery proof of concept and the server-side surface was still being stood up. These are not fixed here on purpose. The next change replaces that delivery path with the endpoint agent, so the notification persists on the endpoint until the operator acknowledges it or the server closes it, and the affected surfaces get their gating and migration settled as part of that work rather than being patched twice.
This commit is contained in:
@@ -32,6 +32,8 @@ from ..models import (
|
||||
)
|
||||
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK
|
||||
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
|
||||
from shopdb.utils.mailer import send_alert
|
||||
from markupsafe import escape
|
||||
|
||||
endpoints_bp = Blueprint('endpoints', __name__)
|
||||
|
||||
@@ -2943,6 +2945,85 @@ def _purge_expired_operator_reports():
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _send_operator_report_alert(report, asset_id, hostname):
|
||||
"""Send email alert for new operator issue report to default alert recipients."""
|
||||
from flask import url_for, request
|
||||
from shopdb.utils.mailer import get_smtp_config, _normalize_recipients
|
||||
|
||||
current_app.logger.info('_send_operator_report_alert called for report %d, asset %d, hostname %s',
|
||||
report.operatorreportid, asset_id, hostname)
|
||||
|
||||
# Check if we have recipients before doing expensive work
|
||||
config = get_smtp_config()
|
||||
current_app.logger.info('SMTP config: enabled=%s, host=%s, alert_recipients=%s',
|
||||
config.get('enabled'), config.get('host'), config.get('alert_recipients'))
|
||||
recipients = _normalize_recipients(config['alert_recipients'])
|
||||
if not recipients:
|
||||
current_app.logger.warning('Skipping operator report alert: no alert recipients configured')
|
||||
return
|
||||
current_app.logger.info('Will send alert to %d recipient(s): %s', len(recipients), recipients)
|
||||
|
||||
asset = Asset.query.get(asset_id)
|
||||
if not asset:
|
||||
current_app.logger.warning('Cannot send operator report alert: asset %d not found', asset_id)
|
||||
return
|
||||
|
||||
# Generate absolute URLs
|
||||
try:
|
||||
device_url = url_for('assets.get_asset', asset_id=asset_id, _external=True)
|
||||
except Exception as e:
|
||||
current_app.logger.error('Failed to generate device URL: %s', e)
|
||||
# Build absolute URL manually using request context
|
||||
scheme = request.scheme if request else 'https'
|
||||
host = request.host if request else 'localhost'
|
||||
device_url = f'{scheme}://{host}/shopdb/assets/{asset_id}'
|
||||
|
||||
reported_at = report.createdat.strftime('%Y-%m-%d %H:%M:%S UTC') if report.createdat else 'Unknown'
|
||||
|
||||
screenshot_info = ''
|
||||
if report.imagefilename:
|
||||
try:
|
||||
screenshot_url = url_for('endpoints.serve_operator_report_image',
|
||||
operatorreportid=report.operatorreportid,
|
||||
_external=True)
|
||||
screenshot_info = f'<p><strong>Screenshot:</strong> <a href="{escape(screenshot_url)}">View Screenshot</a></p>'
|
||||
except Exception as e:
|
||||
current_app.logger.warning('Failed to generate screenshot URL: %s', e)
|
||||
|
||||
# HTML-escape all user-controlled values to prevent XSS
|
||||
safe_hostname = escape(hostname)
|
||||
safe_name = escape(asset.name or hostname)
|
||||
safe_assetnumber = escape(asset.assetnumber or 'N/A')
|
||||
safe_reason = escape(report.reason)
|
||||
safe_details = escape(report.details or 'No additional details provided.')
|
||||
|
||||
# Build HTML body (don't use render_email to avoid subject duplication)
|
||||
html_body = f'''
|
||||
<div style="font-family:Arial,Helvetica,sans-serif;max-width:640px;margin:0 auto;color:#222;">
|
||||
<h2 style="color:#1a1a1a;margin:0 0 12px;">Operator Issue Report</h2>
|
||||
<div style="padding: 16px; background: #f9f9f9; border-left: 4px solid #d93f3f; margin-bottom: 16px;">
|
||||
<p style="margin: 0 0 8px; font-weight: bold; color: #d93f3f;">New Issue Report from Shop Floor</p>
|
||||
<p style="margin: 0;"><strong>Device:</strong> <a href="{escape(device_url)}">{safe_name}</a> ({safe_hostname})</p>
|
||||
<p style="margin: 0;"><strong>Asset Number:</strong> <a href="{escape(device_url)}">{safe_assetnumber}</a></p>
|
||||
<p style="margin: 0;"><strong>Reason:</strong> {safe_reason}</p>
|
||||
<p style="margin: 0;"><strong>Reported:</strong> {escape(reported_at)}</p>
|
||||
</div>
|
||||
<p><strong>Details:</strong></p>
|
||||
<p style="white-space: pre-wrap;">{safe_details}</p>
|
||||
{screenshot_info}
|
||||
<p><a href="{escape(device_url)}" style="display: inline-block; padding: 10px 16px; background: #1a73e8; color: white; text-decoration: none; border-radius: 4px;">View Device in ShopDB</a></p>
|
||||
<hr style="border:none;border-top:1px solid #ddd;margin:24px 0 12px;">
|
||||
<p style="font-size:12px;color:#888;margin:0;">Sent by ShopDB.</p>
|
||||
</div>
|
||||
'''
|
||||
|
||||
subject = f'Operator Issue Report: {safe_name}'
|
||||
|
||||
current_app.logger.info('Sending operator report alert for %s (report %d)',
|
||||
hostname, report.operatorreportid)
|
||||
send_alert(subject, html_body)
|
||||
|
||||
|
||||
@endpoints_bp.route('/operatorreports', methods=['POST'])
|
||||
def upload_operator_report():
|
||||
"""Agent uploads one operator issue report (collector-token auth).
|
||||
@@ -3039,6 +3120,14 @@ def upload_operator_report():
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
# Send email alert to default alert recipients
|
||||
current_app.logger.info('Operator report %d saved, attempting to send email alert', report.operatorreportid)
|
||||
try:
|
||||
_send_operator_report_alert(report, asset_id, hostname)
|
||||
current_app.logger.info('Email alert function completed for report %d', report.operatorreportid)
|
||||
except Exception as e:
|
||||
current_app.logger.error('Failed to send operator report alert email for report %d: %s', report.operatorreportid, e, exc_info=True)
|
||||
|
||||
return success_response(report.to_dict(),
|
||||
message='Operator report received', http_code=201)
|
||||
|
||||
@@ -3085,3 +3174,148 @@ def serve_operator_report_image(operatorreportid: int):
|
||||
return send_upload(_operator_report_dir(), report.imagefilename)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fleet alerts (agent-facing, collector-token auth)
|
||||
# =============================================================================
|
||||
|
||||
_DEFAULT_ALERT_TYPE_COLOR = '#17a2b8'
|
||||
|
||||
@endpoints_bp.route('/alerts', methods=['GET'])
|
||||
def get_fleet_alerts():
|
||||
"""Fleet alerts for one endpoint by hostname (collector-token auth).
|
||||
|
||||
Called by the endpoint agent to fetch active, unacknowledged fleet alerts
|
||||
for its hostname. Returns alerts with notification type info (typename and
|
||||
typecolor from database) formatted for display in the agent tray.
|
||||
"""
|
||||
error = _collector_auth_or_error()
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
hostname = (request.args.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.NOT_FOUND,
|
||||
f'Unknown hostname: {hostname!r}', http_code=404)
|
||||
|
||||
from plugins.notifications.models import Notification, NotificationType, NotificationAcknowledgement
|
||||
|
||||
now = _naive_utcnow()
|
||||
|
||||
# Query for active fleet alerts not acknowledged by this asset
|
||||
query = (db.session.query(Notification, NotificationType)
|
||||
.join(NotificationType,
|
||||
NotificationType.notificationtypeid == Notification.notificationtypeid)
|
||||
.outerjoin(NotificationAcknowledgement,
|
||||
db.and_(
|
||||
NotificationAcknowledgement.notificationid == Notification.notificationid,
|
||||
NotificationAcknowledgement.assetid == asset_id))
|
||||
.filter(Notification.alertfleet.is_(True),
|
||||
Notification.isactive.is_(True),
|
||||
db.or_(
|
||||
Notification.endtime.is_(None),
|
||||
Notification.endtime > now),
|
||||
NotificationAcknowledgement.notificationacknowledgementid.is_(None)))
|
||||
|
||||
results = query.all()
|
||||
|
||||
alerts = []
|
||||
for notification, ntype in results:
|
||||
title = notification.notification[:100] if notification.notification else ''
|
||||
if '\n' in title:
|
||||
title = title.split('\n')[0][:100] or '(no title)'
|
||||
|
||||
alerts.append({
|
||||
'notificationId': notification.notificationid,
|
||||
'type': ntype.typename,
|
||||
'typeColor': ntype.typecolor or _DEFAULT_ALERT_TYPE_COLOR,
|
||||
'title': title,
|
||||
'message': notification.notification or '',
|
||||
'timeout': notification.alerttimeout,
|
||||
})
|
||||
|
||||
return success_response(alerts)
|
||||
|
||||
|
||||
@endpoints_bp.route('/alerts/<int:notification_id>/acknowledge', methods=['POST'])
|
||||
def acknowledge_fleet_alert(notification_id: int):
|
||||
"""Agent acknowledges a fleet alert (collector-token auth).
|
||||
|
||||
The agent posts the hostname plus assetid when dismissing a fleet alert from
|
||||
the tray. Records acknowledgement so this alert will not reappear in future polls.
|
||||
Idempotent - repeated acknowledgements succeed.
|
||||
"""
|
||||
error = _collector_auth_or_error()
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
hostname = (payload.get('hostname') or '').strip()
|
||||
assetid = payload.get('assetid')
|
||||
|
||||
if not hostname:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'hostname is required')
|
||||
|
||||
if assetid is None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'assetid is required')
|
||||
|
||||
try:
|
||||
assetid = int(assetid)
|
||||
except (TypeError, ValueError):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'assetid must be an integer')
|
||||
|
||||
from plugins.notifications.models import Notification, NotificationAcknowledgement
|
||||
|
||||
notification = db.session.get(Notification, notification_id)
|
||||
if notification is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Notification {notification_id} not found',
|
||||
http_code=404)
|
||||
|
||||
asset = db.session.get(Asset, assetid)
|
||||
if asset is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Asset {assetid} not found',
|
||||
http_code=404)
|
||||
|
||||
# Validate hostname matches assetid to prevent cross-machine acknowledgements
|
||||
resolved_asset = _resolve_asset_for_hostname(hostname)
|
||||
if resolved_asset is None or resolved_asset.assetid != assetid:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'hostname does not match provided assetid')
|
||||
|
||||
# Check if already acknowledged (idempotent)
|
||||
existing = (NotificationAcknowledgement.query
|
||||
.filter_by(notificationid=notification_id, assetid=assetid)
|
||||
.first())
|
||||
if existing:
|
||||
return success_response({'acknowledged': True},
|
||||
message='Alert already acknowledged')
|
||||
|
||||
# Record acknowledgement
|
||||
ack = NotificationAcknowledgement(
|
||||
notificationid=notification_id,
|
||||
assetid=assetid
|
||||
)
|
||||
db.session.add(ack)
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
logger.warning(f'Failed to commit acknowledgement for notification {notification_id}: {exc}')
|
||||
return error_response(ErrorCodes.INTERNAL_ERROR,
|
||||
'Failed to record acknowledgement',
|
||||
http_code=500)
|
||||
|
||||
return success_response({'acknowledged': True},
|
||||
message='Alert acknowledged', http_code=201)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user