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:
Proudlock
2026-09-08 16:08:39 -04:00
parent 86256e7041
commit 381cc6c845
14 changed files with 983 additions and 8 deletions

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
"""Notifications plugin models."""
from .notification import Notification, NotificationType
from .acknowledgement import NotificationAcknowledgement
__all__ = ['Notification', 'NotificationType']
__all__ = ['Notification', 'NotificationType', 'NotificationAcknowledgement']

View 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
}

View File

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