diff --git a/Data/Engine/services/API/__init__.py b/Data/Engine/services/API/__init__.py index 39254bfe..f449beb8 100644 --- a/Data/Engine/services/API/__init__.py +++ b/Data/Engine/services/API/__init__.py @@ -41,10 +41,22 @@ from .devices import routes as device_routes from .devices.approval import register_admin_endpoints from .devices.management import register_management from .filters import management as filters_management +from .notifications import management as notifications_management from .scheduled_jobs import management as scheduled_jobs_management from .server import info as server_info, log_management -DEFAULT_API_GROUPS: Sequence[str] = ("core", "auth", "tokens", "enrollment", "devices", "filters", "server", "assemblies", "scheduled_jobs") +DEFAULT_API_GROUPS: Sequence[str] = ( + "core", + "auth", + "tokens", + "enrollment", + "devices", + "filters", + "server", + "assemblies", + "scheduled_jobs", + "notifications", +) _SERVER_SCOPE_PATTERN = re.compile(r"\\b(?:scope|context|agent_context)=([A-Za-z0-9_-]+)", re.IGNORECASE) _SERVER_AGENT_ID_PATTERN = re.compile(r"\\bagent_id=([^\\s,]+)", re.IGNORECASE) @@ -279,6 +291,10 @@ def _register_scheduled_jobs(app: Flask, adapters: EngineServiceAdapters) -> Non scheduled_jobs_management.register_management(app, adapters) +def _register_notifications(app: Flask, adapters: EngineServiceAdapters) -> None: + notifications_management.register_notifications(app, adapters) + + def _register_assemblies(app: Flask, adapters: EngineServiceAdapters) -> None: register_assemblies(app, adapters) register_execution(app, adapters) @@ -298,6 +314,7 @@ _GROUP_REGISTRARS: Mapping[str, Callable[[Flask, EngineServiceAdapters], None]] "server": _register_server, "assemblies": _register_assemblies, "scheduled_jobs": _register_scheduled_jobs, + "notifications": _register_notifications, } @@ -321,6 +338,8 @@ def register_api(app: Flask, context: EngineContext) -> None: normalized = [group.strip().lower() for group in enabled_groups if group] if "filters" not in normalized: normalized.append("filters") + if "notifications" not in normalized: + normalized.append("notifications") adapters: Optional[EngineServiceAdapters] = None for group in normalized: diff --git a/Data/Engine/services/API/notifications/__init__.py b/Data/Engine/services/API/notifications/__init__.py new file mode 100644 index 00000000..75cc2a64 --- /dev/null +++ b/Data/Engine/services/API/notifications/__init__.py @@ -0,0 +1,12 @@ +# ====================================================== +# Data\Engine\services\API\notifications\__init__.py +# Description: Package init for notification API endpoints. +# +# API Endpoints (if applicable): None +# ====================================================== + +"""Notification API package for the Borealis Engine.""" + +from .management import register_notifications + +__all__ = ["register_notifications"] diff --git a/Data/Engine/services/API/notifications/management.py b/Data/Engine/services/API/notifications/management.py new file mode 100644 index 00000000..ef276a02 --- /dev/null +++ b/Data/Engine/services/API/notifications/management.py @@ -0,0 +1,98 @@ +# ====================================================== +# Data\Engine\services\API\notifications\management.py +# Description: Notification dispatch endpoint used to surface authenticated toast events to the WebUI via Socket.IO. +# +# API Endpoints (if applicable): +# - POST /api/notifications/notify (Token Authenticated) - Broadcasts a transient notification payload to connected operators. +# ====================================================== + +"""Notification endpoints for the Borealis Engine runtime.""" +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Any, Dict + +from flask import Blueprint, Flask, jsonify, request + +from ...auth import RequestAuthContext + +if TYPE_CHECKING: # pragma: no cover - typing aide + from .. import EngineServiceAdapters + + +def _clean_text(value: Any, fallback: str = "") -> str: + text = fallback + if isinstance(value, str): + text = value.strip() or fallback + elif value is not None: + try: + text = str(value).strip() or fallback + except Exception: + text = fallback + return text + + +def register_notifications(app: Flask, adapters: "EngineServiceAdapters") -> None: + """Expose an authenticated notification endpoint that fans out to WebSocket clients.""" + + blueprint = Blueprint("notifications", __name__, url_prefix="/api/notifications") + auth = RequestAuthContext( + app=app, + dev_mode_manager=adapters.dev_mode_manager, + config=adapters.config, + logger=adapters.context.logger, + ) + + def _broadcast(notification: Dict[str, Any]) -> None: + socketio = getattr(adapters.context, "socketio", None) + if not socketio: + return + try: + socketio.emit("borealis_notification", notification) + except Exception: + adapters.context.logger.debug("Failed to emit notification payload.", exc_info=True) + + @blueprint.route("/notify", methods=["POST"]) + def notify() -> Any: + user, error = auth.require_user() + if error: + return jsonify(error[0]), error[1] + + data = request.get_json(silent=True) or {} + title = _clean_text(data.get("title"), "Notification") + message = _clean_text(data.get("message")) + icon = _clean_text(data.get("icon")) + variant_raw = _clean_text(data.get("variant") or data.get("type") or data.get("severity") or "info", "info") + variant = variant_raw.lower() + if variant not in {"info", "warning", "error"}: + variant = "info" + + if not message: + return ( + jsonify( + { + "error": "invalid_payload", + "message": "Notification message is required.", + } + ), + 400, + ) + + now_ts = int(time.time()) + payload = { + "id": f"notif-{now_ts}-{int(time.time() * 1000) % 1000}", + "title": title, + "message": message, + "icon": icon or "NotificationsActive", + "variant": variant, + "username": user.get("username"), + "role": user.get("role") or "User", + "created_at": now_ts, + } + + _broadcast(payload) + adapters.service_log("notifications", f"Notification sent by {user.get('username')}: {title}") + return jsonify({"status": "sent", "notification": payload}) + + app.register_blueprint(blueprint) + adapters.service_log("notifications", "Registered notification endpoints.") diff --git a/Data/Engine/web-interface/src/App.jsx b/Data/Engine/web-interface/src/App.jsx index 3a0bf18e..20c5aac2 100644 --- a/Data/Engine/web-interface/src/App.jsx +++ b/Data/Engine/web-interface/src/App.jsx @@ -51,6 +51,7 @@ import ServerInfo from "./Admin/Server_Info.jsx"; import PageTemplate from "./Admin/Page_Template.jsx"; import LogManagement from "./Admin/Log_Management.jsx"; import DeviceApprovals from "./Devices/Device_Approvals.jsx"; +import Notifications from "./Notifications.jsx"; // Networking Imports import { io } from "socket.io-client"; @@ -1369,6 +1370,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; return ( + (value || "").toString().replace(/[^a-z0-9]/gi, "").toLowerCase(); + +function resolveIcon(name) { + const mapped = ICON_MAP[toKey(name)]; + return mapped || NotificationsActiveIcon; +} + +function NotificationCard({ item, index }) { + const Icon = useMemo(() => resolveIcon(item.icon), [item.icon]); + const delay = Math.min(index * 40, 260); + const animationName = item.closing ? "fadeOut" : "dropIn"; + const theme = themeForVariant(item.variant); + + return ( + + + + + + + {item.title || "Notification"} + + + {item.message} + + + + ); +} + +export default function Notifications({ socket, currentUser }) { + const [items, setItems] = useState([]); + const timeouts = useRef({}); + const normalizedUser = toKey(currentUser); + + const clearTimeoutFor = useCallback((id) => { + const handle = timeouts.current[id]; + if (handle) { + clearTimeout(handle); + delete timeouts.current[id]; + } + }, []); + + const scheduleRemoval = useCallback((id, ttlMs) => { + clearTimeoutFor(id); + const safeTtl = Math.max(1000, ttlMs || 5200); + const exitDuration = 420; + const fadeAfter = Math.max(600, safeTtl - exitDuration); + timeouts.current[id] = setTimeout(() => { + setItems((prev) => + prev.map((entry) => (entry.id === id ? { ...entry, closing: true } : entry)) + ); + timeouts.current[id] = setTimeout(() => { + setItems((prev) => prev.filter((entry) => entry.id !== id)); + clearTimeoutFor(id); + }, exitDuration); + }, fadeAfter); + }, [clearTimeoutFor]); + + const pushNotification = useCallback( + (raw) => { + if (!raw) return; + const owner = toKey(raw.username); + if (owner && normalizedUser && owner !== normalizedUser) { + return; + } + + const id = raw.id || `notif-${Date.now()}`; + const entry = { + id, + title: raw.title || "Notification", + message: raw.message || "", + icon: raw.icon || "notification", + variant: (raw.variant || raw.severity || raw.type || "info").toLowerCase(), + created_at: raw.created_at || Math.floor(Date.now() / 1000), + closing: false, + }; + if (!entry.message) return; + + setItems((prev) => { + const withoutExisting = prev.filter((item) => item.id !== id); + return [entry, ...withoutExisting].slice(0, 5); + }); + scheduleRemoval(id, (raw.ttl_ms && Number(raw.ttl_ms)) || 5200); + }, + [normalizedUser, scheduleRemoval] + ); + + useEffect(() => { + if (!socket || typeof socket.on !== "function") return; + const handler = (data) => pushNotification(data || {}); + socket.on("borealis_notification", handler); + return () => { + try { + socket.off("borealis_notification", handler); + } catch { + /* noop */ + } + }; + }, [socket, pushNotification]); + + useEffect(() => () => Object.keys(timeouts.current).forEach(clearTimeoutFor), [clearTimeoutFor]); + + if (!items.length) { + return null; + } + + return ( + <> + + + {items.map((item, index) => ( + + + + ))} + + + ); +} diff --git a/Docs/Codex/TOAST_NOTIFICATIONS.md b/Docs/Codex/TOAST_NOTIFICATIONS.md new file mode 100644 index 00000000..b8a7ecc0 --- /dev/null +++ b/Docs/Codex/TOAST_NOTIFICATIONS.md @@ -0,0 +1,97 @@ +# Codex Guide: Toast Notifications (Borealis WebUI) + +Use this guide to add, configure, and test transient toast notifications across Borealis. It documents the backend endpoint, frontend listener, payload contract, and quick Firefox console commands you can hand to operators for validation. + +## Components & Paths +- Backend endpoint: `Data/Engine/services/API/notifications/management.py` (registered as `/api/notifications/notify`). +- Frontend listener + renderer: `Data/Engine/web-interface/src/Notifications.jsx` (mounted in `App.jsx`). +- Transport: Socket.IO event `borealis_notification` broadcast to connected WebUI clients. + +## Backend Behavior +- Auth: Uses `RequestAuthContext.require_user()`; session/Bearer must be present. Returns `401/403` otherwise. +- Route: `POST /api/notifications/notify` + - Emits `borealis_notification` over Socket.IO (no persistence). + - Logs via `service_log("notifications", ...)`. +- Validation: Requires `message` in payload. `title` defaults to `"Notification"` if omitted. +- Registration: API group `notifications` is enabled by default via `DEFAULT_API_GROUPS` and `_GROUP_REGISTRARS` in `Data/Engine/services/API/__init__.py`. + +## Payload Schema +Send JSON body (session-authenticated): +- `title` (string, optional): Heading line. Default `"Notification"`. +- `message` (string, required): Body copy. +- `icon` (string, optional): Material icon name hint (e.g., `info`, `filter`, `schedule`, `warning`, `error`). Falls back to `NotificationsActive`. +- `variant` (string, optional): Visual theme. Accepted: `info` | `warning` | `error` (case-insensitive). Aliases: `type` or `severity`. Defaults to `info`. +- `ttl_ms` (number, optional): Client-side lifetime in milliseconds; defaults to ~5200ms before fade-out. + +Notes: +- Payload is fanned out verbatim to the WebUI (plus server-added fields: `id`, `username`, `role`, `created_at`). +- The client caps the visible stack to the 5 most recent items (newest on top). +- Non-empty `message` is mandatory; otherwise HTTP 400. + +## Frontend Rendering Rules +- Component: `Notifications.jsx` listens to `borealis_notification` on `window.BorealisSocket`. +- Stack position: fixed top-right, high z-index, pointer events enabled on toasts only. +- Auto-dismiss: ~5s default; each item fades out and is removed. +- Theme by `variant`: + - `info` (default): Borealis blue aurora gradient. + - `warning`: Muted amber gradient. + - `error`: Deep red gradient. +- Icon: No container; uses the provided Material icon hint. Small drop shadow for legibility. + +## Implementation Steps (Recap) +1) Backend: Ensure `/api/notifications/notify` is registered (already in repo). New services should import `register_notifications` if API groups are customized. +2) Emit: From any authenticated server flow, POST to `/api/notifications/notify` with the payload above. +3) Frontend: `App.jsx` mounts `Notifications` globally; no per-page wiring needed. +4) Test: Use the Firefox console examples below while logged in to confirm toast rendering. + +## Firefox Console Examples (run while signed in) +Info (default blue): +```js +fetch("/api/notifications/notify", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: "Test Notification", + message: "Hello from the console!", + icon: "info", + variant: "info" + }) +}).then(r => r.json()).then(console.log).catch(console.error); +``` + +Warning (amber): +```js +fetch("/api/notifications/notify", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: "Heads up", + message: "This is a warning example.", + icon: "warning", + variant: "warning" + }) +}).then(r => r.json()).then(console.log).catch(console.error); +``` + +Error (red): +```js +fetch("/api/notifications/notify", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: "Error encountered", + message: "Something failed during processing.", + icon: "error", + variant: "error" + }) +}).then(r => r.json()).then(console.log).catch(console.error); +``` + +## Usage Notes & Tips +- Keep `message` concise; multiline is supported via `\n`. +- Use `icon` to match the source feature (e.g., `filter`, `schedule`, `device`, `error`). +- The server adds `username`/`role` to payloads; the client currently shows all variants regardless of role (filtering is per-username match when present). +- If sockets are unavailable, the endpoint still returns 200; toasts simply will not render until Socket.IO is connected. diff --git a/Docs/Codex/USER_INTERFACE.md b/Docs/Codex/USER_INTERFACE.md index 5779fa1a..914158d5 100644 --- a/Docs/Codex/USER_INTERFACE.md +++ b/Docs/Codex/USER_INTERFACE.md @@ -2,6 +2,8 @@ Applies to all Borealis frontends. Use `Data/Engine/web-interface/src/Admin/Page_Template.jsx` as the canonical visual reference (no API/business logic). Keep this doc as the single source of truth for styling rules and AG Grid behavior. +- Toast notifications: see `Docs/Codex/TOAST_NOTIFICATIONS.md` for endpoint, payload, severity variants, and quick test commands. + ## Page Template Reference - Purpose: visual-only baseline for new pages; copy structure but wire your data in real pages. - Header: small Material icon left of the title, subtitle beneath, utility buttons on the top-right.