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:
benjamin.baker
2026-09-09 12:44:38 -04:00
19 changed files with 1828 additions and 24 deletions

View File

@@ -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)