Added Toast Notification Framework & Documentation

This commit is contained in:
2025-11-26 21:08:47 -07:00
parent d50c1533e5
commit da48568aa4
7 changed files with 504 additions and 1 deletions

View File

@@ -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:

View File

@@ -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"]

View File

@@ -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.")

View File

@@ -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 (
<ThemeProvider theme={darkTheme}>
<CssBaseline />
<Notifications socket={window.BorealisSocket} currentUser={user} />
<Box
sx={{
width: "100vw",

View File

@@ -0,0 +1,273 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Box, Typography } from "@mui/material";
import {
NotificationsActive as NotificationsActiveIcon,
InfoOutlined as InfoOutlinedIcon,
CheckCircle as CheckCircleIcon,
WarningAmber as WarningAmberIcon,
ErrorOutline as ErrorOutlineIcon,
Schedule as ScheduleIcon,
FilterAlt as FilterAltIcon,
CloudDone as CloudDoneIcon,
Update as UpdateIcon,
DeviceHub as DeviceHubIcon,
} from "@mui/icons-material";
const ICON_MAP = {
notification: NotificationsActiveIcon,
notifications: NotificationsActiveIcon,
notificationsactive: NotificationsActiveIcon,
info: InfoOutlinedIcon,
success: CheckCircleIcon,
check: CheckCircleIcon,
warning: WarningAmberIcon,
error: ErrorOutlineIcon,
alert: ErrorOutlineIcon,
schedule: ScheduleIcon,
clock: ScheduleIcon,
job: ScheduleIcon,
filter: FilterAltIcon,
filters: FilterAltIcon,
done: CloudDoneIcon,
synced: CloudDoneIcon,
update: UpdateIcon,
device: DeviceHubIcon,
};
const THEMES = {
info: {
background:
"linear-gradient(135deg, rgba(19,27,48,0.96) 0%, rgba(25,36,60,0.94) 40%, rgba(32,52,82,0.9) 68%, rgba(44,70,104,0.86) 100%)",
glow: "0 12px 30px rgba(4, 7, 17, 0.55), 0 0 0 1px rgba(126, 192, 255, 0.14)",
border: "1px solid rgba(148, 208, 255, 0.18)",
text: "#dfe8fa",
icon: "#c5ddff",
accentA: "rgba(125, 211, 252, 0.08)",
accentB: "rgba(192, 132, 252, 0.07)",
},
warning: {
background:
"linear-gradient(135deg, rgba(44,35,10,0.95) 0%, rgba(54,42,14,0.92) 36%, rgba(64,50,18,0.9) 68%, rgba(78,60,20,0.86) 100%)",
glow: "0 12px 30px rgba(18,12,4,0.6), 0 0 0 1px rgba(255, 210, 125, 0.16)",
border: "1px solid rgba(255, 219, 128, 0.24)",
text: "#f6edd8",
icon: "#ffe7a3",
accentA: "rgba(255, 214, 140, 0.1)",
accentB: "rgba(255, 186, 92, 0.08)",
},
error: {
background:
"linear-gradient(135deg, rgba(48,16,18,0.95) 0%, rgba(62,20,24,0.92) 36%, rgba(78,26,30,0.9) 68%, rgba(94,30,34,0.86) 100%)",
glow: "0 12px 30px rgba(18,4,4,0.6), 0 0 0 1px rgba(255, 158, 158, 0.16)",
border: "1px solid rgba(255, 158, 158, 0.22)",
text: "#f9e2e2",
icon: "#ffc4c4",
accentA: "rgba(255, 163, 163, 0.08)",
accentB: "rgba(255, 116, 116, 0.08)",
},
};
function themeForVariant(variant) {
return THEMES[variant] || THEMES.info;
}
const toKey = (value) => (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 (
<Box
sx={{
width: 360,
maxWidth: "92vw",
display: "flex",
gap: 1.5,
alignItems: "center",
backgroundImage: theme.background,
borderRadius: 2.5,
boxShadow: theme.glow,
border: theme.border,
backdropFilter: "blur(14px)",
color: theme.text,
p: 1.6,
opacity: item.closing ? 1 : 0,
transform: item.closing ? "translateY(0) scale(1)" : "translateY(-10px) scale(0.98)",
animation: `${animationName} 0.45s ease forwards`,
animationDelay: `${delay}ms`,
position: "relative",
overflow: "hidden",
"&::after": {
content: "\"\"",
position: "absolute",
inset: 0,
background:
`radial-gradient(120% 120% at 8% 8%, ${theme.accentA}, transparent 52%), ` +
`radial-gradient(120% 120% at 80% 10%, ${theme.accentB}, transparent 60%)`,
pointerEvents: "none",
},
}}
>
<Box
sx={{
width: 34,
height: 34,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: theme.icon,
}}
>
<Icon sx={{ fontSize: 26, filter: "drop-shadow(0 2px 6px rgba(0,0,0,0.35))" }} />
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.25, minWidth: 0 }}>
<Typography
variant="subtitle2"
sx={{
color: theme.text,
fontWeight: 700,
letterSpacing: 0.2,
lineHeight: 1.15,
textShadow: "0 1px 0 rgba(0,0,0,0.2)",
}}
noWrap
>
{item.title || "Notification"}
</Typography>
<Typography
variant="body2"
sx={{
color: theme.text,
opacity: 0.9,
lineHeight: 1.35,
whiteSpace: "pre-line",
}}
>
{item.message}
</Typography>
</Box>
</Box>
);
}
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 (
<>
<style>{`
@keyframes dropIn {
0% { opacity: 0; transform: translateY(-14px) scale(0.97); }
50% { opacity: 0.9; transform: translateY(0) scale(1.01); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes fadeOut {
0% { opacity: 1; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(-8px) scale(0.98); }
}
`}</style>
<Box
sx={{
position: "fixed",
top: 18,
right: 18,
zIndex: 2400,
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
gap: 1.25,
pointerEvents: "none",
}}
>
{items.map((item, index) => (
<Box key={item.id} sx={{ pointerEvents: "auto" }}>
<NotificationCard item={item} index={index} />
</Box>
))}
</Box>
</>
);
}