mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-16 02:45:48 -07:00
Added Toast Notification Framework & Documentation
This commit is contained in:
@@ -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:
|
||||
|
||||
12
Data/Engine/services/API/notifications/__init__.py
Normal file
12
Data/Engine/services/API/notifications/__init__.py
Normal 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"]
|
||||
98
Data/Engine/services/API/notifications/management.py
Normal file
98
Data/Engine/services/API/notifications/management.py
Normal 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.")
|
||||
@@ -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",
|
||||
|
||||
273
Data/Engine/web-interface/src/Notifications.jsx
Normal file
273
Data/Engine/web-interface/src/Notifications.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user