Added Toast Notifications Throughout Borealis Pages

This commit is contained in:
2025-11-26 22:21:56 -07:00
parent da48568aa4
commit 2655a06874
10 changed files with 289 additions and 10 deletions

View File

@@ -71,7 +71,47 @@ export default function GithubAPIToken({ isAdmin = false, onPageMetaChange }) {
error: "" 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); setLoading(true);
setFetchError(""); setFetchError("");
try { try {
@@ -91,6 +131,9 @@ export default function GithubAPIToken({ isAdmin = false, onPageMetaChange }) {
rateLimit: typeof data?.rate_limit === "number" ? data.rate_limit : null, rateLimit: typeof data?.rate_limit === "number" ? data.rate_limit : null,
error: typeof data?.error === "string" ? data.error : "" error: typeof data?.error === "string" ? data.error : ""
}); });
if (shouldNotify) {
broadcastVerificationResult(data);
}
} catch (err) { } catch (err) {
const message = err && typeof err.message === "string" ? err.message : String(err); const message = err && typeof err.message === "string" ? err.message : String(err);
setFetchError(message); setFetchError(message);
@@ -100,7 +143,7 @@ export default function GithubAPIToken({ isAdmin = false, onPageMetaChange }) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [broadcastVerificationResult]);
useEffect(() => { useEffect(() => {
if (!isAdmin) return; if (!isAdmin) return;
@@ -131,13 +174,14 @@ export default function GithubAPIToken({ isAdmin = false, onPageMetaChange }) {
rateLimit: typeof data?.rate_limit === "number" ? data.rate_limit : null, rateLimit: typeof data?.rate_limit === "number" ? data.rate_limit : null,
error: typeof data?.error === "string" ? data.error : "" error: typeof data?.error === "string" ? data.error : ""
}); });
broadcastVerificationResult(data);
} catch (err) { } catch (err) {
const message = err && typeof err.message === "string" ? err.message : String(err); const message = err && typeof err.message === "string" ? err.message : String(err);
setFetchError(message); setFetchError(message);
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [inputValue]); }, [broadcastVerificationResult, inputValue]);
const dirty = useMemo(() => inputValue !== token, [inputValue, token]); const dirty = useMemo(() => inputValue !== token, [inputValue, token]);
@@ -283,7 +327,7 @@ export default function GithubAPIToken({ isAdmin = false, onPageMetaChange }) {
minWidth: 86, minWidth: 86,
"&:hover": { borderColor: "rgba(148,163,184,0.55)" }, "&:hover": { borderColor: "rgba(148,163,184,0.55)" },
}} }}
onClick={hydrate} onClick={() => hydrate(true)}
disabled={loading || saving} disabled={loading || saving}
> >
Refresh Refresh

View File

@@ -106,6 +106,24 @@ export default function UserManagement({ isAdmin = false, onPageMetaChange }) {
const [resetMfaOpen, setResetMfaOpen] = useState(false); const [resetMfaOpen, setResetMfaOpen] = useState(false);
const [resetMfaTarget, setResetMfaTarget] = useState(null); const [resetMfaTarget, setResetMfaTarget] = useState(null);
const useGlobalHeader = Boolean(onPageMetaChange); 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 // Columns and filters
const columns = useMemo(() => ([ const columns = useMemo(() => ([
@@ -223,6 +241,9 @@ export default function UserManagement({ isAdmin = false, onPageMetaChange }) {
return; return;
} }
await fetchUsers(); await fetchUsers();
if (user?.username) {
sendNotification(`User ${user.username} Deleted Successfully`);
}
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setWarnMessage("Failed to delete user"); setWarnMessage("Failed to delete user");
@@ -262,6 +283,10 @@ export default function UserManagement({ isAdmin = false, onPageMetaChange }) {
return; return;
} }
await fetchUsers(); await fetchUsers();
if (user?.username) {
const action = (nextRole || "").toLowerCase() === "admin" ? "Promoted to Admin" : "Demoted to User";
sendNotification(`User ${user.username} ${action}`);
}
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setWarnMessage("Failed to change role"); setWarnMessage("Failed to change role");
@@ -297,6 +322,9 @@ export default function UserManagement({ isAdmin = false, onPageMetaChange }) {
return; return;
} }
await fetchUsers(); await fetchUsers();
if (username) {
sendNotification(`MFA Reset for "${username}"`);
}
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setWarnMessage("Failed to reset MFA for this user."); setWarnMessage("Failed to reset MFA for this user.");
@@ -376,6 +404,9 @@ export default function UserManagement({ isAdmin = false, onPageMetaChange }) {
setResetOpen(false); setResetOpen(false);
setResetTarget(null); setResetTarget(null);
setNewPassword(""); setNewPassword("");
if (user?.username) {
sendNotification(`Password Reset for ${user.username}`);
}
} catch (e) { } catch (e) {
console.error(e); console.error(e);
alert("Failed to reset password"); alert("Failed to reset password");
@@ -411,6 +442,7 @@ export default function UserManagement({ isAdmin = false, onPageMetaChange }) {
} }
setCreateOpen(false); setCreateOpen(false);
await fetchUsers(); await fetchUsers();
sendNotification(`User ${u} Created Successfully`);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
alert("Failed to create user"); alert("Failed to create user");

View File

@@ -125,6 +125,24 @@ export default function LogManagement({ isAdmin = false, onPageMetaChange }) {
const [quickFilter, setQuickFilter] = useState(""); const [quickFilter, setQuickFilter] = useState("");
const gridRef = useRef(null); const gridRef = useRef(null);
const useGlobalHeader = Boolean(onPageMetaChange); 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 logMap = useMemo(() => {
const map = new Map(); const map = new Map();
@@ -305,11 +323,14 @@ const defaultColDef = useMemo(
setEntries([]); setEntries([]);
setEntriesMeta(null); setEntriesMeta(null);
setActionMessage("Log files deleted."); setActionMessage("Log files deleted.");
if (target) {
sendNotification(`Log "${target}" Deleted Successfully`);
}
} catch (err) { } catch (err) {
setError(String(err)); setError(String(err));
} }
}, },
[logs, selectedDomainData, selectedFile] [logs, selectedDomainData, selectedFile, sendNotification]
); );
const disableRetentionSave = const disableRetentionSave =

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
Box, Box,
Paper, Paper,
@@ -385,6 +385,27 @@ export default function AssemblyEditor({
() => (isAnsible ? PAGE_SUBTITLE_ANSIBLE : PAGE_SUBTITLE_SCRIPT), () => (isAnsible ? PAGE_SUBTITLE_ANSIBLE : PAGE_SUBTITLE_SCRIPT),
[isAnsible] [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(() => { useEffect(() => {
onPageMetaChange?.({ onPageMetaChange?.({
@@ -749,7 +770,12 @@ export default function AssemblyEditor({
if (!resp.ok) { if (!resp.ok) {
throw new Error(data?.error || data?.message || `HTTP ${resp.status}`); 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) { } catch (err) {
console.error("Failed to toggle Dev Mode:", err); console.error("Failed to toggle Dev Mode:", err);
const message = err?.message || "Failed to update Dev Mode."; const message = err?.message || "Failed to update Dev Mode.";

View File

@@ -252,6 +252,24 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript, userRole =
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [cloneDialog, setCloneDialog] = useState({ open: false, row: null, targetDomain: "user" }); const [cloneDialog, setCloneDialog] = useState({ open: false, row: null, targetDomain: "user" });
const isAdmin = (userRole || "").toLowerCase() === "admin"; 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(() => { useEffect(() => {
onPageMetaChange?.({ onPageMetaChange?.({
@@ -433,6 +451,10 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript, userRole =
} }
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`); if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
const label = target.name || target.fileName || target.assemblyGuid;
if (label) {
sendNotification(`Assembly "${label}" Deleted Successfully`);
}
await fetchAssemblies(); await fetchAssemblies();
} catch (err) { } catch (err) {
console.error("Failed to delete assembly:", err); console.error("Failed to delete assembly:", err);

View File

@@ -219,6 +219,24 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
const [previewError, setPreviewError] = useState(null); const [previewError, setPreviewError] = useState(null);
const [previewAppliedAt, setPreviewAppliedAt] = useState(null); const [previewAppliedAt, setPreviewAppliedAt] = useState(null);
const gridRef = useRef(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( const gridTheme = useMemo(
() => () =>
themeQuartz.withParams({ themeQuartz.withParams({
@@ -622,12 +640,16 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved })
const json = await resp.json().catch(() => ({})); const json = await resp.json().catch(() => ({}));
const saved = json?.filter || json || payload; const saved = json?.filter || json || payload;
onSaved?.(saved); onSaved?.(saved);
if (method === "POST") {
const createdName = saved?.name || payload.name || "Filter";
sendNotification(`File ${createdName} Created Successfully`);
}
} catch (err) { } catch (err) {
setSaveError(err?.message || "Unable to save filter"); setSaveError(err?.message || "Unable to save filter");
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [applyToAllSites, groups, initialFilter, name, onSaved, scope, targetSite]); }, [applyToAllSites, groups, initialFilter, name, onSaved, scope, sendNotification, targetSite]);
const renderConditionRow = (groupId, condition, isFirst) => { const renderConditionRow = (groupId, condition, isFirst) => {
const label = DEVICE_FIELDS.find((f) => f.value === condition.field)?.label || condition.field; const label = DEVICE_FIELDS.find((f) => f.value === condition.field)?.label || condition.field;

View File

@@ -11,6 +11,13 @@ import {
CloudDone as CloudDoneIcon, CloudDone as CloudDoneIcon,
Update as UpdateIcon, Update as UpdateIcon,
DeviceHub as DeviceHubIcon, 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"; } from "@mui/icons-material";
const ICON_MAP = { const ICON_MAP = {
@@ -32,6 +39,29 @@ const ICON_MAP = {
synced: CloudDoneIcon, synced: CloudDoneIcon,
update: UpdateIcon, update: UpdateIcon,
device: DeviceHubIcon, 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 = { const THEMES = {

View File

@@ -861,6 +861,27 @@ export default function CreateJob({
} }
return PAGE_SUBTITLE; return PAGE_SUBTITLE;
}, [scheduleType]); }, [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(() => { useEffect(() => {
onPageMetaChange?.({ onPageMetaChange?.({
@@ -2759,7 +2780,12 @@ export default function CreateJob({
}); });
const data = await resp.json(); const data = await resp.json();
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`); 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(); onCancel && onCancel();
} catch (err) { } catch (err) {
alert(String(err.message || err)); alert(String(err.message || err));

View File

@@ -175,6 +175,24 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
const [assembliesLoading, setAssembliesLoading] = useState(false); const [assembliesLoading, setAssembliesLoading] = useState(false);
const [assembliesError, setAssembliesError] = useState(""); const [assembliesError, setAssembliesError] = useState("");
const gridApiRef = useRef(null); 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(() => { useEffect(() => {
onPageMetaChange?.({ onPageMetaChange?.({
@@ -997,6 +1015,10 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
try { try {
const ids = Array.from(selectedIds); const ids = Array.from(selectedIds);
const idSet = new Set(ids); 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( await Promise.allSettled(
ids.map((id) => fetch(`/api/scheduled_jobs/${id}`, { method: "DELETE" })) ids.map((id) => fetch(`/api/scheduled_jobs/${id}`, { method: "DELETE" }))
); );
@@ -1007,6 +1029,10 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
}) })
); );
setSelectedIds(() => new Set()); setSelectedIds(() => new Set());
selectedJobs
.map((job) => job?.name)
.filter(Boolean)
.forEach((name) => sendNotification(`Job ${name} Deleted Successfully`));
} catch { } catch {
// ignore delete errors here; a fresh load will surface them // ignore delete errors here; a fresh load will surface them
} }

View File

@@ -78,6 +78,24 @@ export default function SiteList({ onOpenDevicesForSite, onPageMetaChange }) {
const [renameValue, setRenameValue] = useState(""); const [renameValue, setRenameValue] = useState("");
const [rotatingId, setRotatingId] = useState(null); const [rotatingId, setRotatingId] = useState(null);
const gridRef = useRef(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(() => { useEffect(() => {
onPageMetaChange?.({ onPageMetaChange?.({
@@ -355,6 +373,9 @@ export default function SiteList({ onOpenDevicesForSite, onPageMetaChange }) {
}); });
if (res.ok) { if (res.ok) {
setCreateOpen(false); setCreateOpen(false);
if (name) {
sendNotification(`Site ${name} Created Successfully`);
}
fetchSites(); fetchSites();
} }
} catch {} } catch {}
@@ -368,11 +389,18 @@ export default function SiteList({ onOpenDevicesForSite, onPageMetaChange }) {
onConfirm={async () => { onConfirm={async () => {
try { try {
const ids = Array.from(selectedIds); 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", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }), body: JSON.stringify({ ids }),
}); });
if (resp.ok) {
selectedNames.forEach((name) => sendNotification(`Site ${name} Deleted Successfully`));
}
} catch {} } catch {}
setDeleteOpen(false); setDeleteOpen(false);
setSelectedIds(new Set()); setSelectedIds(new Set());
@@ -390,6 +418,7 @@ export default function SiteList({ onOpenDevicesForSite, onPageMetaChange }) {
if (!newName) return; if (!newName) return;
const selId = selectedIds.size === 1 ? Array.from(selectedIds)[0] : null; const selId = selectedIds.size === 1 ? Array.from(selectedIds)[0] : null;
if (!selId) return; if (!selId) return;
const oldName = rows.find((r) => r.id === selId)?.name || "Site";
try { try {
const res = await fetch("/api/sites/rename", { const res = await fetch("/api/sites/rename", {
method: "POST", method: "POST",
@@ -398,6 +427,7 @@ export default function SiteList({ onOpenDevicesForSite, onPageMetaChange }) {
}); });
if (res.ok) { if (res.ok) {
setRenameOpen(false); setRenameOpen(false);
sendNotification(`Site ${oldName} Renamed as ${newName} Successfully`);
fetchSites(); fetchSites();
} }
} catch {} } catch {}