feat: filter notifications to warnings/errors/ready only and add clear-all button

Notification filtering:
- lifecycle_hooks.py: only instance.error and instance.health_changed
  with status=running generate notifications. All other lifecycle events
  (created, started, stopped, restarted, deleted) are filtered out.
- health_monitor.py: only error and unhealthy states generate notifications.
  Running/recovered state no longer creates info notifications.
- _derive_title now maps instance.health_changed to "Container ready".

Clear-all button:
- Added dismiss_all() to NotificationService
- Added DELETE /notifications endpoint for bulk dismiss
- Frontend: clearAllNotifications API, clearAll in notification context,
  "Clear all" button in notification drawer alongside "Mark all as read"
- Added CSS for .notification-clear-all with danger hover state
- Updated notification-center tests

Quality gates: pytest (21 passed), vitest (11 passed)
This commit is contained in:
Alex Blank
2026-05-29 16:42:31 +02:00
parent 9c4500f9cb
commit 2b5223097f
11 changed files with 253 additions and 16 deletions
+7 -7
View File
@@ -220,18 +220,18 @@ class HealthMonitor:
await self._event_bus.publish(event_type, payload)
# Create notification for instance owner (fire-and-forget)
# Only send warnings and errors; skip "recovered" info notifications.
if new_status == "error":
category = "instance"
severity = "error"
title = "Container failed"
else:
elif new_status == "unhealthy":
category = "health"
if new_status == "unhealthy":
severity = "warning"
title = "Container unhealthy"
else:
severity = "info"
title = "Container recovered"
severity = "warning"
title = "Container unhealthy"
else:
# Running/recovered — do not notify
return
try:
await notification_service.create_notification(
+24 -7
View File
@@ -24,6 +24,7 @@ def _derive_title(event_type: str) -> str:
"instance.restarted": "Container restarted",
"instance.deleted": "Container deleted",
"instance.error": "Container error",
"instance.health_changed": "Container ready",
}
return mapping.get(
event_type,
@@ -31,6 +32,21 @@ def _derive_title(event_type: str) -> str:
)
def _should_notify(event_type: str, status: str | None) -> bool:
"""Determine whether a lifecycle event should generate a notification.
Only warnings, errors, and "container is ready" (health_changed running)
are sent to users.
"""
if event_type == "instance.error":
return True
if event_type == "instance.health_changed" and status == "running":
return True
# Filter out: created, started, stopped, restarted, deleted, and any
# health_changed that is not "running" (unhealthy is handled by health_monitor)
return False
def _build_payload(
event_type: str,
instance: ToolInstance,
@@ -118,15 +134,16 @@ async def publish_lifecycle_event(
await event_bus.publish(event_type, payload)
# Create notification for instance owner (fire-and-forget)
# Skip intermediate "starting" notifications — only notify on terminal states
# (failed or successful attempts)
_is_starting_intermediate = event_type == "instance.started" and (
status or instance.status
) == "starting"
if _is_starting_intermediate:
# Only send warnings, errors, and "container is ready" notifications.
effective_status = status or instance.status
if not _should_notify(event_type, effective_status):
return
severity = "error" if event_type == "instance.error" else "info"
severity = (
"error"
if event_type == "instance.error"
else "success"
)
title = _derive_title(event_type)
try:
@@ -195,6 +195,32 @@ class NotificationService:
await session.commit()
return result.rowcount or 0
async def dismiss_all(
self,
session: AsyncSession,
user_id: uuid.UUID,
) -> int:
"""Soft-delete all non-dismissed notifications for a user.
Args:
session: Database session.
user_id: Owner of the notifications.
Returns:
Number of rows updated.
"""
stmt = (
update(Notification)
.where(
Notification.user_id == user_id,
Notification.dismissed_at.is_(None),
)
.values(dismissed_at=datetime.now(timezone.utc))
)
result: CursorResult[Any] = await session.execute(stmt) # type: ignore[assignment]
await session.commit()
return result.rowcount or 0
async def dismiss(
self,
session: AsyncSession,