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))
|
||||
.filter(Notification.alertfleet.is_(True),
|
||||
Notification.isactive.is_(True),
|
||||
db.or_(
|
||||
Notification.starttime.is_(None),
|
||||
Notification.starttime <= now),
|
||||
db.or_(
|
||||
Notification.endtime.is_(None),
|
||||
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