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: ""
});
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

View File

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

View File

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

View File

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

View File

@@ -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);

View File

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

View File

@@ -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 = {

View File

@@ -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));

View File

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

View File

@@ -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 {}