From 2655a068742baa3fb7b987aa6223f1d0fd733f15 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Wed, 26 Nov 2025 22:21:56 -0700 Subject: [PATCH] Added Toast Notifications Throughout Borealis Pages --- .../Access_Management/Github_API_Token.jsx | 52 +++++++++++++++++-- .../src/Access_Management/Users.jsx | 32 ++++++++++++ .../src/Admin/Log_Management.jsx | 23 +++++++- .../src/Assemblies/Assembly_Editor.jsx | 30 ++++++++++- .../src/Assemblies/Assembly_List.jsx | 22 ++++++++ .../src/Devices/Filters/Filter_Editor.jsx | 24 ++++++++- .../web-interface/src/Notifications.jsx | 30 +++++++++++ .../src/Scheduling/Create_Job.jsx | 28 +++++++++- .../src/Scheduling/Scheduled_Jobs_List.jsx | 26 ++++++++++ .../web-interface/src/Sites/Site_List.jsx | 32 +++++++++++- 10 files changed, 289 insertions(+), 10 deletions(-) diff --git a/Data/Engine/web-interface/src/Access_Management/Github_API_Token.jsx b/Data/Engine/web-interface/src/Access_Management/Github_API_Token.jsx index e0b60a33..a29b93a4 100644 --- a/Data/Engine/web-interface/src/Access_Management/Github_API_Token.jsx +++ b/Data/Engine/web-interface/src/Access_Management/Github_API_Token.jsx @@ -71,7 +71,47 @@ export default function GithubAPIToken({ isAdmin = false, onPageMetaChange }) { error: "" }); - const hydrate = useCallback(async () => { + const sendNotification = useCallback(async ({ message, variant = "info" }) => { + if (!message) return; + try { + await fetch("/api/notifications/notify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + title: "GitHub API Token", + message, + icon: "github", + variant, + }), + }); + } catch { + /* Swallow notification transport errors */ + } + }, []); + + const broadcastVerificationResult = useCallback( + (payload) => { + if (!payload) return; + const isValid = payload.valid === true; + const status = (payload.status || "").toLowerCase(); + const hasToken = Boolean((payload.token || "").trim()); + if (isValid) { + sendNotification({ + variant: "info", + message: "Github Personal Access Token Successfully Validated and Working", + }); + } else if ((hasToken || status) && status !== "missing") { + sendNotification({ + variant: "error", + message: "Github Personal Access Token is either Invalid or Expired", + }); + } + }, + [sendNotification] + ); + + const hydrate = useCallback(async (shouldNotify = false) => { setLoading(true); setFetchError(""); try { @@ -91,6 +131,9 @@ export default function GithubAPIToken({ isAdmin = false, onPageMetaChange }) { rateLimit: typeof data?.rate_limit === "number" ? data.rate_limit : null, error: typeof data?.error === "string" ? data.error : "" }); + if (shouldNotify) { + broadcastVerificationResult(data); + } } catch (err) { const message = err && typeof err.message === "string" ? err.message : String(err); setFetchError(message); @@ -100,7 +143,7 @@ export default function GithubAPIToken({ isAdmin = false, onPageMetaChange }) { } finally { setLoading(false); } - }, []); + }, [broadcastVerificationResult]); useEffect(() => { if (!isAdmin) return; @@ -131,13 +174,14 @@ export default function GithubAPIToken({ isAdmin = false, onPageMetaChange }) { rateLimit: typeof data?.rate_limit === "number" ? data.rate_limit : null, error: typeof data?.error === "string" ? data.error : "" }); + broadcastVerificationResult(data); } catch (err) { const message = err && typeof err.message === "string" ? err.message : String(err); setFetchError(message); } finally { setSaving(false); } - }, [inputValue]); + }, [broadcastVerificationResult, inputValue]); const dirty = useMemo(() => inputValue !== token, [inputValue, token]); @@ -283,7 +327,7 @@ export default function GithubAPIToken({ isAdmin = false, onPageMetaChange }) { minWidth: 86, "&:hover": { borderColor: "rgba(148,163,184,0.55)" }, }} - onClick={hydrate} + onClick={() => hydrate(true)} disabled={loading || saving} > Refresh diff --git a/Data/Engine/web-interface/src/Access_Management/Users.jsx b/Data/Engine/web-interface/src/Access_Management/Users.jsx index 6330397f..29c68700 100644 --- a/Data/Engine/web-interface/src/Access_Management/Users.jsx +++ b/Data/Engine/web-interface/src/Access_Management/Users.jsx @@ -106,6 +106,24 @@ export default function UserManagement({ isAdmin = false, onPageMetaChange }) { const [resetMfaOpen, setResetMfaOpen] = useState(false); const [resetMfaTarget, setResetMfaTarget] = useState(null); const useGlobalHeader = Boolean(onPageMetaChange); + const sendNotification = useCallback(async (message) => { + if (!message) return; + try { + await fetch("/api/notifications/notify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + title: "User Management", + message, + icon: "group", + variant: "info", + }), + }); + } catch { + /* notification transport errors are non-critical */ + } + }, []); // Columns and filters const columns = useMemo(() => ([ @@ -223,6 +241,9 @@ export default function UserManagement({ isAdmin = false, onPageMetaChange }) { return; } await fetchUsers(); + if (user?.username) { + sendNotification(`User ${user.username} Deleted Successfully`); + } } catch (e) { console.error(e); setWarnMessage("Failed to delete user"); @@ -262,6 +283,10 @@ export default function UserManagement({ isAdmin = false, onPageMetaChange }) { return; } await fetchUsers(); + if (user?.username) { + const action = (nextRole || "").toLowerCase() === "admin" ? "Promoted to Admin" : "Demoted to User"; + sendNotification(`User ${user.username} ${action}`); + } } catch (e) { console.error(e); setWarnMessage("Failed to change role"); @@ -297,6 +322,9 @@ export default function UserManagement({ isAdmin = false, onPageMetaChange }) { return; } await fetchUsers(); + if (username) { + sendNotification(`MFA Reset for "${username}"`); + } } catch (err) { console.error(err); setWarnMessage("Failed to reset MFA for this user."); @@ -376,6 +404,9 @@ export default function UserManagement({ isAdmin = false, onPageMetaChange }) { setResetOpen(false); setResetTarget(null); setNewPassword(""); + if (user?.username) { + sendNotification(`Password Reset for ${user.username}`); + } } catch (e) { console.error(e); alert("Failed to reset password"); @@ -411,6 +442,7 @@ export default function UserManagement({ isAdmin = false, onPageMetaChange }) { } setCreateOpen(false); await fetchUsers(); + sendNotification(`User ${u} Created Successfully`); } catch (e) { console.error(e); alert("Failed to create user"); diff --git a/Data/Engine/web-interface/src/Admin/Log_Management.jsx b/Data/Engine/web-interface/src/Admin/Log_Management.jsx index c382c3e4..decd3969 100644 --- a/Data/Engine/web-interface/src/Admin/Log_Management.jsx +++ b/Data/Engine/web-interface/src/Admin/Log_Management.jsx @@ -125,6 +125,24 @@ export default function LogManagement({ isAdmin = false, onPageMetaChange }) { const [quickFilter, setQuickFilter] = useState(""); const gridRef = useRef(null); const useGlobalHeader = Boolean(onPageMetaChange); + const sendNotification = useCallback(async (message) => { + if (!message) return; + try { + await fetch("/api/notifications/notify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + title: "Log Management", + message, + icon: "logs", + variant: "info", + }), + }); + } catch { + /* notifications are best-effort */ + } + }, []); const logMap = useMemo(() => { const map = new Map(); @@ -305,11 +323,14 @@ const defaultColDef = useMemo( setEntries([]); setEntriesMeta(null); setActionMessage("Log files deleted."); + if (target) { + sendNotification(`Log "${target}" Deleted Successfully`); + } } catch (err) { setError(String(err)); } }, - [logs, selectedDomainData, selectedFile] + [logs, selectedDomainData, selectedFile, sendNotification] ); const disableRetentionSave = diff --git a/Data/Engine/web-interface/src/Assemblies/Assembly_Editor.jsx b/Data/Engine/web-interface/src/Assemblies/Assembly_Editor.jsx index 7e87f8c8..fcfc8a9c 100644 --- a/Data/Engine/web-interface/src/Assemblies/Assembly_Editor.jsx +++ b/Data/Engine/web-interface/src/Assemblies/Assembly_Editor.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Paper, @@ -385,6 +385,27 @@ export default function AssemblyEditor({ () => (isAnsible ? PAGE_SUBTITLE_ANSIBLE : PAGE_SUBTITLE_SCRIPT), [isAnsible] ); + const sendNotification = useCallback( + async ({ message, variant = "info" }) => { + if (!message) return; + try { + await fetch("/api/notifications/notify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + title: pageTitle, + message, + icon: "code", + variant, + }), + }); + } catch { + /* notifications are best-effort */ + } + }, + [pageTitle] + ); useEffect(() => { onPageMetaChange?.({ @@ -749,7 +770,12 @@ export default function AssemblyEditor({ if (!resp.ok) { throw new Error(data?.error || data?.message || `HTTP ${resp.status}`); } - setDevModeEnabled(Boolean(data?.dev_mode)); + const nextDevMode = Boolean(data?.dev_mode); + setDevModeEnabled(nextDevMode); + sendNotification({ + variant: nextDevMode ? "warning" : "info", + message: nextDevMode ? "Developer Mode Enabled" : "Developer Mode Disabled", + }); } catch (err) { console.error("Failed to toggle Dev Mode:", err); const message = err?.message || "Failed to update Dev Mode."; diff --git a/Data/Engine/web-interface/src/Assemblies/Assembly_List.jsx b/Data/Engine/web-interface/src/Assemblies/Assembly_List.jsx index 15f89f98..d158f6c5 100644 --- a/Data/Engine/web-interface/src/Assemblies/Assembly_List.jsx +++ b/Data/Engine/web-interface/src/Assemblies/Assembly_List.jsx @@ -252,6 +252,24 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript, userRole = const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [cloneDialog, setCloneDialog] = useState({ open: false, row: null, targetDomain: "user" }); const isAdmin = (userRole || "").toLowerCase() === "admin"; + const sendNotification = useCallback(async (message) => { + if (!message) return; + try { + await fetch("/api/notifications/notify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + title: "Assemblies", + message, + icon: "apps", + variant: "info", + }), + }); + } catch { + /* notification transport is best-effort */ + } + }, []); useEffect(() => { onPageMetaChange?.({ @@ -433,6 +451,10 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript, userRole = } if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`); setDeleteDialogOpen(false); + const label = target.name || target.fileName || target.assemblyGuid; + if (label) { + sendNotification(`Assembly "${label}" Deleted Successfully`); + } await fetchAssemblies(); } catch (err) { console.error("Failed to delete assembly:", err); diff --git a/Data/Engine/web-interface/src/Devices/Filters/Filter_Editor.jsx b/Data/Engine/web-interface/src/Devices/Filters/Filter_Editor.jsx index e1739535..e2ae8852 100644 --- a/Data/Engine/web-interface/src/Devices/Filters/Filter_Editor.jsx +++ b/Data/Engine/web-interface/src/Devices/Filters/Filter_Editor.jsx @@ -219,6 +219,24 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved }) const [previewError, setPreviewError] = useState(null); const [previewAppliedAt, setPreviewAppliedAt] = useState(null); const gridRef = useRef(null); + const sendNotification = useCallback(async (message) => { + if (!message) return; + try { + await fetch("/api/notifications/notify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + title: "Device Filters", + message, + icon: "filter", + variant: "info", + }), + }); + } catch { + /* ignore notification transport errors */ + } + }, []); const gridTheme = useMemo( () => themeQuartz.withParams({ @@ -622,12 +640,16 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved }) const json = await resp.json().catch(() => ({})); const saved = json?.filter || json || payload; onSaved?.(saved); + if (method === "POST") { + const createdName = saved?.name || payload.name || "Filter"; + sendNotification(`File ${createdName} Created Successfully`); + } } catch (err) { setSaveError(err?.message || "Unable to save filter"); } finally { setSaving(false); } - }, [applyToAllSites, groups, initialFilter, name, onSaved, scope, targetSite]); + }, [applyToAllSites, groups, initialFilter, name, onSaved, scope, sendNotification, targetSite]); const renderConditionRow = (groupId, condition, isFirst) => { const label = DEVICE_FIELDS.find((f) => f.value === condition.field)?.label || condition.field; diff --git a/Data/Engine/web-interface/src/Notifications.jsx b/Data/Engine/web-interface/src/Notifications.jsx index 5f770b0a..daf37567 100644 --- a/Data/Engine/web-interface/src/Notifications.jsx +++ b/Data/Engine/web-interface/src/Notifications.jsx @@ -11,6 +11,13 @@ import { CloudDone as CloudDoneIcon, Update as UpdateIcon, DeviceHub as DeviceHubIcon, + GitHub as GitHubIcon, + Group as GroupIcon, + ReceiptLong as ReceiptLongIcon, + Apps as AppsIcon, + Code as CodeIcon, + PendingActions as PendingActionsIcon, + LocationCity as LocationCityIcon, } from "@mui/icons-material"; const ICON_MAP = { @@ -32,6 +39,29 @@ const ICON_MAP = { synced: CloudDoneIcon, update: UpdateIcon, device: DeviceHubIcon, + github: GitHubIcon, + git: GitHubIcon, + user: GroupIcon, + users: GroupIcon, + group: GroupIcon, + groups: GroupIcon, + people: GroupIcon, + log: ReceiptLongIcon, + logs: ReceiptLongIcon, + receipt: ReceiptLongIcon, + receiptlong: ReceiptLongIcon, + app: AppsIcon, + apps: AppsIcon, + assembly: AppsIcon, + assemblies: AppsIcon, + code: CodeIcon, + dev: CodeIcon, + developer: CodeIcon, + pendingactions: PendingActionsIcon, + queue: PendingActionsIcon, + locationcity: LocationCityIcon, + site: LocationCityIcon, + sites: LocationCityIcon, }; const THEMES = { diff --git a/Data/Engine/web-interface/src/Scheduling/Create_Job.jsx b/Data/Engine/web-interface/src/Scheduling/Create_Job.jsx index 3e68f9ae..051b871a 100644 --- a/Data/Engine/web-interface/src/Scheduling/Create_Job.jsx +++ b/Data/Engine/web-interface/src/Scheduling/Create_Job.jsx @@ -861,6 +861,27 @@ export default function CreateJob({ } return PAGE_SUBTITLE; }, [scheduleType]); + const sendNotification = useCallback( + async (message) => { + if (!message) return; + try { + await fetch("/api/notifications/notify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + title: resolvedPageTitle, + message, + icon: "pendingactions", + variant: "info", + }), + }); + } catch { + /* notification failures are non-blocking */ + } + }, + [resolvedPageTitle] + ); useEffect(() => { onPageMetaChange?.({ @@ -2759,7 +2780,12 @@ export default function CreateJob({ }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`); - onCreated && onCreated(data.job || payload); + const savedJob = data.job || payload; + if (!(initialJob && initialJob.id)) { + const createdName = savedJob?.name || jobName || "Job"; + sendNotification(`Job ${createdName} Created Successfully`); + } + onCreated && onCreated(savedJob); onCancel && onCancel(); } catch (err) { alert(String(err.message || err)); diff --git a/Data/Engine/web-interface/src/Scheduling/Scheduled_Jobs_List.jsx b/Data/Engine/web-interface/src/Scheduling/Scheduled_Jobs_List.jsx index 263e87c4..a4a58ed8 100644 --- a/Data/Engine/web-interface/src/Scheduling/Scheduled_Jobs_List.jsx +++ b/Data/Engine/web-interface/src/Scheduling/Scheduled_Jobs_List.jsx @@ -175,6 +175,24 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken const [assembliesLoading, setAssembliesLoading] = useState(false); const [assembliesError, setAssembliesError] = useState(""); const gridApiRef = useRef(null); + const sendNotification = useCallback(async (message) => { + if (!message) return; + try { + await fetch("/api/notifications/notify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + title: PAGE_TITLE, + message, + icon: "schedule", + variant: "info", + }), + }); + } catch { + /* best-effort notification */ + } + }, []); useEffect(() => { onPageMetaChange?.({ @@ -997,6 +1015,10 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken try { const ids = Array.from(selectedIds); const idSet = new Set(ids); + const selectedJobs = rows.filter((job, index) => { + const key = getRowId({ data: job, rowIndex: index }); + return idSet.has(key); + }); await Promise.allSettled( ids.map((id) => fetch(`/api/scheduled_jobs/${id}`, { method: "DELETE" })) ); @@ -1007,6 +1029,10 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken }) ); setSelectedIds(() => new Set()); + selectedJobs + .map((job) => job?.name) + .filter(Boolean) + .forEach((name) => sendNotification(`Job ${name} Deleted Successfully`)); } catch { // ignore delete errors here; a fresh load will surface them } diff --git a/Data/Engine/web-interface/src/Sites/Site_List.jsx b/Data/Engine/web-interface/src/Sites/Site_List.jsx index 9104e845..840173a7 100644 --- a/Data/Engine/web-interface/src/Sites/Site_List.jsx +++ b/Data/Engine/web-interface/src/Sites/Site_List.jsx @@ -78,6 +78,24 @@ export default function SiteList({ onOpenDevicesForSite, onPageMetaChange }) { const [renameValue, setRenameValue] = useState(""); const [rotatingId, setRotatingId] = useState(null); const gridRef = useRef(null); + const sendNotification = useCallback(async (message) => { + if (!message) return; + try { + await fetch("/api/notifications/notify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + title: PAGE_TITLE, + message, + icon: "locationcity", + variant: "info", + }), + }); + } catch { + /* notification transport errors are non-blocking */ + } + }, []); useEffect(() => { onPageMetaChange?.({ @@ -355,6 +373,9 @@ export default function SiteList({ onOpenDevicesForSite, onPageMetaChange }) { }); if (res.ok) { setCreateOpen(false); + if (name) { + sendNotification(`Site ${name} Created Successfully`); + } fetchSites(); } } catch {} @@ -368,11 +389,18 @@ export default function SiteList({ onOpenDevicesForSite, onPageMetaChange }) { onConfirm={async () => { try { const ids = Array.from(selectedIds); - await fetch("/api/sites/delete", { + const selectedNames = rows + .filter((row) => ids.includes(row.id)) + .map((row) => row?.name) + .filter(Boolean); + const resp = await fetch("/api/sites/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids }), }); + if (resp.ok) { + selectedNames.forEach((name) => sendNotification(`Site ${name} Deleted Successfully`)); + } } catch {} setDeleteOpen(false); setSelectedIds(new Set()); @@ -390,6 +418,7 @@ export default function SiteList({ onOpenDevicesForSite, onPageMetaChange }) { if (!newName) return; const selId = selectedIds.size === 1 ? Array.from(selectedIds)[0] : null; if (!selId) return; + const oldName = rows.find((r) => r.id === selId)?.name || "Site"; try { const res = await fetch("/api/sites/rename", { method: "POST", @@ -398,6 +427,7 @@ export default function SiteList({ onOpenDevicesForSite, onPageMetaChange }) { }); if (res.ok) { setRenameOpen(false); + sendNotification(`Site ${oldName} Renamed as ${newName} Successfully`); fetchSites(); } } catch {}