"""Notification system supporting progress bars and rich text alerts. External scripts push notifications via POST /api/v1/notify. Two types are supported: - "progress": long-running tasks with a measurable progress bar - "alert": one-off informational/warning/error messages """ from __future__ import annotations import logging import uuid from dataclasses import dataclass, field from typing import Literal logger = logging.getLogger("paperdash.notifications") # --------------------------------------------------------------------------- # Data Models # --------------------------------------------------------------------------- @dataclass class ProgressState: """Progress tracking for long-running tasks.""" current: float = 0.0 min: float = 0.0 max: float = 100.0 unit: str = "" @property def percentage(self) -> float: if self.max <= self.min: return 0.0 return max( 0.0, min(100.0, (self.current - self.min) / (self.max - self.min) * 100) ) @dataclass class Notification: """A notification to display on the dashboard.""" id: str = field(default_factory=lambda: str(uuid.uuid4())) type: Literal["progress", "alert"] = "alert" title: str = "" message: str = "" progress: ProgressState | None = None eta: str | None = None priority: Literal["info", "warning", "error"] = "info" # --------------------------------------------------------------------------- # Notification Manager # --------------------------------------------------------------------------- class NotificationManager: """Manages active notifications and their lifecycle.""" def __init__(self) -> None: self._notifications: dict[str, Notification] = {} self._max_active: int = 10 # Keep at most 10 active at a time async def create(self, notification: Notification) -> Notification: """Create a new notification or update an existing one by ID.""" # If a notification with this ID already exists, update it if notification.id in self._notifications: existing = self._notifications[notification.id] # Preserve type if not explicitly set on update if not notification.type: notification.type = existing.type self._notifications[notification.id] = notification logger.debug(f"Notification updated: {notification.id}") else: self._notifications[notification.id] = notification logger.info( f"Notification created: {notification.id} ({notification.type})" ) # Prune old notifications if we exceed the limit self._prune_old() return notification def _prune_old(self) -> None: """Remove oldest notifications if we exceed the limit.""" if len(self._notifications) <= self._max_active: return # Remove oldest first (by insertion order) ids_to_remove = list(self._notifications.keys())[ : len(self._notifications) - self._max_active ] for nid in ids_to_remove: del self._notifications[nid] logger.debug(f"Notification pruned: {nid}") def get_active(self) -> list[dict]: """Return all active notifications as serializable dicts.""" result = [] for n in self._notifications.values(): entry: dict = { "id": n.id, "type": n.type, "title": n.title, "message": n.message, "priority": n.priority, } if n.type == "progress" and n.progress: entry["progress"] = { "current": n.progress.current, "min": n.progress.min, "max": n.progress.max, "unit": n.progress.unit, "percentage": n.progress.percentage, } if n.eta: entry["eta"] = n.eta result.append(entry) return result async def dismiss(self, notification_id: str) -> bool: """Dismiss (remove) a notification by ID.""" if notification_id in self._notifications: del self._notifications[notification_id] logger.debug(f"Notification dismissed: {notification_id}") return True return False