mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-14 21:15:47 -07:00
Added Toast Notifications Throughout Borealis Pages
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user