Add fleet alert notifications for shop floor PCs
Fleet alerts push server notifications to endpoint PCs via the command pipe. Operators acknowledge alerts via toast UI in the tray. Backend tracks acknowledgements per asset to prevent re-showing dismissed alerts. Backend: alertfleet/alertscope/alerttimeout columns, GET/POST /api/endpoints/alerts, NotificationAcknowledgement model with unique constraint, hostname validation. Frontend: Alert Fleet checkbox in notification form. Agent: FleetAlertClient polls alerts, FleetAlertStore tracks acknowledged IDs, FleetAlertForm displays toast (no auto-dismiss), FleetAlertWorker 60s polling. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3077,3 +3077,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)
|
||||
|
||||
|
||||
|
||||
@@ -455,7 +455,10 @@ def create_notification():
|
||||
isactive=True,
|
||||
isshopfloor=data.get('isshopfloor', False),
|
||||
employeesso=data.get('employeesso'),
|
||||
employeename=data.get('employeename')
|
||||
employeename=data.get('employeename'),
|
||||
alertfleet=data.get('alertfleet', False),
|
||||
alertscope=data.get('alertscope'),
|
||||
alerttimeout=data.get('alerttimeout', 0)
|
||||
)
|
||||
|
||||
db.session.add(n)
|
||||
@@ -504,6 +507,12 @@ def update_notification(notification_id: int):
|
||||
n.employeesso = data['employeesso']
|
||||
if 'employeename' in data:
|
||||
n.employeename = data['employeename']
|
||||
if 'alertfleet' in data:
|
||||
n.alertfleet = data['alertfleet']
|
||||
if 'alertscope' in data:
|
||||
n.alertscope = data['alertscope']
|
||||
if 'alerttimeout' in data:
|
||||
n.alerttimeout = data['alerttimeout']
|
||||
|
||||
# Parse and update dates
|
||||
if 'starttime' in data or 'startdate' in data:
|
||||
|
||||
@@ -217,6 +217,18 @@
|
||||
</label>
|
||||
<small class="form-hint">Display on TV dashboard</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.alertfleet"
|
||||
:disabled="isDetail || !form.isactive"
|
||||
/>
|
||||
Alert Fleet
|
||||
</label>
|
||||
<small class="form-hint">Push this notification to shop floor PCs</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
@@ -282,7 +294,10 @@ const form = ref({
|
||||
// loads its own stored value (see loadNotification), so this never flips a
|
||||
// setting somebody deliberately turned off.
|
||||
isshopfloor: true,
|
||||
employeesso: ''
|
||||
employeesso: '',
|
||||
alertfleet: false,
|
||||
alertscope: null,
|
||||
alerttimeout: 0
|
||||
})
|
||||
|
||||
// Site timezone (settings key site_timezone) so form times show/enter in the
|
||||
@@ -349,7 +364,10 @@ onMounted(async () => {
|
||||
endtime: formatDateForInput(n.endtime),
|
||||
isactive: n.isactive !== false,
|
||||
isshopfloor: n.isshopfloor || false,
|
||||
employeesso: n.employeesso || ''
|
||||
employeesso: n.employeesso || '',
|
||||
alertfleet: n.alertfleet || false,
|
||||
alertscope: n.alertscope || null,
|
||||
alerttimeout: n.alerttimeout !== undefined ? n.alerttimeout : 0
|
||||
}
|
||||
|
||||
// Parse existing employee data: SSOs are comma-joined and names are
|
||||
@@ -527,7 +545,10 @@ async function saveNotification() {
|
||||
endtime: utcFromZonedInput(form.value.endtime, siteTimezone.value),
|
||||
isactive: form.value.isactive,
|
||||
isshopfloor: form.value.isshopfloor,
|
||||
employeesso: form.value.employeesso || null
|
||||
employeesso: form.value.employeesso || null,
|
||||
alertfleet: form.value.alertfleet || false,
|
||||
alertscope: form.value.alertscope || null,
|
||||
alerttimeout: form.value.alerttimeout !== undefined ? form.value.alerttimeout : 0
|
||||
}
|
||||
|
||||
// For employee-photo types, also send employeename
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Add fleet alert support to notifications plugin.
|
||||
|
||||
Adds alertfleet, alertscope, and alerttimeout columns to the notifications table
|
||||
to support fleet-wide alert broadcasting. Creates notificationacknowledgements
|
||||
table to track which assets have acknowledged fleet alerts.
|
||||
|
||||
Fleet alerts can be scoped to specific asset types, locations, or business units
|
||||
via the alertscope JSONB field. alerttimeout=0 means no auto-dismiss (manual only).
|
||||
|
||||
Idempotent: column and table creation are skipped when they already exist.
|
||||
|
||||
Revision ID: notifications0006fleetalerts
|
||||
Revises: notifications0005boardorder
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
revision = 'notifications0006fleetalerts'
|
||||
down_revision = 'notifications0005boardorder'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_names(insp, table):
|
||||
return {c['name'] for c in insp.get_columns(table)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
existing_tables = set(insp.get_table_names())
|
||||
|
||||
# Add alertfleet, alertscope, alerttimeout to notifications table
|
||||
if 'notifications' in existing_tables:
|
||||
cols = _column_names(insp, 'notifications')
|
||||
|
||||
if 'alertfleet' not in cols:
|
||||
op.add_column('notifications',
|
||||
sa.Column('alertfleet', sa.Boolean(), nullable=False,
|
||||
server_default=sa.false()))
|
||||
|
||||
if 'alertscope' not in cols:
|
||||
op.add_column('notifications',
|
||||
sa.Column('alertscope', JSONB, nullable=True))
|
||||
|
||||
if 'alerttimeout' not in cols:
|
||||
op.add_column('notifications',
|
||||
sa.Column('alerttimeout', sa.Integer(), nullable=False,
|
||||
server_default='0'))
|
||||
|
||||
# Create notificationacknowledgements table
|
||||
if 'notificationacknowledgements' not in existing_tables:
|
||||
op.create_table(
|
||||
'notificationacknowledgements',
|
||||
sa.Column('notificationacknowledgementid', sa.Integer(), nullable=False),
|
||||
sa.Column('notificationid', sa.Integer(), nullable=False),
|
||||
sa.Column('assetid', sa.Integer(), nullable=False),
|
||||
sa.Column('acknowledgedat', sa.DateTime(), nullable=False,
|
||||
server_default=sa.text('NOW()')),
|
||||
sa.PrimaryKeyConstraint('notificationacknowledgementid'),
|
||||
sa.ForeignKeyConstraint(['notificationid'],
|
||||
['notifications.notificationid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.UniqueConstraint('notificationid', 'assetid',
|
||||
name='uq_notification_asset')
|
||||
)
|
||||
op.create_index('idx_notificationack_notification',
|
||||
'notificationacknowledgements',
|
||||
['notificationid'])
|
||||
op.create_index('idx_notificationack_asset',
|
||||
'notificationacknowledgements',
|
||||
['assetid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
existing_tables = set(insp.get_table_names())
|
||||
|
||||
# Drop notificationacknowledgements table
|
||||
if 'notificationacknowledgements' in existing_tables:
|
||||
op.drop_index('idx_notificationack_asset',
|
||||
table_name='notificationacknowledgements')
|
||||
op.drop_index('idx_notificationack_notification',
|
||||
table_name='notificationacknowledgements')
|
||||
op.drop_table('notificationacknowledgements')
|
||||
|
||||
# Drop alert columns from notifications table
|
||||
if 'notifications' in existing_tables:
|
||||
cols = _column_names(insp, 'notifications')
|
||||
|
||||
if 'alerttimeout' in cols:
|
||||
op.drop_column('notifications', 'alerttimeout')
|
||||
|
||||
if 'alertscope' in cols:
|
||||
op.drop_column('notifications', 'alertscope')
|
||||
|
||||
if 'alertfleet' in cols:
|
||||
op.drop_column('notifications', 'alertfleet')
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Notifications plugin models."""
|
||||
|
||||
from .notification import Notification, NotificationType
|
||||
from .acknowledgement import NotificationAcknowledgement
|
||||
|
||||
__all__ = ['Notification', 'NotificationType']
|
||||
__all__ = ['Notification', 'NotificationType', 'NotificationAcknowledgement']
|
||||
|
||||
52
plugins/notifications/models/acknowledgement.py
Normal file
52
plugins/notifications/models/acknowledgement.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Notification acknowledgement tracking for fleet alerts."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
class NotificationAcknowledgement(db.Model):
|
||||
"""
|
||||
Tracks which assets have acknowledged fleet alerts.
|
||||
Matches existing notificationacknowledgements table.
|
||||
"""
|
||||
__tablename__ = 'notificationacknowledgements'
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('notificationid', 'assetid', name='uq_notification_asset'),
|
||||
)
|
||||
|
||||
notificationacknowledgementid = db.Column(db.Integer, primary_key=True)
|
||||
notificationid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('notifications.notificationid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
acknowledgedat = db.Column(
|
||||
db.DateTime,
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
)
|
||||
|
||||
# Relationships
|
||||
notification = db.relationship('Notification', backref='acknowledgements')
|
||||
asset = db.relationship('Asset', backref='notification_acknowledgements')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NotificationAcknowledgement {self.notificationacknowledgementid}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary following ShopDB patterns."""
|
||||
return {
|
||||
'notificationacknowledgementid': self.notificationacknowledgementid,
|
||||
'notificationid': self.notificationid,
|
||||
'assetid': self.assetid,
|
||||
'acknowledgedat': self.acknowledgedat.replace(
|
||||
tzinfo=timezone.utc
|
||||
).isoformat() if self.acknowledgedat else None
|
||||
}
|
||||
@@ -146,6 +146,9 @@ class Notification(db.Model):
|
||||
# ~11 people are listed.
|
||||
employeesso = db.Column(db.Text, nullable=True)
|
||||
employeename = db.Column(db.Text, nullable=True)
|
||||
alertfleet = db.Column(db.Boolean, default=False)
|
||||
alertscope = db.Column(db.JSON, nullable=True)
|
||||
alerttimeout = db.Column(db.Integer, nullable=True)
|
||||
|
||||
# Relationships
|
||||
notificationtype = db.relationship('NotificationType', backref='notifications')
|
||||
@@ -194,7 +197,10 @@ class Notification(db.Model):
|
||||
'isshopfloor': bool(self.isshopfloor) if self.isshopfloor is not None else False,
|
||||
'employeesso': self.employeesso,
|
||||
'employeename': self.employeename,
|
||||
'iscurrent': self.is_current
|
||||
'iscurrent': self.is_current,
|
||||
'alertfleet': bool(self.alertfleet) if self.alertfleet is not None else False,
|
||||
'alertscope': self.alertscope,
|
||||
'alerttimeout': self.alerttimeout
|
||||
}
|
||||
|
||||
# Add type info
|
||||
|
||||
Reference in New Issue
Block a user