Fix fleet alerts sent immediately instead of respecting scheduled starttime
Bug Fix: Fleet alert scheduling regression
Problem:
- User sets notification to run at 3:30 PM (future time)
- Fleet alert sent to agents immediately at 3:10 PM instead of waiting
- Agents see notification 20 minutes early
Root Cause:
- GET /api/endpoints/alerts query filtered for:
- alertfleet = True
- isactive = True
- endtime is NULL OR endtime > now (not expired)
- Not acknowledged
- MISSING CHECK: starttime is NULL OR starttime <= now (has started)
- Result: Any notification with future starttime appeared immediately
Solution:
- Added starttime filter to fleet alert query (routes.py:4151-4153)
- Query now checks:
db.or_(
Notification.starttime.is_(None),
Notification.starttime <= now
)
- Notifications with future starttime excluded until that time
Behavior After Fix:
- Notification with no starttime: appears immediately (existing behavior)
- Notification with past starttime: appears immediately (existing behavior)
- Notification with future starttime: waits until that time (FIXED)
- Notification with past endtime: never appears (existing behavior)
Testing:
- tests/test_plugins/test_endpoints_fleet_alert_scheduling.py
- test_fleet_alert_respects_future_starttime (regression test)
- test_fleet_alert_with_no_starttime_appears_immediately
- test_fleet_alert_past_starttime_appears
- test_fleet_alert_expired_does_not_appear
Timeline Example:
- 3:10 PM: User creates notification with starttime=3:30 PM, alertfleet=True
- 3:15 PM: Agent polls /api/endpoints/alerts -> empty (starttime not reached)
- 3:35 PM: Agent polls again -> alert appears (starttime has passed)
Impact:
- Agents poll every 60 seconds, so scheduled alerts appear within 1 minute of starttime
- No database migration needed (starttime column already exists)
- Backwards compatible (NULL starttime still means immediate)
Related:
- Notification.is_current property already checks starttime (models/notification.py:162-169)
- Shopfloor board respects starttime correctly
- Only fleet alert endpoint had this regression
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4148,6 +4148,9 @@ def get_fleet_alerts():
|
|||||||
NotificationAcknowledgement.assetid == asset_id))
|
NotificationAcknowledgement.assetid == asset_id))
|
||||||
.filter(Notification.alertfleet.is_(True),
|
.filter(Notification.alertfleet.is_(True),
|
||||||
Notification.isactive.is_(True),
|
Notification.isactive.is_(True),
|
||||||
|
db.or_(
|
||||||
|
Notification.starttime.is_(None),
|
||||||
|
Notification.starttime <= now),
|
||||||
db.or_(
|
db.or_(
|
||||||
Notification.endtime.is_(None),
|
Notification.endtime.is_(None),
|
||||||
Notification.endtime > now),
|
Notification.endtime > now),
|
||||||
|
|||||||
254
tests/test_plugins/test_endpoints_fleet_alert_scheduling.py
Normal file
254
tests/test_plugins/test_endpoints_fleet_alert_scheduling.py
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
"""Test fleet alert scheduling respects starttime.
|
||||||
|
|
||||||
|
Regression test for bug where fleet alerts with future starttime were sent
|
||||||
|
immediately instead of waiting for the scheduled time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def test_fleet_alert_respects_future_starttime(client, auth_headers, sample_asset):
|
||||||
|
"""Fleet alerts with future starttime should not appear until that time."""
|
||||||
|
from plugins.notifications.models import Notification, NotificationType
|
||||||
|
from plugins.endpoints.models import EndpointState
|
||||||
|
from shopdb.api import db
|
||||||
|
|
||||||
|
# Create notification type
|
||||||
|
ntype = NotificationType(
|
||||||
|
typename='System Maintenance',
|
||||||
|
typecolor='#ffc107',
|
||||||
|
isactive=True
|
||||||
|
)
|
||||||
|
db.session.add(ntype)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
# Create endpoint state for the asset
|
||||||
|
state = EndpointState(
|
||||||
|
assetid=sample_asset.assetid,
|
||||||
|
hostname='TEST-PC-01',
|
||||||
|
agentversion='1.0.0',
|
||||||
|
lastreportedat=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.session.add(state)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
future_time = now + timedelta(minutes=20) # 20 minutes from now
|
||||||
|
end_time = future_time + timedelta(hours=1)
|
||||||
|
|
||||||
|
# Create fleet alert scheduled for 20 minutes in the future
|
||||||
|
notification = Notification(
|
||||||
|
notification='Scheduled maintenance at 3:30 PM',
|
||||||
|
notificationtypeid=ntype.notificationtypeid,
|
||||||
|
starttime=future_time,
|
||||||
|
endtime=end_time,
|
||||||
|
isactive=True,
|
||||||
|
alertfleet=True,
|
||||||
|
alertscope=None, # All endpoints
|
||||||
|
alerttimeout=0
|
||||||
|
)
|
||||||
|
db.session.add(notification)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Agent polls for fleet alerts NOW (before starttime)
|
||||||
|
response = client.get(
|
||||||
|
'/api/endpoints/alerts?hostname=TEST-PC-01',
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
|
||||||
|
# BUG FIX: Alert should NOT appear because starttime is in the future
|
||||||
|
alerts = data['data']
|
||||||
|
assert len(alerts) == 0, 'Fleet alert with future starttime should not appear yet'
|
||||||
|
|
||||||
|
# Simulate time passing - set starttime to the past
|
||||||
|
notification.starttime = now - timedelta(minutes=5)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Agent polls again (now starttime has passed)
|
||||||
|
response = client.get(
|
||||||
|
'/api/endpoints/alerts?hostname=TEST-PC-01',
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
|
||||||
|
# Now the alert SHOULD appear
|
||||||
|
alerts = data['data']
|
||||||
|
assert len(alerts) == 1
|
||||||
|
assert alerts[0]['notificationId'] == notification.notificationid
|
||||||
|
assert alerts[0]['title'] == 'Scheduled maintenance at 3:30 PM'
|
||||||
|
|
||||||
|
|
||||||
|
def test_fleet_alert_with_no_starttime_appears_immediately(client, auth_headers, sample_asset):
|
||||||
|
"""Fleet alerts with no starttime should appear immediately."""
|
||||||
|
from plugins.notifications.models import Notification, NotificationType
|
||||||
|
from plugins.endpoints.models import EndpointState
|
||||||
|
from shopdb.api import db
|
||||||
|
|
||||||
|
# Create notification type
|
||||||
|
ntype = NotificationType(
|
||||||
|
typename='System Alert',
|
||||||
|
typecolor='#dc3545',
|
||||||
|
isactive=True
|
||||||
|
)
|
||||||
|
db.session.add(ntype)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
# Create endpoint state
|
||||||
|
state = EndpointState(
|
||||||
|
assetid=sample_asset.assetid,
|
||||||
|
hostname='TEST-PC-02',
|
||||||
|
agentversion='1.0.0',
|
||||||
|
lastreportedat=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.session.add(state)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
# Create fleet alert with no starttime (immediate)
|
||||||
|
notification = Notification(
|
||||||
|
notification='Immediate alert - urgent',
|
||||||
|
notificationtypeid=ntype.notificationtypeid,
|
||||||
|
starttime=None, # No starttime = immediate
|
||||||
|
endtime=None,
|
||||||
|
isactive=True,
|
||||||
|
alertfleet=True,
|
||||||
|
alertscope=None,
|
||||||
|
alerttimeout=0
|
||||||
|
)
|
||||||
|
db.session.add(notification)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Agent polls for fleet alerts
|
||||||
|
response = client.get(
|
||||||
|
'/api/endpoints/alerts?hostname=TEST-PC-02',
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
|
||||||
|
# Alert should appear immediately
|
||||||
|
alerts = data['data']
|
||||||
|
assert len(alerts) == 1
|
||||||
|
assert alerts[0]['notificationId'] == notification.notificationid
|
||||||
|
assert alerts[0]['title'] == 'Immediate alert - urgent'
|
||||||
|
|
||||||
|
|
||||||
|
def test_fleet_alert_past_starttime_appears(client, auth_headers, sample_asset):
|
||||||
|
"""Fleet alerts with past starttime should appear."""
|
||||||
|
from plugins.notifications.models import Notification, NotificationType
|
||||||
|
from plugins.endpoints.models import EndpointState
|
||||||
|
from shopdb.api import db
|
||||||
|
|
||||||
|
ntype = NotificationType(
|
||||||
|
typename='Update',
|
||||||
|
typecolor='#17a2b8',
|
||||||
|
isactive=True
|
||||||
|
)
|
||||||
|
db.session.add(ntype)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
state = EndpointState(
|
||||||
|
assetid=sample_asset.assetid,
|
||||||
|
hostname='TEST-PC-03',
|
||||||
|
agentversion='1.0.0',
|
||||||
|
lastreportedat=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.session.add(state)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
past_time = now - timedelta(hours=1)
|
||||||
|
|
||||||
|
# Create fleet alert that started 1 hour ago
|
||||||
|
notification = Notification(
|
||||||
|
notification='Alert started 1 hour ago',
|
||||||
|
notificationtypeid=ntype.notificationtypeid,
|
||||||
|
starttime=past_time,
|
||||||
|
endtime=None,
|
||||||
|
isactive=True,
|
||||||
|
alertfleet=True,
|
||||||
|
alertscope=None,
|
||||||
|
alerttimeout=0
|
||||||
|
)
|
||||||
|
db.session.add(notification)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Agent polls for fleet alerts
|
||||||
|
response = client.get(
|
||||||
|
'/api/endpoints/alerts?hostname=TEST-PC-03',
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
|
||||||
|
# Alert should appear (starttime is in the past)
|
||||||
|
alerts = data['data']
|
||||||
|
assert len(alerts) == 1
|
||||||
|
assert alerts[0]['notificationId'] == notification.notificationid
|
||||||
|
|
||||||
|
|
||||||
|
def test_fleet_alert_expired_does_not_appear(client, auth_headers, sample_asset):
|
||||||
|
"""Fleet alerts with past endtime should not appear."""
|
||||||
|
from plugins.notifications.models import Notification, NotificationType
|
||||||
|
from plugins.endpoints.models import EndpointState
|
||||||
|
from shopdb.api import db
|
||||||
|
|
||||||
|
ntype = NotificationType(
|
||||||
|
typename='Expired',
|
||||||
|
typecolor='#6c757d',
|
||||||
|
isactive=True
|
||||||
|
)
|
||||||
|
db.session.add(ntype)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
state = EndpointState(
|
||||||
|
assetid=sample_asset.assetid,
|
||||||
|
hostname='TEST-PC-04',
|
||||||
|
agentversion='1.0.0',
|
||||||
|
lastreportedat=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.session.add(state)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
past_start = now - timedelta(hours=2)
|
||||||
|
past_end = now - timedelta(hours=1)
|
||||||
|
|
||||||
|
# Create expired fleet alert
|
||||||
|
notification = Notification(
|
||||||
|
notification='This alert already ended',
|
||||||
|
notificationtypeid=ntype.notificationtypeid,
|
||||||
|
starttime=past_start,
|
||||||
|
endtime=past_end, # Ended 1 hour ago
|
||||||
|
isactive=True,
|
||||||
|
alertfleet=True,
|
||||||
|
alertscope=None,
|
||||||
|
alerttimeout=0
|
||||||
|
)
|
||||||
|
db.session.add(notification)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Agent polls for fleet alerts
|
||||||
|
response = client.get(
|
||||||
|
'/api/endpoints/alerts?hostname=TEST-PC-04',
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
|
||||||
|
# Alert should NOT appear (endtime is in the past)
|
||||||
|
alerts = data['data']
|
||||||
|
assert len(alerts) == 0
|
||||||
Reference in New Issue
Block a user