Revert from Gitea Mirror Due to Catastrophic Destruction in Github

This commit is contained in:
2025-11-01 05:17:42 -06:00
parent 02eae72c0d
commit 6df391f21a
115 changed files with 37093 additions and 332 deletions

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<!-- Vite serves everything in /public at the site root -->
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Borealis - Automation Platform" />
<link rel="apple-touch-icon" href="/Borealis_Logo.png" />
<link rel="manifest" href="/manifest.json" />
<title>Borealis</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!-- Vite entrypoint; adjust to main.tsx if you switch to TS -->
<script type="module" src="/src/index.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,50 @@
{
"name": "borealis-webui",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.0",
"@fortawesome/fontawesome-free": "7.1.0",
"@fontsource/ibm-plex-sans": "5.0.17",
"@mui/icons-material": "7.0.2",
"@mui/material": "7.0.2",
"@mui/x-date-pickers": "8.11.3",
"@mui/x-tree-view": "8.10.0",
"ag-grid-community": "34.2.0",
"ag-grid-react": "34.2.0",
"dayjs": "1.11.18",
"normalize.css": "8.0.1",
"prismjs": "1.30.0",
"react-simple-code-editor": "0.13.1",
"react": "19.1.0",
"react-color": "2.19.3",
"react-dom": "19.1.0",
"react-resizable": "3.0.5",
"react-markdown": "8.0.6",
"reactflow": "11.11.4",
"react-simple-keyboard": "3.8.62",
"socket.io-client": "4.8.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.0.0",
"vite": "^5.0.0"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,549 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
InputLabel,
MenuItem,
Select,
TextField,
Typography,
IconButton,
Tooltip,
CircularProgress
} from "@mui/material";
import UploadIcon from "@mui/icons-material/UploadFile";
import ClearIcon from "@mui/icons-material/Clear";
const CREDENTIAL_TYPES = [
{ value: "machine", label: "Machine" },
{ value: "domain", label: "Domain" },
{ value: "token", label: "Token" }
];
const CONNECTION_TYPES = [
{ value: "ssh", label: "SSH" },
{ value: "winrm", label: "WinRM" }
];
const BECOME_METHODS = [
{ value: "", label: "None" },
{ value: "sudo", label: "sudo" },
{ value: "su", label: "su" },
{ value: "runas", label: "runas" },
{ value: "enable", label: "enable" }
];
function emptyForm() {
return {
name: "",
description: "",
site_id: "",
credential_type: "machine",
connection_type: "ssh",
username: "",
password: "",
private_key: "",
private_key_passphrase: "",
become_method: "",
become_username: "",
become_password: ""
};
}
function normalizeSiteId(value) {
if (value === null || typeof value === "undefined" || value === "") return "";
const num = Number(value);
if (Number.isNaN(num)) return "";
return String(num);
}
export default function CredentialEditor({
open,
mode = "create",
credential,
onClose,
onSaved
}) {
const isEdit = mode === "edit" && credential && credential.id;
const [form, setForm] = useState(emptyForm);
const [sites, setSites] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [passwordDirty, setPasswordDirty] = useState(false);
const [privateKeyDirty, setPrivateKeyDirty] = useState(false);
const [passphraseDirty, setPassphraseDirty] = useState(false);
const [becomePasswordDirty, setBecomePasswordDirty] = useState(false);
const [clearPassword, setClearPassword] = useState(false);
const [clearPrivateKey, setClearPrivateKey] = useState(false);
const [clearPassphrase, setClearPassphrase] = useState(false);
const [clearBecomePassword, setClearBecomePassword] = useState(false);
const [fetchingDetail, setFetchingDetail] = useState(false);
const credentialId = credential?.id;
useEffect(() => {
if (!open) return;
let canceled = false;
(async () => {
try {
const resp = await fetch("/api/sites");
if (!resp.ok) return;
const data = await resp.json();
if (canceled) return;
const parsed = Array.isArray(data?.sites)
? data.sites
.filter((s) => s && s.id)
.map((s) => ({
id: s.id,
name: s.name || `Site ${s.id}`
}))
: [];
parsed.sort((a, b) => String(a.name || "").localeCompare(String(b.name || "")));
setSites(parsed);
} catch {
if (!canceled) setSites([]);
}
})();
return () => {
canceled = true;
};
}, [open]);
useEffect(() => {
if (!open) return;
setError("");
setPasswordDirty(false);
setPrivateKeyDirty(false);
setPassphraseDirty(false);
setBecomePasswordDirty(false);
setClearPassword(false);
setClearPrivateKey(false);
setClearPassphrase(false);
setClearBecomePassword(false);
if (isEdit && credentialId) {
const applyData = (detail) => {
const next = emptyForm();
next.name = detail?.name || "";
next.description = detail?.description || "";
next.site_id = normalizeSiteId(detail?.site_id);
next.credential_type = (detail?.credential_type || "machine").toLowerCase();
next.connection_type = (detail?.connection_type || "ssh").toLowerCase();
next.username = detail?.username || "";
next.become_method = (detail?.become_method || "").toLowerCase();
next.become_username = detail?.become_username || "";
setForm(next);
};
if (credential?.name) {
applyData(credential);
} else {
setFetchingDetail(true);
(async () => {
try {
const resp = await fetch(`/api/credentials/${credentialId}`);
if (resp.ok) {
const data = await resp.json();
applyData(data?.credential || {});
}
} catch {
/* ignore */
} finally {
setFetchingDetail(false);
}
})();
}
} else {
setForm(emptyForm());
}
}, [open, isEdit, credentialId, credential]);
const currentCredentialFlags = useMemo(() => ({
hasPassword: Boolean(credential?.has_password),
hasPrivateKey: Boolean(credential?.has_private_key),
hasPrivateKeyPassphrase: Boolean(credential?.has_private_key_passphrase),
hasBecomePassword: Boolean(credential?.has_become_password)
}), [credential]);
const disableSave = loading || fetchingDetail;
const updateField = (key) => (event) => {
const value = event?.target?.value ?? "";
setForm((prev) => ({ ...prev, [key]: value }));
if (key === "password") {
setPasswordDirty(true);
setClearPassword(false);
} else if (key === "private_key") {
setPrivateKeyDirty(true);
setClearPrivateKey(false);
} else if (key === "private_key_passphrase") {
setPassphraseDirty(true);
setClearPassphrase(false);
} else if (key === "become_password") {
setBecomePasswordDirty(true);
setClearBecomePassword(false);
}
};
const handlePrivateKeyUpload = async (event) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
setForm((prev) => ({ ...prev, private_key: text }));
setPrivateKeyDirty(true);
setClearPrivateKey(false);
} catch {
setError("Unable to read private key file.");
} finally {
event.target.value = "";
}
};
const handleCancel = () => {
if (loading) return;
onClose && onClose();
};
const validate = () => {
if (!form.name.trim()) {
setError("Credential name is required.");
return false;
}
setError("");
return true;
};
const buildPayload = () => {
const payload = {
name: form.name.trim(),
description: form.description.trim(),
credential_type: (form.credential_type || "machine").toLowerCase(),
connection_type: (form.connection_type || "ssh").toLowerCase(),
username: form.username.trim(),
become_method: form.become_method.trim(),
become_username: form.become_username.trim()
};
const siteId = normalizeSiteId(form.site_id);
if (siteId) {
payload.site_id = Number(siteId);
} else {
payload.site_id = null;
}
if (passwordDirty) {
payload.password = form.password;
}
if (privateKeyDirty) {
payload.private_key = form.private_key;
}
if (passphraseDirty) {
payload.private_key_passphrase = form.private_key_passphrase;
}
if (becomePasswordDirty) {
payload.become_password = form.become_password;
}
if (clearPassword) payload.clear_password = true;
if (clearPrivateKey) payload.clear_private_key = true;
if (clearPassphrase) payload.clear_private_key_passphrase = true;
if (clearBecomePassword) payload.clear_become_password = true;
return payload;
};
const handleSave = async () => {
if (!validate()) return;
setLoading(true);
setError("");
const payload = buildPayload();
try {
const resp = await fetch(
isEdit ? `/api/credentials/${credentialId}` : "/api/credentials",
{
method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}
);
const data = await resp.json();
if (!resp.ok) {
throw new Error(data?.error || `Request failed (${resp.status})`);
}
onSaved && onSaved(data?.credential || null);
} catch (err) {
setError(String(err.message || err));
} finally {
setLoading(false);
}
};
const title = isEdit ? "Edit Credential" : "Create Credential";
const helperStyle = { fontSize: 12, color: "#8a8a8a", mt: 0.5 };
return (
<Dialog
open={open}
onClose={handleCancel}
maxWidth="md"
fullWidth
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
>
<DialogTitle sx={{ pb: 1 }}>{title}</DialogTitle>
<DialogContent dividers sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{fetchingDetail && (
<Box sx={{ display: "flex", alignItems: "center", gap: 1, color: "#aaa" }}>
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
<Typography variant="body2">Loading credential details</Typography>
</Box>
)}
{error && (
<Box sx={{ bgcolor: "#2c1c1c", border: "1px solid #663939", borderRadius: 1, p: 1 }}>
<Typography variant="body2" sx={{ color: "#ff8080" }}>{error}</Typography>
</Box>
)}
<TextField
label="Name"
value={form.name}
onChange={updateField("name")}
required
disabled={disableSave}
sx={{
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
"& label": { color: "#888" }
}}
/>
<TextField
label="Description"
value={form.description}
onChange={updateField("description")}
disabled={disableSave}
multiline
minRows={2}
sx={{
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
"& label": { color: "#888" }
}}
/>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 2 }}>
<FormControl sx={{ minWidth: 220 }} size="small" disabled={disableSave}>
<InputLabel sx={{ color: "#aaa" }}>Site</InputLabel>
<Select
value={form.site_id}
label="Site"
onChange={updateField("site_id")}
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
>
<MenuItem value="">(None)</MenuItem>
{sites.map((site) => (
<MenuItem key={site.id} value={String(site.id)}>
{site.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl sx={{ minWidth: 180 }} size="small" disabled={disableSave}>
<InputLabel sx={{ color: "#aaa" }}>Credential Type</InputLabel>
<Select
value={form.credential_type}
label="Credential Type"
onChange={updateField("credential_type")}
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
>
{CREDENTIAL_TYPES.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
))}
</Select>
</FormControl>
<FormControl sx={{ minWidth: 180 }} size="small" disabled={disableSave}>
<InputLabel sx={{ color: "#aaa" }}>Connection</InputLabel>
<Select
value={form.connection_type}
label="Connection"
onChange={updateField("connection_type")}
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
>
{CONNECTION_TYPES.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
))}
</Select>
</FormControl>
</Box>
<TextField
label="Username"
value={form.username}
onChange={updateField("username")}
disabled={disableSave}
sx={{
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
"& label": { color: "#888" }
}}
/>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<TextField
label="Password"
type="password"
value={form.password}
onChange={updateField("password")}
disabled={disableSave}
sx={{
flex: 1,
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
"& label": { color: "#888" }
}}
/>
{isEdit && currentCredentialFlags.hasPassword && !passwordDirty && !clearPassword && (
<Tooltip title="Clear stored password">
<IconButton size="small" onClick={() => setClearPassword(true)} sx={{ color: "#ff8080" }}>
<ClearIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
{isEdit && currentCredentialFlags.hasPassword && !passwordDirty && !clearPassword && (
<Typography sx={helperStyle}>Stored password will remain unless you change or clear it.</Typography>
)}
{clearPassword && (
<Typography sx={{ ...helperStyle, color: "#ffaaaa" }}>Password will be removed when saving.</Typography>
)}
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-start" }}>
<TextField
label="SSH Private Key"
value={form.private_key}
onChange={updateField("private_key")}
disabled={disableSave}
multiline
minRows={4}
maxRows={12}
sx={{
flex: 1,
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff", fontFamily: "monospace" },
"& label": { color: "#888" }
}}
/>
<Button
variant="outlined"
component="label"
startIcon={<UploadIcon />}
disabled={disableSave}
sx={{ alignSelf: "center", borderColor: "#58a6ff", color: "#58a6ff" }}
>
Upload
<input type="file" hidden accept=".pem,.key,.txt" onChange={handlePrivateKeyUpload} />
</Button>
{isEdit && currentCredentialFlags.hasPrivateKey && !privateKeyDirty && !clearPrivateKey && (
<Tooltip title="Clear stored private key">
<IconButton size="small" onClick={() => setClearPrivateKey(true)} sx={{ color: "#ff8080" }}>
<ClearIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
{isEdit && currentCredentialFlags.hasPrivateKey && !privateKeyDirty && !clearPrivateKey && (
<Typography sx={helperStyle}>Private key is stored. Upload or paste a new one to replace, or clear it.</Typography>
)}
{clearPrivateKey && (
<Typography sx={{ ...helperStyle, color: "#ffaaaa" }}>Private key will be removed when saving.</Typography>
)}
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<TextField
label="Private Key Passphrase"
type="password"
value={form.private_key_passphrase}
onChange={updateField("private_key_passphrase")}
disabled={disableSave}
sx={{
flex: 1,
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
"& label": { color: "#888" }
}}
/>
{isEdit && currentCredentialFlags.hasPrivateKeyPassphrase && !passphraseDirty && !clearPassphrase && (
<Tooltip title="Clear stored passphrase">
<IconButton size="small" onClick={() => setClearPassphrase(true)} sx={{ color: "#ff8080" }}>
<ClearIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
{isEdit && currentCredentialFlags.hasPrivateKeyPassphrase && !passphraseDirty && !clearPassphrase && (
<Typography sx={helperStyle}>A passphrase is stored for this key.</Typography>
)}
{clearPassphrase && (
<Typography sx={{ ...helperStyle, color: "#ffaaaa" }}>Key passphrase will be removed when saving.</Typography>
)}
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<FormControl sx={{ minWidth: 180 }} size="small" disabled={disableSave}>
<InputLabel sx={{ color: "#aaa" }}>Privilege Escalation</InputLabel>
<Select
value={form.become_method}
label="Privilege Escalation"
onChange={updateField("become_method")}
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
>
{BECOME_METHODS.map((opt) => (
<MenuItem key={opt.value || "none"} value={opt.value}>{opt.label}</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="Escalation Username"
value={form.become_username}
onChange={updateField("become_username")}
disabled={disableSave}
sx={{
flex: 1,
minWidth: 200,
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
"& label": { color: "#888" }
}}
/>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<TextField
label="Escalation Password"
type="password"
value={form.become_password}
onChange={updateField("become_password")}
disabled={disableSave}
sx={{
flex: 1,
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
"& label": { color: "#888" }
}}
/>
{isEdit && currentCredentialFlags.hasBecomePassword && !becomePasswordDirty && !clearBecomePassword && (
<Tooltip title="Clear stored escalation password">
<IconButton size="small" onClick={() => setClearBecomePassword(true)} sx={{ color: "#ff8080" }}>
<ClearIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
{isEdit && currentCredentialFlags.hasBecomePassword && !becomePasswordDirty && !clearBecomePassword && (
<Typography sx={helperStyle}>Escalation password is stored.</Typography>
)}
{clearBecomePassword && (
<Typography sx={{ ...helperStyle, color: "#ffaaaa" }}>Escalation password will be removed when saving.</Typography>
)}
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={handleCancel} sx={{ color: "#58a6ff" }} disabled={loading}>
Cancel
</Button>
<Button
onClick={handleSave}
variant="outlined"
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
disabled={disableSave}
>
{loading ? <CircularProgress size={18} sx={{ color: "#58a6ff" }} /> : "Save"}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,464 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Box,
Button,
IconButton,
Menu,
MenuItem,
Paper,
Typography,
CircularProgress,
} from "@mui/material";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import AddIcon from "@mui/icons-material/Add";
import RefreshIcon from "@mui/icons-material/Refresh";
import LockIcon from "@mui/icons-material/Lock";
import WifiIcon from "@mui/icons-material/Wifi";
import ComputerIcon from "@mui/icons-material/Computer";
import { AgGridReact } from "ag-grid-react";
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
import CredentialEditor from "./Credential_Editor.jsx";
import { ConfirmDeleteDialog } from "../Dialogs.jsx";
ModuleRegistry.registerModules([AllCommunityModule]);
const myTheme = themeQuartz.withParams({
accentColor: "#FFA6FF",
backgroundColor: "#1f2836",
browserColorScheme: "dark",
chromeBackgroundColor: {
ref: "foregroundColor",
mix: 0.07,
onto: "backgroundColor"
},
fontFamily: {
googleFont: "IBM Plex Sans"
},
foregroundColor: "#FFF",
headerFontSize: 14
});
const themeClassName = myTheme.themeName || "ag-theme-quartz";
const gridFontFamily = '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif';
const iconFontFamily = '"Quartz Regular"';
function formatTs(ts) {
if (!ts) return "-";
const date = new Date(Number(ts) * 1000);
if (Number.isNaN(date?.getTime())) return "-";
return `${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
}
function titleCase(value) {
if (!value) return "-";
const lower = String(value).toLowerCase();
return lower.replace(/(^|\s)\w/g, (c) => c.toUpperCase());
}
function connectionIcon(connection) {
const val = (connection || "").toLowerCase();
if (val === "ssh") return <LockIcon fontSize="small" sx={{ mr: 0.6, color: "#58a6ff" }} />;
if (val === "winrm") return <WifiIcon fontSize="small" sx={{ mr: 0.6, color: "#58a6ff" }} />;
return <ComputerIcon fontSize="small" sx={{ mr: 0.6, color: "#58a6ff" }} />;
}
export default function CredentialList({ isAdmin = false }) {
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [menuAnchor, setMenuAnchor] = useState(null);
const [menuRow, setMenuRow] = useState(null);
const [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState("create");
const [editingCredential, setEditingCredential] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const gridApiRef = useRef(null);
const openMenu = useCallback((event, row) => {
setMenuAnchor(event.currentTarget);
setMenuRow(row);
}, []);
const closeMenu = useCallback(() => {
setMenuAnchor(null);
setMenuRow(null);
}, []);
const connectionCellRenderer = useCallback((params) => {
const row = params.data || {};
const label = titleCase(row.connection_type);
return (
<Box sx={{ display: "flex", alignItems: "center", fontFamily: gridFontFamily }}>
{connectionIcon(row.connection_type)}
<Box component="span" sx={{ color: "#f5f7fa" }}>
{label}
</Box>
</Box>
);
}, []);
const actionCellRenderer = useCallback(
(params) => {
const row = params.data;
if (!row) return null;
const handleClick = (event) => {
event.preventDefault();
event.stopPropagation();
openMenu(event, row);
};
return (
<IconButton size="small" onClick={handleClick} sx={{ color: "#7db7ff" }}>
<MoreVertIcon fontSize="small" />
</IconButton>
);
},
[openMenu]
);
const columnDefs = useMemo(
() => [
{
headerName: "Name",
field: "name",
sort: "asc",
cellRenderer: (params) => params.value || "-"
},
{
headerName: "Credential Type",
field: "credential_type",
valueGetter: (params) => titleCase(params.data?.credential_type)
},
{
headerName: "Connection",
field: "connection_type",
cellRenderer: connectionCellRenderer
},
{
headerName: "Site",
field: "site_name",
cellRenderer: (params) => params.value || "-"
},
{
headerName: "Username",
field: "username",
cellRenderer: (params) => params.value || "-"
},
{
headerName: "Updated",
field: "updated_at",
valueGetter: (params) =>
formatTs(params.data?.updated_at || params.data?.created_at)
},
{
headerName: "",
field: "__actions__",
minWidth: 70,
maxWidth: 80,
sortable: false,
filter: false,
resizable: false,
suppressMenu: true,
cellRenderer: actionCellRenderer,
pinned: "right"
}
],
[actionCellRenderer, connectionCellRenderer]
);
const defaultColDef = useMemo(
() => ({
sortable: true,
filter: "agTextColumnFilter",
resizable: true,
flex: 1,
minWidth: 140,
cellStyle: {
display: "flex",
alignItems: "center",
color: "#f5f7fa",
fontFamily: gridFontFamily,
fontSize: "13px"
},
headerClass: "credential-grid-header"
}),
[]
);
const getRowId = useCallback(
(params) =>
params.data?.id ||
params.data?.name ||
params.data?.username ||
String(params.rowIndex ?? ""),
[]
);
const fetchCredentials = useCallback(async () => {
setLoading(true);
setError("");
try {
const resp = await fetch("/api/credentials");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const list = Array.isArray(data?.credentials) ? data.credentials : [];
list.sort((a, b) => String(a?.name || "").localeCompare(String(b?.name || "")));
setRows(list);
} catch (err) {
setRows([]);
setError(String(err.message || err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchCredentials();
}, [fetchCredentials]);
const handleCreate = () => {
setEditorMode("create");
setEditingCredential(null);
setEditorOpen(true);
};
const handleEdit = (row) => {
closeMenu();
setEditorMode("edit");
setEditingCredential(row);
setEditorOpen(true);
};
const handleDelete = (row) => {
closeMenu();
setDeleteTarget(row);
};
const doDelete = async () => {
if (!deleteTarget?.id) return;
setDeleteBusy(true);
try {
const resp = await fetch(`/api/credentials/${deleteTarget.id}`, { method: "DELETE" });
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data?.error || `HTTP ${resp.status}`);
}
setDeleteTarget(null);
await fetchCredentials();
} catch (err) {
setError(String(err.message || err));
} finally {
setDeleteBusy(false);
}
};
const handleEditorSaved = async () => {
setEditorOpen(false);
setEditingCredential(null);
await fetchCredentials();
};
const handleGridReady = useCallback((params) => {
gridApiRef.current = params.api;
}, []);
useEffect(() => {
const api = gridApiRef.current;
if (!api) return;
if (loading) {
api.showLoadingOverlay();
} else if (!rows.length) {
api.showNoRowsOverlay();
} else {
api.hideOverlay();
}
}, [loading, rows]);
if (!isAdmin) {
return (
<Paper sx={{ m: 2, p: 3, bgcolor: "#1e1e1e" }}>
<Typography variant="h6" sx={{ color: "#ff8080" }}>
Access denied
</Typography>
<Typography variant="body2" sx={{ color: "#bbb" }}>
You do not have permission to manage credentials.
</Typography>
</Paper>
);
}
return (
<>
<Paper
sx={{
m: 2,
p: 0,
bgcolor: "#1e1e1e",
fontFamily: gridFontFamily,
color: "#f5f7fa",
display: "flex",
flexDirection: "column",
flexGrow: 1,
minWidth: 0,
minHeight: 420
}}
elevation={2}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
p: 2,
borderBottom: "1px solid #2a2a2a"
}}
>
<Box>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0.3 }}>
Credentials
</Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}>
Stored credentials for remote automation tasks and Ansible playbook runs.
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1 }}>
<Button
variant="outlined"
size="small"
startIcon={<RefreshIcon />}
sx={{ borderColor: "#58a6ff", color: "#58a6ff" }}
onClick={fetchCredentials}
disabled={loading}
>
Refresh
</Button>
<Button
variant="contained"
size="small"
startIcon={<AddIcon />}
sx={{ bgcolor: "#58a6ff", color: "#0b0f19" }}
onClick={handleCreate}
>
New Credential
</Button>
</Box>
</Box>
{loading && (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
color: "#7db7ff",
px: 2,
py: 1.5,
borderBottom: "1px solid #2a2a2a"
}}
>
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
<Typography variant="body2">Loading credentials</Typography>
</Box>
)}
{error && (
<Box sx={{ px: 2, py: 1.5, color: "#ff8080", borderBottom: "1px solid #2a2a2a" }}>
<Typography variant="body2">{error}</Typography>
</Box>
)}
<Box
sx={{
flexGrow: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
mt: "10px",
px: 2,
pb: 2
}}
>
<Box
className={themeClassName}
sx={{
width: "100%",
height: "100%",
flexGrow: 1,
fontFamily: gridFontFamily,
"--ag-font-family": gridFontFamily,
"--ag-icon-font-family": iconFontFamily,
"--ag-row-border-style": "solid",
"--ag-row-border-color": "#2a2a2a",
"--ag-row-border-width": "1px",
"& .ag-root-wrapper": {
borderRadius: 1,
minHeight: 320
},
"& .ag-root, & .ag-header, & .ag-center-cols-container, & .ag-paging-panel": {
fontFamily: gridFontFamily
},
"& .ag-icon": {
fontFamily: iconFontFamily
}
}}
style={{ color: "#f5f7fa" }}
>
<AgGridReact
rowData={rows}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
animateRows
rowHeight={46}
headerHeight={44}
getRowId={getRowId}
overlayNoRowsTemplate="<span class='ag-overlay-no-rows-center'>No credentials have been created yet.</span>"
onGridReady={handleGridReady}
suppressCellFocus
theme={myTheme}
style={{
width: "100%",
height: "100%",
fontFamily: gridFontFamily,
"--ag-icon-font-family": iconFontFamily
}}
/>
</Box>
</Box>
</Paper>
<Menu
anchorEl={menuAnchor}
open={Boolean(menuAnchor)}
onClose={closeMenu}
elevation={2}
PaperProps={{ sx: { bgcolor: "#1f1f1f", color: "#f5f5f5" } }}
>
<MenuItem onClick={() => handleEdit(menuRow)}>Edit</MenuItem>
<MenuItem onClick={() => handleDelete(menuRow)} sx={{ color: "#ff8080" }}>
Delete
</MenuItem>
</Menu>
<CredentialEditor
open={editorOpen}
mode={editorMode}
credential={editingCredential}
onClose={() => {
setEditorOpen(false);
setEditingCredential(null);
}}
onSaved={handleEditorSaved}
/>
<ConfirmDeleteDialog
open={Boolean(deleteTarget)}
onCancel={() => setDeleteTarget(null)}
onConfirm={doDelete}
confirmDisabled={deleteBusy}
message={
deleteTarget
? `Delete credential '${deleteTarget.name || ""}'? Any jobs referencing it will require an update.`
: ""
}
/>
</>
);
}

View File

@@ -0,0 +1,325 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Box,
Button,
CircularProgress,
InputAdornment,
Link,
Paper,
TextField,
Typography
} from "@mui/material";
import RefreshIcon from "@mui/icons-material/Refresh";
import SaveIcon from "@mui/icons-material/Save";
import VisibilityIcon from "@mui/icons-material/Visibility";
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
const paperSx = {
m: 2,
p: 0,
bgcolor: "#1e1e1e",
color: "#f5f7fa",
display: "flex",
flexDirection: "column",
flexGrow: 1,
minWidth: 0,
minHeight: 320
};
const fieldSx = {
mt: 2,
"& .MuiOutlinedInput-root": {
bgcolor: "#181818",
color: "#f5f7fa",
"& fieldset": { borderColor: "#2a2a2a" },
"&:hover fieldset": { borderColor: "#58a6ff" },
"&.Mui-focused fieldset": { borderColor: "#58a6ff" }
},
"& .MuiInputLabel-root": { color: "#bbb" },
"& .MuiInputLabel-root.Mui-focused": { color: "#7db7ff" }
};
export default function GithubAPIToken({ isAdmin = false }) {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [token, setToken] = useState("");
const [inputValue, setInputValue] = useState("");
const [fetchError, setFetchError] = useState("");
const [showToken, setShowToken] = useState(false);
const [verification, setVerification] = useState({
message: "",
valid: null,
status: "",
rateLimit: null,
error: ""
});
const hydrate = useCallback(async () => {
setLoading(true);
setFetchError("");
try {
const resp = await fetch("/api/github/token");
const data = await resp.json();
if (!resp.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
const storedToken = typeof data?.token === "string" ? data.token : "";
setToken(storedToken);
setInputValue(storedToken);
setShowToken(false);
setVerification({
message: typeof data?.message === "string" ? data.message : "",
valid: data?.valid === true,
status: typeof data?.status === "string" ? data.status : "",
rateLimit: typeof data?.rate_limit === "number" ? data.rate_limit : null,
error: typeof data?.error === "string" ? data.error : ""
});
} catch (err) {
const message = err && typeof err.message === "string" ? err.message : String(err);
setFetchError(message);
setToken("");
setInputValue("");
setVerification({ message: "", valid: null, status: "", rateLimit: null, error: "" });
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!isAdmin) return;
hydrate();
}, [hydrate, isAdmin]);
const handleSave = useCallback(async () => {
setSaving(true);
setFetchError("");
try {
const resp = await fetch("/api/github/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: inputValue })
});
const data = await resp.json();
if (!resp.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
const storedToken = typeof data?.token === "string" ? data.token : "";
setToken(storedToken);
setInputValue(storedToken);
setShowToken(false);
setVerification({
message: typeof data?.message === "string" ? data.message : "",
valid: data?.valid === true,
status: typeof data?.status === "string" ? data.status : "",
rateLimit: typeof data?.rate_limit === "number" ? data.rate_limit : null,
error: typeof data?.error === "string" ? data.error : ""
});
} catch (err) {
const message = err && typeof err.message === "string" ? err.message : String(err);
setFetchError(message);
} finally {
setSaving(false);
}
}, [inputValue]);
const dirty = useMemo(() => inputValue !== token, [inputValue, token]);
const verificationMessage = useMemo(() => {
if (dirty) {
return { text: "Token has not been saved yet — Save to verify.", color: "#f0c36d" };
}
const message = verification.message || "";
if (!message) {
return { text: "", color: "#bbb" };
}
if (verification.valid) {
return { text: message, color: "#7dffac" };
}
if ((verification.status || "").toLowerCase() === "missing") {
return { text: message, color: "#bbb" };
}
return { text: message, color: "#ff8080" };
}, [dirty, verification]);
const toggleReveal = useCallback(() => {
setShowToken((prev) => !prev);
}, []);
if (!isAdmin) {
return (
<Paper sx={{ m: 2, p: 3, bgcolor: "#1e1e1e" }}>
<Typography variant="h6" sx={{ color: "#ff8080" }}>
Access denied
</Typography>
<Typography variant="body2" sx={{ color: "#bbb" }}>
You do not have permission to manage the GitHub API token.
</Typography>
</Paper>
);
}
return (
<Paper sx={paperSx} elevation={2}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
p: 2,
borderBottom: "1px solid #2a2a2a"
}}
>
<Box>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0.3 }}>
Github API Token
</Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}>
Using a Github "Personal Access Token" increases the Github API rate limits from 60/hr to 5,000/hr. This is important for production Borealis usage as it likes to hit its unauthenticated API limits sometimes despite my best efforts.
<br></br>Navigate to{' '}
<Link
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
sx={{ color: "#7db7ff" }}
>
https://github.com/settings/tokens
</Link>{' '}
&#10095; <b>Personal Access Tokens &#10095; Tokens (Classic) &#10095; Generate New Token &#10095; New Personal Access Token (Classic)</b>
</Typography>
<br></br>
<Typography variant="body2" sx={{ color: "#ccc" }}>
<Box component="span" sx={{ fontWeight: 600 }}>Note:</Box>{' '}
<Box component="code" sx={{ bgcolor: "#222", px: 0.75, py: 0.25, borderRadius: 1, fontSize: "0.85rem" }}>
Borealis Automation Platform
</Box>
</Typography>
<Typography variant="body2" sx={{ color: "#ccc" }}>
<Box component="span" sx={{ fontWeight: 600 }}>Scope:</Box>{' '}
<Box component="code" sx={{ bgcolor: "#222", px: 0.75, py: 0.25, borderRadius: 1, fontSize: "0.85rem" }}>
public_repo
</Box>
</Typography>
<Typography variant="body2" sx={{ color: "#ccc" }}>
<Box component="span" sx={{ fontWeight: 600 }}>Expiration:</Box>{' '}
<Box component="code" sx={{ bgcolor: "#222", px: 0.75, py: 0.25, borderRadius: 1, fontSize: "0.85rem" }}>
No Expiration
</Box>
</Typography>
</Box>
</Box>
<Box sx={{ px: 2, py: 2, display: "flex", flexDirection: "column", gap: 1.5 }}>
<TextField
label="Personal Access Token"
value={inputValue}
onChange={(event) => setInputValue(event.target.value)}
fullWidth
variant="outlined"
sx={fieldSx}
disabled={saving || loading}
type={showToken ? "text" : "password"}
InputProps={{
endAdornment: (
<InputAdornment
position="end"
sx={{ mr: -1, display: "flex", alignItems: "center", gap: 1 }}
>
<Button
variant="contained"
size="small"
onClick={toggleReveal}
disabled={loading || saving}
startIcon={showToken ? <VisibilityOffIcon /> : <VisibilityIcon />}
sx={{
bgcolor: "#3a3a3a",
color: "#f5f7fa",
minWidth: 96,
mr: 0.5,
"&:hover": { bgcolor: "#4a4a4a" }
}}
>
{showToken ? "Hide" : "Reveal"}
</Button>
<Button
variant="contained"
size="small"
onClick={handleSave}
disabled={saving || loading}
startIcon={!saving ? <SaveIcon /> : null}
sx={{
bgcolor: "#58a6ff",
color: "#0b0f19",
minWidth: 88,
mr: 1,
"&:hover": { bgcolor: "#7db7ff" }
}}
>
{saving ? <CircularProgress size={16} sx={{ color: "#0b0f19" }} /> : "Save"}
</Button>
</InputAdornment>
)
}}
/>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 2
}}
>
<Button
variant="outlined"
size="small"
startIcon={<RefreshIcon />}
sx={{ borderColor: "#58a6ff", color: "#58a6ff" }}
onClick={hydrate}
disabled={loading || saving}
>
Refresh
</Button>
{(verificationMessage.text || (!dirty && verification.rateLimit)) && (
<Typography
variant="body2"
sx={{
display: "inline-flex",
alignItems: "center",
color: verificationMessage.color || "#7db7ff",
textAlign: "right"
}}
>
{verificationMessage.text && `${verificationMessage.text} `}
{!dirty &&
verification.rateLimit &&
`- Hourly Request Rate Limit: ${verification.rateLimit.toLocaleString()}`}
</Typography>
)}
</Box>
{loading && (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
color: "#7db7ff",
px: 2,
py: 1.5,
borderBottom: "1px solid #2a2a2a"
}}
>
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
<Typography variant="body2">Loading token</Typography>
</Box>
)}
{fetchError && (
<Box sx={{ px: 2, py: 1.5, color: "#ff8080", borderBottom: "1px solid #2a2a2a" }}>
<Typography variant="body2">{fetchError}</Typography>
</Box>
)}
</Box>
</Paper>
);
}

View File

@@ -0,0 +1,680 @@
import React, { useEffect, useMemo, useState, useCallback } from "react";
import {
Paper,
Box,
Typography,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TableSortLabel,
IconButton,
Menu,
MenuItem,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
TextField,
Select,
FormControl,
InputLabel,
Checkbox,
Popover
} from "@mui/material";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import FilterListIcon from "@mui/icons-material/FilterList";
import { ConfirmDeleteDialog } from "../Dialogs.jsx";
/* ---------- Formatting helpers to keep this page in lockstep with Device_List ---------- */
const tablePaperSx = { m: 2, p: 0, bgcolor: "#1e1e1e" };
const tableSx = {
minWidth: 820,
"& th, & td": {
color: "#ddd",
borderColor: "#2a2a2a",
fontSize: 13,
py: 0.75
},
"& th .MuiTableSortLabel-root": { color: "#ddd" },
"& th .MuiTableSortLabel-root.Mui-active": { color: "#ddd" }
};
const menuPaperSx = { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" };
const filterFieldSx = {
input: { color: "#fff" },
minWidth: 220,
"& .MuiOutlinedInput-root": {
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
}
};
/* -------------------------------------------------------------------- */
function formatTs(tsSec) {
if (!tsSec) return "-";
const d = new Date((tsSec || 0) * 1000);
const date = d.toLocaleDateString("en-US", { month: "2-digit", day: "2-digit", year: "numeric" });
const time = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
return `${date} @ ${time}`;
}
async function sha512(text) {
const enc = new TextEncoder();
const data = enc.encode(text || "");
const buf = await crypto.subtle.digest("SHA-512", data);
const arr = Array.from(new Uint8Array(buf));
return arr.map((b) => b.toString(16).padStart(2, "0")).join("");
}
export default function UserManagement({ isAdmin = false }) {
const [rows, setRows] = useState([]); // {username, display_name, role, last_login}
const [orderBy, setOrderBy] = useState("username");
const [order, setOrder] = useState("asc");
const [menuAnchor, setMenuAnchor] = useState(null);
const [menuUser, setMenuUser] = useState(null);
const [resetOpen, setResetOpen] = useState(false);
const [resetTarget, setResetTarget] = useState(null);
const [newPassword, setNewPassword] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState({ username: "", display_name: "", password: "", role: "User" });
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [confirmChangeRoleOpen, setConfirmChangeRoleOpen] = useState(false);
const [changeRoleTarget, setChangeRoleTarget] = useState(null);
const [changeRoleNext, setChangeRoleNext] = useState(null);
const [warnOpen, setWarnOpen] = useState(false);
const [warnMessage, setWarnMessage] = useState("");
const [me, setMe] = useState(null);
const [mfaBusyUser, setMfaBusyUser] = useState(null);
const [resetMfaOpen, setResetMfaOpen] = useState(false);
const [resetMfaTarget, setResetMfaTarget] = useState(null);
// Columns and filters
const columns = useMemo(() => ([
{ id: "display_name", label: "Display Name" },
{ id: "username", label: "User Name" },
{ id: "last_login", label: "Last Login" },
{ id: "role", label: "User Role" },
{ id: "mfa_enabled", label: "MFA" },
{ id: "actions", label: "" }
]), []);
const [filters, setFilters] = useState({}); // id -> string
const [filterAnchor, setFilterAnchor] = useState(null); // { id, anchorEl }
const openFilter = (id) => (e) => setFilterAnchor({ id, anchorEl: e.currentTarget });
const closeFilter = () => setFilterAnchor(null);
const onFilterChange = (id) => (e) => setFilters((prev) => ({ ...prev, [id]: e.target.value }));
const fetchUsers = useCallback(async () => {
try {
const res = await fetch("/api/users", { credentials: "include" });
const data = await res.json();
if (Array.isArray(data?.users)) {
setRows(
data.users.map((u) => ({
...u,
mfa_enabled: u && typeof u.mfa_enabled !== "undefined" ? (u.mfa_enabled ? 1 : 0) : 0
}))
);
} else {
setRows([]);
}
} catch {
setRows([]);
}
}, []);
useEffect(() => {
if (!isAdmin) return;
(async () => {
try {
const resp = await fetch("/api/auth/me", { credentials: "include" });
if (resp.ok) {
const who = await resp.json();
setMe(who);
}
} catch {}
})();
fetchUsers();
}, [fetchUsers, isAdmin]);
const handleSort = (col) => {
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
else { setOrderBy(col); setOrder("asc"); }
};
const filteredSorted = useMemo(() => {
const applyFilters = (r) => {
for (const [key, val] of Object.entries(filters || {})) {
if (!val) continue;
const needle = String(val).toLowerCase();
let hay = "";
if (key === "last_login") hay = String(formatTs(r.last_login));
else hay = String(r[key] ?? "");
if (!hay.toLowerCase().includes(needle)) return false;
}
return true;
};
const dir = order === "asc" ? 1 : -1;
const arr = rows.filter(applyFilters);
arr.sort((a, b) => {
if (orderBy === "last_login") return ((a.last_login || 0) - (b.last_login || 0)) * dir;
if (orderBy === "mfa_enabled") return ((a.mfa_enabled ? 1 : 0) - (b.mfa_enabled ? 1 : 0)) * dir;
return String(a[orderBy] ?? "").toLowerCase()
.localeCompare(String(b[orderBy] ?? "").toLowerCase()) * dir;
});
return arr;
}, [rows, filters, orderBy, order]);
const openMenu = (evt, user) => {
setMenuAnchor({ mouseX: evt.clientX, mouseY: evt.clientY, anchorEl: evt.currentTarget });
setMenuUser(user);
};
const closeMenu = () => { setMenuAnchor(null); setMenuUser(null); };
const confirmDelete = (user) => {
if (!user) return;
if (me && user.username && String(me.username).toLowerCase() === String(user.username).toLowerCase()) {
setWarnMessage("You cannot delete the user you are currently logged in as.");
setWarnOpen(true);
return;
}
setDeleteTarget(user);
setConfirmDeleteOpen(true);
};
const doDelete = async () => {
const user = deleteTarget;
setConfirmDeleteOpen(false);
if (!user) return;
try {
const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}`, { method: "DELETE", credentials: "include" });
const data = await resp.json();
if (!resp.ok) {
setWarnMessage(data?.error || "Failed to delete user");
setWarnOpen(true);
return;
}
await fetchUsers();
} catch (e) {
console.error(e);
setWarnMessage("Failed to delete user");
setWarnOpen(true);
}
};
const openChangeRole = (user) => {
if (!user) return;
if (me && user.username && String(me.username).toLowerCase() === String(user.username).toLowerCase()) {
setWarnMessage("You cannot change your own role.");
setWarnOpen(true);
return;
}
const nextRole = (String(user.role || "User").toLowerCase() === "admin") ? "User" : "Admin";
setChangeRoleTarget(user);
setChangeRoleNext(nextRole);
setConfirmChangeRoleOpen(true);
};
const doChangeRole = async () => {
const user = changeRoleTarget;
const nextRole = changeRoleNext;
setConfirmChangeRoleOpen(false);
if (!user || !nextRole) return;
try {
const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}/role`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ role: nextRole })
});
const data = await resp.json();
if (!resp.ok) {
setWarnMessage(data?.error || "Failed to change role");
setWarnOpen(true);
return;
}
await fetchUsers();
} catch (e) {
console.error(e);
setWarnMessage("Failed to change role");
setWarnOpen(true);
}
};
const openResetMfa = (user) => {
if (!user) return;
setResetMfaTarget(user);
setResetMfaOpen(true);
};
const doResetMfa = async () => {
const user = resetMfaTarget;
setResetMfaOpen(false);
setResetMfaTarget(null);
if (!user) return;
const username = user.username;
const keepEnabled = Boolean(user.mfa_enabled);
setMfaBusyUser(username);
try {
const resp = await fetch(`/api/users/${encodeURIComponent(username)}/mfa`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ enabled: keepEnabled, reset_secret: true })
});
const data = await resp.json();
if (!resp.ok) {
setWarnMessage(data?.error || "Failed to reset MFA for this user.");
setWarnOpen(true);
return;
}
await fetchUsers();
} catch (err) {
console.error(err);
setWarnMessage("Failed to reset MFA for this user.");
setWarnOpen(true);
} finally {
setMfaBusyUser(null);
}
};
const toggleMfa = async (user, enabled) => {
if (!user) return;
const previous = Boolean(user.mfa_enabled);
const nextFlag = enabled ? 1 : 0;
setRows((prev) =>
prev.map((r) =>
String(r.username).toLowerCase() === String(user.username).toLowerCase()
? { ...r, mfa_enabled: nextFlag }
: r
)
);
setMfaBusyUser(user.username);
try {
const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}/mfa`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ enabled })
});
const data = await resp.json();
if (!resp.ok) {
setRows((prev) =>
prev.map((r) =>
String(r.username).toLowerCase() === String(user.username).toLowerCase()
? { ...r, mfa_enabled: previous ? 1 : 0 }
: r
)
);
setWarnMessage(data?.error || "Failed to update MFA settings.");
setWarnOpen(true);
return;
}
await fetchUsers();
} catch (e) {
console.error(e);
setRows((prev) =>
prev.map((r) =>
String(r.username).toLowerCase() === String(user.username).toLowerCase()
? { ...r, mfa_enabled: previous ? 1 : 0 }
: r
)
);
setWarnMessage("Failed to update MFA settings.");
setWarnOpen(true);
} finally {
setMfaBusyUser(null);
}
};
const doResetPassword = async () => {
const user = resetTarget;
if (!user) return;
const pw = newPassword || "";
if (!pw.trim()) return;
try {
const hash = await sha512(pw);
const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}/reset_password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ password_sha512: hash })
});
const data = await resp.json();
if (!resp.ok) {
alert(data?.error || "Failed to reset password");
return;
}
setResetOpen(false);
setResetTarget(null);
setNewPassword("");
} catch (e) {
console.error(e);
alert("Failed to reset password");
}
};
const openReset = (user) => {
if (!user) return;
setResetTarget(user);
setResetOpen(true);
setNewPassword("");
};
const openCreate = () => { setCreateOpen(true); setCreateForm({ username: "", display_name: "", password: "", role: "User" }); };
const doCreate = async () => {
const u = (createForm.username || "").trim();
const dn = (createForm.display_name || u).trim();
const pw = (createForm.password || "").trim();
const role = (createForm.role || "User");
if (!u || !pw) return;
try {
const hash = await sha512(pw);
const resp = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username: u, display_name: dn, password_sha512: hash, role })
});
const data = await resp.json();
if (!resp.ok) {
alert(data?.error || "Failed to create user");
return;
}
setCreateOpen(false);
await fetchUsers();
} catch (e) {
console.error(e);
alert("Failed to create user");
}
};
if (!isAdmin) return null;
return (
<>
<Paper sx={tablePaperSx} elevation={2}>
<Box sx={{ p: 2, pb: 1, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<Box>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
User Management
</Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}>
Manage authorized users of the Borealis Automation Platform.
</Typography>
</Box>
<Button
variant="outlined"
size="small"
onClick={openCreate}
sx={{ color: "#58a6ff", borderColor: "#58a6ff", textTransform: "none" }}
>
Create User
</Button>
</Box>
<Table size="small" sx={tableSx}>
<TableHead>
<TableRow>
{/* Leading checkbox gutter to match Devices table rhythm */}
<TableCell padding="checkbox" />
{columns.map((col) => (
<TableCell
key={col.id}
sortDirection={["actions"].includes(col.id) ? false : (orderBy === col.id ? order : false)}
>
{col.id !== "actions" ? (
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<TableSortLabel
active={orderBy === col.id}
direction={orderBy === col.id ? order : "asc"}
onClick={() => handleSort(col.id)}
>
{col.label}
</TableSortLabel>
<IconButton
size="small"
onClick={openFilter(col.id)}
sx={{ color: filters[col.id] ? "#58a6ff" : "#888" }}
>
<FilterListIcon fontSize="inherit" />
</IconButton>
</Box>
) : null}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{filteredSorted.map((u) => (
<TableRow key={u.username} hover>
{/* Body gutter to stay aligned with header */}
<TableCell padding="checkbox" />
<TableCell>{u.display_name || u.username}</TableCell>
<TableCell>{u.username}</TableCell>
<TableCell>{formatTs(u.last_login)}</TableCell>
<TableCell>{u.role || "User"}</TableCell>
<TableCell align="center">
<Checkbox
size="small"
checked={Boolean(u.mfa_enabled)}
disabled={Boolean(mfaBusyUser && String(mfaBusyUser).toLowerCase() === String(u.username).toLowerCase())}
onChange={(event) => {
event.stopPropagation();
toggleMfa(u, event.target.checked);
}}
onClick={(event) => event.stopPropagation()}
sx={{
color: "#888",
"&.Mui-checked": { color: "#58a6ff" }
}}
inputProps={{ "aria-label": `Toggle MFA for ${u.username}` }}
/>
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={(e) => openMenu(e, u)} sx={{ color: "#ccc" }}>
<MoreVertIcon fontSize="inherit" />
</IconButton>
</TableCell>
</TableRow>
))}
{filteredSorted.length === 0 && (
<TableRow>
<TableCell colSpan={columns.length + 1} sx={{ color: "#888" }}>
No users found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{/* Filter popover (styled to match Device_List) */}
<Popover
open={Boolean(filterAnchor)}
anchorEl={filterAnchor?.anchorEl || null}
onClose={closeFilter}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
PaperProps={{ sx: { bgcolor: "#1e1e1e", p: 1 } }}
>
{filterAnchor && (
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<TextField
autoFocus
size="small"
placeholder={`Filter ${columns.find((c) => c.id === filterAnchor.id)?.label || ""}`}
value={filters[filterAnchor.id] || ""}
onChange={onFilterChange(filterAnchor.id)}
onKeyDown={(e) => { if (e.key === "Escape") closeFilter(); }}
sx={filterFieldSx}
/>
<Button
variant="outlined"
size="small"
onClick={() => {
setFilters((prev) => ({ ...prev, [filterAnchor.id]: "" }));
closeFilter();
}}
sx={{ textTransform: "none", borderColor: "#555", color: "#bbb" }}
>
Clear
</Button>
</Box>
)}
</Popover>
<Menu
anchorEl={menuAnchor?.anchorEl}
open={Boolean(menuAnchor)}
onClose={closeMenu}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
PaperProps={{ sx: menuPaperSx }}
>
<MenuItem
disabled={me && menuUser && String(me.username).toLowerCase() === String(menuUser.username).toLowerCase()}
onClick={() => { const u = menuUser; closeMenu(); confirmDelete(u); }}
>
Delete User
</MenuItem>
<MenuItem onClick={() => { const u = menuUser; closeMenu(); openReset(u); }}>Reset Password</MenuItem>
<MenuItem
disabled={me && menuUser && String(me.username).toLowerCase() === String(menuUser.username).toLowerCase()}
onClick={() => { const u = menuUser; closeMenu(); openChangeRole(u); }}
>
Change Role
</MenuItem>
<MenuItem onClick={() => { const u = menuUser; closeMenu(); openResetMfa(u); }}>
Reset MFA
</MenuItem>
</Menu>
<Dialog open={resetOpen} onClose={() => setResetOpen(false)} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Reset Password</DialogTitle>
<DialogContent>
<DialogContentText sx={{ color: "#ccc" }}>
Enter a new password for {resetTarget?.username}.
</DialogContentText>
<TextField
autoFocus
margin="dense"
fullWidth
label="New Password"
type="password"
variant="outlined"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#2a2a2a",
color: "#ccc",
"& fieldset": { borderColor: "#444" },
"&:hover fieldset": { borderColor: "#666" }
},
label: { color: "#aaa" },
mt: 1
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => { setResetOpen(false); setResetTarget(null); }} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={doResetPassword} sx={{ color: "#58a6ff" }}>OK</Button>
</DialogActions>
</Dialog>
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Create User</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
fullWidth
label="Username"
variant="outlined"
value={createForm.username}
onChange={(e) => setCreateForm((p) => ({ ...p, username: e.target.value }))}
sx={{
"& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } },
label: { color: "#aaa" }, mt: 1
}}
/>
<TextField
margin="dense"
fullWidth
label="Display Name (optional)"
variant="outlined"
value={createForm.display_name}
onChange={(e) => setCreateForm((p) => ({ ...p, display_name: e.target.value }))}
sx={{
"& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } },
label: { color: "#aaa" }, mt: 1
}}
/>
<TextField
margin="dense"
fullWidth
label="Password"
type="password"
variant="outlined"
value={createForm.password}
onChange={(e) => setCreateForm((p) => ({ ...p, password: e.target.value }))}
sx={{
"& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } },
label: { color: "#aaa" }, mt: 1
}}
/>
<FormControl fullWidth sx={{ mt: 2 }}>
<InputLabel sx={{ color: "#aaa" }}>Role</InputLabel>
<Select
native
value={createForm.role}
onChange={(e) => setCreateForm((p) => ({ ...p, role: e.target.value }))}
sx={{
backgroundColor: "#2a2a2a",
color: "#ccc",
borderColor: "#444"
}}
>
<option value="User">User</option>
<option value="Admin">Admin</option>
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={() => setCreateOpen(false)} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={doCreate} sx={{ color: "#58a6ff" }}>Create</Button>
</DialogActions>
</Dialog>
</Paper>
<ConfirmDeleteDialog
open={confirmDeleteOpen}
message={`Are you sure you want to delete user '${deleteTarget?.username || ""}'?`}
onCancel={() => setConfirmDeleteOpen(false)}
onConfirm={doDelete}
/>
<ConfirmDeleteDialog
open={confirmChangeRoleOpen}
message={changeRoleTarget ? `Change role for '${changeRoleTarget.username}' to ${changeRoleNext}?` : ""}
onCancel={() => setConfirmChangeRoleOpen(false)}
onConfirm={doChangeRole}
/>
<ConfirmDeleteDialog
open={resetMfaOpen}
message={resetMfaTarget ? `Reset MFA enrollment for '${resetMfaTarget.username}'? This clears their existing authenticator.` : ""}
onCancel={() => { setResetMfaOpen(false); setResetMfaTarget(null); }}
onConfirm={doResetMfa}
/>
<ConfirmDeleteDialog
open={warnOpen}
message={warnMessage}
onCancel={() => setWarnOpen(false)}
onConfirm={() => setWarnOpen(false)}
/>
</>
);
}

View File

@@ -0,0 +1,73 @@
import React, { useEffect, useState } from "react";
import { Paper, Box, Typography, Button } from "@mui/material";
import { GitHub as GitHubIcon, InfoOutlined as InfoIcon } from "@mui/icons-material";
import { CreditsDialog } from "../Dialogs.jsx";
export default function ServerInfo({ isAdmin = false }) {
const [serverTime, setServerTime] = useState(null);
const [error, setError] = useState(null);
const [aboutOpen, setAboutOpen] = useState(false);
useEffect(() => {
if (!isAdmin) return;
let isMounted = true;
const fetchTime = async () => {
try {
const resp = await fetch('/api/server/time');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (isMounted) {
setServerTime(data?.display || data?.iso || null);
setError(null);
}
} catch (e) {
if (isMounted) setError(String(e));
}
};
fetchTime();
const id = setInterval(fetchTime, 60000); // update once per minute
return () => { isMounted = false; clearInterval(id); };
}, [isAdmin]);
if (!isAdmin) return null;
return (
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
<Box sx={{ p: 2 }}>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 1 }}>Server Info</Typography>
<Typography sx={{ color: '#aaa', mb: 1 }}>Basic server information will appear here for informative and debug purposes.</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'baseline' }}>
<Typography sx={{ color: '#ccc', fontWeight: 600, minWidth: 120 }}>Server Time</Typography>
<Typography sx={{ color: error ? '#ff6b6b' : '#ddd', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' }}>
{error ? `Error: ${error}` : (serverTime || 'Loading...')}
</Typography>
</Box>
<Box sx={{ mt: 3 }}>
<Typography variant="subtitle1" sx={{ color: "#58a6ff", mb: 1 }}>Project Links</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<Button
variant="outlined"
color="primary"
startIcon={<GitHubIcon />}
onClick={() => window.open("https://github.com/bunny-lab-io/Borealis", "_blank")}
sx={{ borderColor: '#3a3a3a', color: '#7db7ff' }}
>
GitHub Project
</Button>
<Button
variant="outlined"
color="inherit"
startIcon={<InfoIcon />}
onClick={() => setAboutOpen(true)}
sx={{ borderColor: '#3a3a3a', color: '#ddd' }}
>
About Borealis
</Button>
</Box>
</Box>
</Box>
<CreditsDialog open={aboutOpen} onClose={() => setAboutOpen(false)} />
</Paper>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,777 @@
import React, { useState, useEffect, useCallback } from "react";
import { Paper, Box, Typography, Menu, MenuItem, Button } from "@mui/material";
import { Folder as FolderIcon, Description as DescriptionIcon, Polyline as WorkflowsIcon, Code as ScriptIcon, MenuBook as BookIcon } from "@mui/icons-material";
import {
SimpleTreeView,
TreeItem,
useTreeViewApiRef
} from "@mui/x-tree-view";
import {
RenameWorkflowDialog,
RenameFolderDialog,
NewWorkflowDialog,
ConfirmDeleteDialog
} from "../Dialogs";
// Generic Island wrapper with large icon, stacked title/description, and actions on the right
const Island = ({ title, description, icon, actions, children, sx }) => (
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 2, bgcolor: '#1c1c1c', border: '1px solid #2a2a2a', mb: 1.5, ...(sx || {}) }}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{icon ? (
<Box
sx={{
color: '#58a6ff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 48,
mr: 1.0,
}}
>
{icon}
</Box>
) : null}
<Box>
<Typography
variant="caption"
sx={{ color: '#58a6ff', fontWeight: 400, fontSize: '14px', letterSpacing: 0.2 }}
>
{title}
</Typography>
{description ? (
<Typography variant="body2" sx={{ color: '#aaa' }}>
{description}
</Typography>
) : null}
</Box>
</Box>
{actions ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{actions}
</Box>
) : null}
</Box>
{children}
</Paper>
);
// ---------------- Workflows Island -----------------
const sortTree = (node) => {
if (!node || !Array.isArray(node.children)) return;
node.children.sort((a, b) => {
const aFolder = Boolean(a.isFolder);
const bFolder = Boolean(b.isFolder);
if (aFolder !== bFolder) return aFolder ? -1 : 1;
return String(a.label || "").localeCompare(String(b.label || ""), undefined, {
sensitivity: "base"
});
});
node.children.forEach(sortTree);
};
function buildWorkflowTree(workflows, folders) {
const map = {};
const rootNode = { id: "root", label: "Workflows", path: "", isFolder: true, children: [] };
map[rootNode.id] = rootNode;
(folders || []).forEach((f) => {
const parts = (f || "").split("/");
let children = rootNode.children;
let parentPath = "";
parts.forEach((part) => {
const path = parentPath ? `${parentPath}/${part}` : part;
let node = children.find((n) => n.id === path);
if (!node) {
node = { id: path, label: part, path, isFolder: true, children: [] };
children.push(node);
map[path] = node;
}
children = node.children;
parentPath = path;
});
});
(workflows || []).forEach((w) => {
const parts = (w.rel_path || "").split("/");
let children = rootNode.children;
let parentPath = "";
parts.forEach((part, idx) => {
const path = parentPath ? `${parentPath}/${part}` : part;
const isFile = idx === parts.length - 1;
let node = children.find((n) => n.id === path);
if (!node) {
node = {
id: path,
label: isFile ? ((w.tab_name && w.tab_name.trim()) || w.file_name) : part,
path,
isFolder: !isFile,
fileName: w.file_name,
workflow: isFile ? w : null,
children: []
};
children.push(node);
map[path] = node;
}
if (!isFile) {
children = node.children;
parentPath = path;
}
});
});
sortTree(rootNode);
return { root: [rootNode], map };
}
function WorkflowsIsland({ onOpenWorkflow }) {
const [tree, setTree] = useState([]);
const [nodeMap, setNodeMap] = useState({});
const [contextMenu, setContextMenu] = useState(null);
const [selectedNode, setSelectedNode] = useState(null);
const [renameValue, setRenameValue] = useState("");
const [renameOpen, setRenameOpen] = useState(false);
const [renameFolderOpen, setRenameFolderOpen] = useState(false);
const [folderDialogMode, setFolderDialogMode] = useState("rename");
const [newWorkflowOpen, setNewWorkflowOpen] = useState(false);
const [newWorkflowName, setNewWorkflowName] = useState("");
const [deleteOpen, setDeleteOpen] = useState(false);
const apiRef = useTreeViewApiRef();
const [dragNode, setDragNode] = useState(null);
const handleDrop = async (target) => {
if (!dragNode || !target.isFolder) return;
if (dragNode.path === target.path || target.path.startsWith(`${dragNode.path}/`)) {
setDragNode(null);
return;
}
const newPath = target.path ? `${target.path}/${dragNode.fileName}` : dragNode.fileName;
try {
await fetch("/api/assembly/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island: 'workflows', kind: 'file', path: dragNode.path, new_path: newPath })
});
loadTree();
} catch (err) {
console.error("Failed to move workflow:", err);
}
setDragNode(null);
};
const loadTree = useCallback(async () => {
try {
const resp = await fetch(`/api/assembly/list?island=workflows`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const { root, map } = buildWorkflowTree(data.items || [], data.folders || []);
setTree(root);
setNodeMap(map);
} catch (err) {
console.error("Failed to load workflows:", err);
setTree([]);
setNodeMap({});
}
}, []);
useEffect(() => { loadTree(); }, [loadTree]);
const handleContextMenu = (e, node) => {
e.preventDefault();
setSelectedNode(node);
setContextMenu(
contextMenu === null ? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 } : null
);
};
const handleRename = () => {
setContextMenu(null);
if (!selectedNode) return;
setRenameValue(selectedNode.label);
if (selectedNode.isFolder) {
setFolderDialogMode("rename");
setRenameFolderOpen(true);
} else setRenameOpen(true);
};
const handleEdit = () => {
setContextMenu(null);
if (selectedNode && !selectedNode.isFolder && onOpenWorkflow) {
onOpenWorkflow(selectedNode.workflow);
}
};
const handleDelete = () => {
setContextMenu(null);
if (!selectedNode) return;
setDeleteOpen(true);
};
const handleNewFolder = () => {
if (!selectedNode) return;
setContextMenu(null);
setFolderDialogMode("create");
setRenameValue("");
setRenameFolderOpen(true);
};
const handleNewWorkflow = () => {
if (!selectedNode) return;
setContextMenu(null);
setNewWorkflowName("");
setNewWorkflowOpen(true);
};
const saveRenameWorkflow = async () => {
if (!selectedNode) return;
try {
await fetch("/api/assembly/rename", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island: 'workflows', kind: 'file', path: selectedNode.path, new_name: renameValue })
});
loadTree();
} catch (err) {
console.error("Failed to rename workflow:", err);
}
setRenameOpen(false);
};
const saveRenameFolder = async () => {
try {
if (folderDialogMode === "rename" && selectedNode) {
await fetch("/api/assembly/rename", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island: 'workflows', kind: 'folder', path: selectedNode.path, new_name: renameValue })
});
} else {
const basePath = selectedNode ? selectedNode.path : "";
const newPath = basePath ? `${basePath}/${renameValue}` : renameValue;
await fetch("/api/assembly/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island: 'workflows', kind: 'folder', path: newPath })
});
}
loadTree();
} catch (err) {
console.error("Folder operation failed:", err);
}
setRenameFolderOpen(false);
};
const handleNodeSelect = (_event, itemId) => {
const node = nodeMap[itemId];
if (node && !node.isFolder && onOpenWorkflow) {
onOpenWorkflow(node.workflow);
}
};
const confirmDelete = async () => {
if (!selectedNode) return;
try {
if (selectedNode.isFolder) {
await fetch("/api/assembly/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island: 'workflows', kind: 'folder', path: selectedNode.path })
});
} else {
await fetch("/api/assembly/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island: 'workflows', kind: 'file', path: selectedNode.path })
});
}
loadTree();
} catch (err) {
console.error("Failed to delete:", err);
}
setDeleteOpen(false);
};
const renderItems = (nodes) =>
(nodes || []).map((n) => (
<TreeItem
key={n.id}
itemId={n.id}
label={
<Box
sx={{ display: "flex", alignItems: "center" }}
draggable={!n.isFolder}
onDragStart={() => !n.isFolder && setDragNode(n)}
onDragOver={(e) => { if (dragNode && n.isFolder) e.preventDefault(); }}
onDrop={(e) => { e.preventDefault(); handleDrop(n); }}
onContextMenu={(e) => handleContextMenu(e, n)}
>
{n.isFolder ? (
<FolderIcon sx={{ mr: 1, color: "#0475c2" }} />
) : (
<DescriptionIcon sx={{ mr: 1, color: "#0475c2" }} />
)}
<Typography sx={{ flexGrow: 1, color: "#e6edf3" }}>{n.label}</Typography>
</Box>
}
>
{n.children && n.children.length > 0 ? renderItems(n.children) : null}
</TreeItem>
));
const rootChildIds = tree[0]?.children?.map((c) => c.id) || [];
return (
<Island
title="Workflows"
description="Node-Based Automation Pipelines"
icon={<WorkflowsIcon sx={{ fontSize: 40 }} />}
actions={
<Button
size="small"
variant="outlined"
onClick={() => { setSelectedNode({ id: 'root', path: '', isFolder: true }); setNewWorkflowName(''); setNewWorkflowOpen(true); }}
sx={{
color: '#58a6ff',
borderColor: '#2f81f7',
textTransform: 'none',
'&:hover': { borderColor: '#58a6ff' }
}}
>
New Workflow
</Button>
}
>
<Box
sx={{ p: 1 }}
onDragOver={(e) => { if (dragNode) e.preventDefault(); }}
onDrop={(e) => { e.preventDefault(); handleDrop({ path: "", isFolder: true }); }}
>
<SimpleTreeView
key={rootChildIds.join(",")}
sx={{ color: "#e6edf3" }}
onNodeSelect={handleNodeSelect}
apiRef={apiRef}
defaultExpandedItems={["root", ...rootChildIds]}
>
{renderItems(tree)}
</SimpleTreeView>
</Box>
<Menu
open={contextMenu !== null}
onClose={() => setContextMenu(null)}
anchorReference="anchorPosition"
anchorPosition={contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined}
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
>
{selectedNode?.isFolder && (
<>
<MenuItem onClick={handleNewWorkflow}>New Workflow</MenuItem>
<MenuItem onClick={handleNewFolder}>New Subfolder</MenuItem>
{selectedNode.id !== "root" && (<MenuItem onClick={handleRename}>Rename</MenuItem>)}
{selectedNode.id !== "root" && (<MenuItem onClick={handleDelete}>Delete</MenuItem>)}
</>
)}
{!selectedNode?.isFolder && (
<>
<MenuItem onClick={handleEdit}>Edit</MenuItem>
<MenuItem onClick={handleRename}>Rename</MenuItem>
<MenuItem onClick={handleDelete}>Delete</MenuItem>
</>
)}
</Menu>
<RenameWorkflowDialog open={renameOpen} value={renameValue} onChange={setRenameValue} onCancel={() => setRenameOpen(false)} onSave={saveRenameWorkflow} />
<RenameFolderDialog open={renameFolderOpen} value={renameValue} onChange={setRenameValue} onCancel={() => setRenameFolderOpen(false)} onSave={saveRenameFolder} title={folderDialogMode === "rename" ? "Rename Folder" : "New Folder"} confirmText={folderDialogMode === "rename" ? "Save" : "Create"} />
<NewWorkflowDialog open={newWorkflowOpen} value={newWorkflowName} onChange={setNewWorkflowName} onCancel={() => setNewWorkflowOpen(false)} onCreate={() => { setNewWorkflowOpen(false); onOpenWorkflow && onOpenWorkflow(null, selectedNode?.path || "", newWorkflowName); }} />
<ConfirmDeleteDialog open={deleteOpen} message="If you delete this, there is no undo button, are you sure you want to proceed?" onCancel={() => setDeleteOpen(false)} onConfirm={confirmDelete} />
</Island>
);
}
// ---------------- Generic Scripts-like Islands (used for Scripts and Ansible) -----------------
function buildFileTree(rootLabel, items, folders) {
// Some backends (e.g. /api/scripts) return paths relative to
// the Assemblies root, which prefixes items with a top-level
// folder like "Scripts". Others (e.g. /api/ansible) already
// return paths relative to their specific root. Normalize by
// stripping a matching top-level segment so the UI shows
// "Scripts/<...>" rather than "Scripts/Scripts/<...>".
const normalize = (p) => {
const candidates = [
String(rootLabel || "").trim(),
String(rootLabel || "").replace(/\s+/g, "_")
].filter(Boolean);
const parts = String(p || "").replace(/\\/g, "/").split("/").filter(Boolean);
if (parts.length && candidates.includes(parts[0])) parts.shift();
return parts;
};
const map = {};
const rootNode = { id: "root", label: rootLabel, path: "", isFolder: true, children: [] };
map[rootNode.id] = rootNode;
(folders || []).forEach((f) => {
const parts = normalize(f);
let children = rootNode.children;
let parentPath = "";
parts.forEach((part) => {
const path = parentPath ? `${parentPath}/${part}` : part;
let node = children.find((n) => n.id === path);
if (!node) {
node = { id: path, label: part, path, isFolder: true, children: [] };
children.push(node);
map[path] = node;
}
children = node.children;
parentPath = path;
});
});
(items || []).forEach((s) => {
const parts = normalize(s?.rel_path);
let children = rootNode.children;
let parentPath = "";
parts.forEach((part, idx) => {
const path = parentPath ? `${parentPath}/${part}` : part;
const isFile = idx === parts.length - 1;
let node = children.find((n) => n.id === path);
if (!node) {
node = {
id: path,
label: isFile ? (s.name || s.display_name || s.file_name || part) : part,
path,
isFolder: !isFile,
fileName: s.file_name,
meta: isFile ? s : null,
children: []
};
children.push(node);
map[path] = node;
}
if (!isFile) {
children = node.children;
parentPath = path;
}
});
});
sortTree(rootNode);
return { root: [rootNode], map };
}
function ScriptsLikeIsland({
title,
description,
rootLabel,
baseApi, // e.g. '/api/scripts' or '/api/ansible'
newItemLabel = "New Script",
onEdit // (rel_path) => void
}) {
const [tree, setTree] = useState([]);
const [nodeMap, setNodeMap] = useState({});
const [contextMenu, setContextMenu] = useState(null);
const [selectedNode, setSelectedNode] = useState(null);
const [renameValue, setRenameValue] = useState("");
const [renameOpen, setRenameOpen] = useState(false);
const [renameFolderOpen, setRenameFolderOpen] = useState(false);
const [folderDialogMode, setFolderDialogMode] = useState("rename");
const [newItemOpen, setNewItemOpen] = useState(false);
const [newItemName, setNewItemName] = useState("");
const [deleteOpen, setDeleteOpen] = useState(false);
const apiRef = useTreeViewApiRef();
const [dragNode, setDragNode] = useState(null);
const island = React.useMemo(() => {
const b = String(baseApi || '').toLowerCase();
return b.endsWith('/api/ansible') ? 'ansible' : 'scripts';
}, [baseApi]);
const loadTree = useCallback(async () => {
try {
const resp = await fetch(`/api/assembly/list?island=${encodeURIComponent(island)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const { root, map } = buildFileTree(rootLabel, data.items || [], data.folders || []);
setTree(root);
setNodeMap(map);
} catch (err) {
console.error(`Failed to load ${title}:`, err);
setTree([]);
setNodeMap({});
}
}, [island, title, rootLabel]);
useEffect(() => { loadTree(); }, [loadTree]);
const handleContextMenu = (e, node) => {
e.preventDefault();
setSelectedNode(node);
setContextMenu(
contextMenu === null ? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 } : null
);
};
const handleDrop = async (target) => {
if (!dragNode || !target.isFolder) return;
if (dragNode.path === target.path || target.path.startsWith(`${dragNode.path}/`)) {
setDragNode(null);
return;
}
const newPath = target.path ? `${target.path}/${dragNode.fileName}` : dragNode.fileName;
try {
await fetch(`/api/assembly/move`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island, kind: 'file', path: dragNode.path, new_path: newPath })
});
loadTree();
} catch (err) {
console.error("Failed to move:", err);
}
setDragNode(null);
};
const handleNodeSelect = async (_e, itemId) => {
const node = nodeMap[itemId];
if (node && !node.isFolder) {
setContextMenu(null);
onEdit && onEdit(node.path);
}
};
const saveRenameFile = async () => {
try {
const payload = { island, kind: 'file', path: selectedNode.path, new_name: renameValue };
// preserve extension for scripts when no extension provided
if (selectedNode?.meta?.type) payload.type = selectedNode.meta.type;
const res = await fetch(`/api/assembly/rename`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
setRenameOpen(false);
loadTree();
} catch (err) {
console.error("Failed to rename file:", err);
setRenameOpen(false);
}
};
const saveRenameFolder = async () => {
try {
if (folderDialogMode === "rename" && selectedNode) {
await fetch(`/api/assembly/rename`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island, kind: 'folder', path: selectedNode.path, new_name: renameValue })
});
} else {
const basePath = selectedNode ? selectedNode.path : "";
const newPath = basePath ? `${basePath}/${renameValue}` : renameValue;
await fetch(`/api/assembly/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island, kind: 'folder', path: newPath })
});
}
setRenameFolderOpen(false);
loadTree();
} catch (err) {
console.error("Folder operation failed:", err);
setRenameFolderOpen(false);
}
};
const confirmDelete = async () => {
if (!selectedNode) return;
try {
if (selectedNode.isFolder) {
await fetch(`/api/assembly/delete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island, kind: 'folder', path: selectedNode.path })
});
} else {
await fetch(`/api/assembly/delete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island, kind: 'file', path: selectedNode.path })
});
}
setDeleteOpen(false);
loadTree();
} catch (err) {
console.error("Failed to delete:", err);
setDeleteOpen(false);
}
};
const createNewItem = () => {
const trimmedName = (newItemName || '').trim();
const folder = selectedNode?.isFolder
? selectedNode.path
: (selectedNode?.path?.split("/").slice(0, -1).join("/") || "");
const context = {
folder,
suggestedFileName: trimmedName,
defaultType: island === 'ansible' ? 'ansible' : 'powershell',
type: island === 'ansible' ? 'ansible' : 'powershell',
category: island === 'ansible' ? 'application' : 'script'
};
setNewItemOpen(false);
setNewItemName("");
onEdit && onEdit(null, context);
};
const renderItems = (nodes) =>
(nodes || []).map((n) => (
<TreeItem
key={n.id}
itemId={n.id}
label={
<Box
sx={{ display: "flex", alignItems: "center" }}
draggable={!n.isFolder}
onDragStart={() => !n.isFolder && setDragNode(n)}
onDragOver={(e) => { if (dragNode && n.isFolder) e.preventDefault(); }}
onDrop={(e) => { e.preventDefault(); handleDrop(n); }}
onContextMenu={(e) => handleContextMenu(e, n)}
onDoubleClick={() => { if (!n.isFolder) onEdit && onEdit(n.path); }}
>
{n.isFolder ? (
<FolderIcon sx={{ mr: 1, color: "#0475c2" }} />
) : (
<DescriptionIcon sx={{ mr: 1, color: "#0475c2" }} />
)}
<Typography sx={{ flexGrow: 1, color: "#e6edf3" }}>{n.label}</Typography>
</Box>
}
>
{n.children && n.children.length > 0 ? renderItems(n.children) : null}
</TreeItem>
));
const rootChildIds = tree[0]?.children?.map((c) => c.id) || [];
return (
<Island
title={title}
description={description}
icon={title === 'Scripts' ? <ScriptIcon sx={{ fontSize: 40 }} /> : <BookIcon sx={{ fontSize: 40 }} />}
actions={
<Button
size="small"
variant="outlined"
onClick={() => { setNewItemName(''); setNewItemOpen(true); }}
sx={{ color: '#58a6ff', borderColor: '#2f81f7', textTransform: 'none', '&:hover': { borderColor: '#58a6ff' } }}
>
{newItemLabel}
</Button>
}
>
<Box
sx={{ p: 1 }}
onDragOver={(e) => { if (dragNode) e.preventDefault(); }}
onDrop={(e) => { e.preventDefault(); handleDrop({ path: "", isFolder: true }); }}
>
<SimpleTreeView
key={rootChildIds.join(",")}
sx={{ color: "#e6edf3" }}
onNodeSelect={handleNodeSelect}
apiRef={apiRef}
defaultExpandedItems={["root", ...rootChildIds]}
>
{renderItems(tree)}
</SimpleTreeView>
</Box>
<Menu
open={contextMenu !== null}
onClose={() => setContextMenu(null)}
anchorReference="anchorPosition"
anchorPosition={contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined}
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
>
{selectedNode?.isFolder && (
<>
<MenuItem onClick={() => { setContextMenu(null); setNewItemOpen(true); }}>{newItemLabel}</MenuItem>
<MenuItem onClick={() => { setContextMenu(null); setFolderDialogMode("create"); setRenameValue(""); setRenameFolderOpen(true); }}>New Subfolder</MenuItem>
{selectedNode.id !== "root" && (<MenuItem onClick={() => { setContextMenu(null); setRenameValue(selectedNode.label); setRenameOpen(true); }}>Rename</MenuItem>)}
{selectedNode.id !== "root" && (<MenuItem onClick={() => { setContextMenu(null); setDeleteOpen(true); }}>Delete</MenuItem>)}
</>
)}
{!selectedNode?.isFolder && (
<>
<MenuItem onClick={() => { setContextMenu(null); onEdit && onEdit(selectedNode.path); }}>Edit</MenuItem>
<MenuItem onClick={() => { setContextMenu(null); setRenameValue(selectedNode.label); setRenameOpen(true); }}>Rename</MenuItem>
<MenuItem onClick={() => { setContextMenu(null); setDeleteOpen(true); }}>Delete</MenuItem>
</>
)}
</Menu>
{/* Simple inline dialogs using shared components */}
<RenameFolderDialog open={renameFolderOpen} value={renameValue} onChange={setRenameValue} onCancel={() => setRenameFolderOpen(false)} onSave={saveRenameFolder} title={folderDialogMode === "rename" ? "Rename Folder" : "New Folder"} confirmText={folderDialogMode === "rename" ? "Save" : "Create"} />
{/* File rename */}
<Paper component={(p) => <div {...p} />} sx={{ display: renameOpen ? 'block' : 'none' }}>
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999 }}>
<Paper sx={{ bgcolor: '#121212', color: '#fff', p: 2, minWidth: 360 }}>
<Typography variant="h6" sx={{ mb: 1 }}>Rename</Typography>
<input autoFocus value={renameValue} onChange={(e) => setRenameValue(e.target.value)} style={{ width: '100%', padding: 8, background: '#2a2a2a', color: '#ccc', border: '1px solid #444', borderRadius: 4 }} />
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
<Button onClick={() => setRenameOpen(false)} sx={{ color: '#58a6ff' }}>Cancel</Button>
<Button onClick={saveRenameFile} sx={{ color: '#58a6ff' }}>Save</Button>
</Box>
</Paper>
</div>
</Paper>
<Paper component={(p) => <div {...p} />} sx={{ display: newItemOpen ? 'block' : 'none' }}>
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999 }}>
<Paper sx={{ bgcolor: '#121212', color: '#fff', p: 2, minWidth: 360 }}>
<Typography variant="h6" sx={{ mb: 1 }}>{newItemLabel}</Typography>
<input autoFocus value={newItemName} onChange={(e) => setNewItemName(e.target.value)} placeholder="Name" style={{ width: '100%', padding: 8, background: '#2a2a2a', color: '#ccc', border: '1px solid #444', borderRadius: 4 }} />
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
<Button onClick={() => setNewItemOpen(false)} sx={{ color: '#58a6ff' }}>Cancel</Button>
<Button onClick={createNewItem} sx={{ color: '#58a6ff' }}>Create</Button>
</Box>
</Paper>
</div>
</Paper>
<ConfirmDeleteDialog open={deleteOpen} message="If you delete this, there is no undo button, are you sure you want to proceed?" onCancel={() => setDeleteOpen(false)} onConfirm={confirmDelete} />
</Island>
);
}
export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
return (
<Paper sx={{ m: 2, p: 0, bgcolor: '#1e1e1e' }} elevation={2}>
<Box sx={{ p: 2, pb: 1 }}>
<Typography variant="h6" sx={{ color: '#58a6ff', mb: 0 }}>Assemblies</Typography>
<Typography variant="body2" sx={{ color: '#aaa' }}>Collections of various types of components used to perform various automations upon targeted devices.</Typography>
</Box>
<Box sx={{ px: 2, pb: 2 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1.2fr 1fr 1fr' }, gap: 2 }}>
{/* Left: Workflows */}
<WorkflowsIsland onOpenWorkflow={onOpenWorkflow} />
{/* Middle: Scripts */}
<ScriptsLikeIsland
title="Scripts"
description="Powershell, Batch, and Bash Scripts"
rootLabel="Scripts"
baseApi="/api/scripts"
newItemLabel="New Script"
onEdit={(rel, ctx) => onOpenScript && onOpenScript(rel, 'scripts', ctx)}
/>
{/* Right: Ansible Playbooks */}
<ScriptsLikeIsland
title="Ansible Playbooks"
description="Declarative Instructions for Consistent Automation"
rootLabel="Ansible Playbooks"
baseApi="/api/ansible"
newItemLabel="New Playbook"
onEdit={(rel, ctx) => onOpenScript && onOpenScript(rel, 'ansible', ctx)}
/>
</Box>
</Box>
</Paper>
);
}

View File

@@ -0,0 +1,252 @@
/* ///////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Borealis.css
body {
font-family: "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
background-color: #0b0f19;
color: #f5f7fa;
}
/* ======================================= */
/* FLOW EDITOR */
/* ======================================= */
/* FlowEditor background container */
.flow-editor-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
/* Blue Gradient Overlay */
.flow-editor-container::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: linear-gradient(
to bottom,
rgba(9, 44, 68, 0.9) 0%,
rgba(30, 30, 30, 0) 45%,
rgba(30, 30, 30, 0) 75%,
rgba(9, 44, 68, 0.7) 100%
);
z-index: -1;
}
/* helper lines for snapping */
.helper-line {
position: absolute;
background: #0074ff;
z-index: 10;
pointer-events: none;
}
.helper-line-vertical {
width: 1px;
height: 100%;
}
.helper-line-horizontal {
height: 1px;
width: 100%;
}
/* ======================================= */
/* NODE SIDEBAR */
/* ======================================= */
/* Emphasize Drag & Drop Node Functionality */
.sidebar-button:hover {
background-color: #2a2a2a !important;
box-shadow: 0 0 5px rgba(88, 166, 255, 0.3);
cursor: grab;
}
/* ======================================= */
/* NODES */
/* ======================================= */
/* Borealis Node Styling */
.borealis-node {
background: linear-gradient(
to bottom,
#2c2c2c 60%,
#232323 100%
);
border: 1px solid #3a3a3a;
border-radius: 4px;
color: #ccc;
font-size: 12px;
min-width: 160px;
max-width: 260px;
position: relative;
box-shadow: 0 0 5px rgba(88, 166, 255, 0.15),
0 0 10px rgba(88, 166, 255, 0.15);
transition: box-shadow 0.3s ease-in-out;
}
.borealis-node::before {
content: "";
display: block;
position: absolute;
left: 0;
top: 0;
width: 3px;
height: 100%;
background: linear-gradient(
to bottom,
var(--borealis-accent, #58a6ff) 0%,
var(--borealis-accent-dark, #0475c2) 100%
);
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
}
.borealis-node-header {
background: #232323;
padding: 6px 10px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
font-weight: bold;
color: var(--borealis-title, #58a6ff);
font-size: 10px;
}
.borealis-node-content {
padding: 10px;
font-size: 9px;
}
.borealis-handle {
background: #58a6ff;
width: 10px;
height: 10px;
}
/* Global dark form inputs */
input,
select,
button {
background-color: #1d1d1d;
color: #ccc;
border: 1px solid #444;
font-size: 12px;
}
/* Label / Dark Text styling */
label {
color: #aaa;
font-size: 9px;
}
/* Node Header - Shows drag handle cursor */
.borealis-node-header {
cursor: grab;
}
/* Optional: when actively dragging */
.borealis-node-header:active {
cursor: grabbing;
}
/* Node Body - Just pointer, not draggable */
.borealis-node-content {
cursor: default;
}
/* ======================================= */
/* FLOW TABS */
/* ======================================= */
/* Multi-Tab Bar Adjustments */
.MuiTabs-root {
min-height: 32px !important;
}
.MuiTab-root {
min-height: 32px !important;
padding: 6px 12px !important;
color: #58a6ff !important;
text-transform: none !important;
}
/* Highlight tab on hover if it's not active */
.MuiTab-root:hover:not(.Mui-selected) {
background-color: #2C2C2C !important;
}
/* We rely on the TabIndicatorProps to show the underline highlight for active tabs. */
/* ======================================= */
/* REACT-SIMPLE-KEYBOARD */
/* ======================================= */
/* Make the keyboard max width like the demo */
.simple-keyboard {
max-width: 950px;
margin: 0 auto;
background: #181c23;
border-radius: 8px;
padding: 24px 24px 30px 24px;
box-shadow: 0 2px 24px 0 #000a;
}
/* Set dark background and color for the keyboard and its keys */
.simple-keyboard .hg-button {
background: #23262e;
color: #b0d0ff;
border: 1px solid #333;
font-size: 1.1em;
min-width: 48px;
min-height: 48px;
margin: 5px;
border-radius: 6px;
transition: background 0.1s, color 0.1s;
padding-top: 6px;
padding-left: 8px;
}
.simple-keyboard .hg-button[data-skbtn="space"] {
min-width: 380px;
}
.simple-keyboard .hg-button[data-skbtn="tab"],
.simple-keyboard .hg-button[data-skbtn="caps"],
.simple-keyboard .hg-button[data-skbtn="shift"],
.simple-keyboard .hg-button[data-skbtn="enter"],
.simple-keyboard .hg-button[data-skbtn="bksp"] {
min-width: 82px;
}
.simple-keyboard .hg-button:hover {
background: #58a6ff;
color: #000;
border-color: #58a6ff;
}
/* Make sure rows aren't squashed */
.simple-keyboard .hg-row {
display: flex !important;
flex-flow: row wrap;
justify-content: center;
margin-bottom: 10px;
}
/* Remove any unwanted shrink/stretch */
.simple-keyboard .hg-button {
flex: 0 0 auto;
}
/* Optional: on-screen keyboard input field (if you ever show it) */
input[type="text"].simple-keyboard-input {
width: 100%;
height: 48px;
padding: 10px 20px;
font-size: 20px;
border: none;
box-sizing: border-box;
background: #181818;
color: #f5f7fa;
border-radius: 6px;
margin-bottom: 20px;
}

View File

@@ -0,0 +1,219 @@
import React, { useEffect, useState } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
MenuItem,
Typography
} from "@mui/material";
const TYPE_OPTIONS = [
{ value: "ssh", label: "SSH" },
{ value: "winrm", label: "WinRM" }
];
const initialForm = {
hostname: "",
address: "",
description: "",
operating_system: ""
};
export default function AddDevice({
open,
onClose,
defaultType = null,
onCreated
}) {
const [type, setType] = useState(defaultType || "ssh");
const [form, setForm] = useState(initialForm);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (open) {
setType(defaultType || "ssh");
setForm(initialForm);
setError("");
}
}, [open, defaultType]);
const handleClose = () => {
if (submitting) return;
onClose && onClose();
};
const handleChange = (field) => (event) => {
const value = event.target.value;
setForm((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async () => {
if (submitting) return;
const trimmedHostname = form.hostname.trim();
const trimmedAddress = form.address.trim();
if (!trimmedHostname) {
setError("Hostname is required.");
return;
}
if (!type) {
setError("Select a device type.");
return;
}
if (!trimmedAddress) {
setError("Address is required.");
return;
}
setSubmitting(true);
setError("");
const payload = {
hostname: trimmedHostname,
address: trimmedAddress,
description: form.description.trim(),
operating_system: form.operating_system.trim()
};
const apiBase = type === "winrm" ? "/api/winrm_devices" : "/api/ssh_devices";
try {
const resp = await fetch(apiBase, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
onCreated && onCreated(data.device || null);
onClose && onClose();
} catch (err) {
setError(String(err.message || err));
} finally {
setSubmitting(false);
}
};
const dialogTitle = defaultType
? `Add ${defaultType.toUpperCase()} Device`
: "Add Device";
const typeLabel = (TYPE_OPTIONS.find((opt) => opt.value === type) || TYPE_OPTIONS[0]).label;
return (
<Dialog
open={open}
onClose={handleClose}
fullWidth
maxWidth="sm"
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}>
{!defaultType && (
<TextField
select
label="Device Type"
size="small"
value={type}
onChange={(e) => setType(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
>
{TYPE_OPTIONS.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</TextField>
)}
<TextField
label="Hostname"
value={form.hostname}
onChange={handleChange("hostname")}
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
helperText="Name used inside Borealis."
/>
<TextField
label={`${typeLabel} Address`}
value={form.address}
onChange={handleChange("address")}
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
helperText="IP or FQDN reachable from the Borealis server."
/>
<TextField
label="Description"
value={form.description}
onChange={handleChange("description")}
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
/>
<TextField
label="Operating System"
value={form.operating_system}
onChange={handleChange("operating_system")}
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
/>
{error && (
<Typography variant="body2" sx={{ color: "#ff8080" }}>
{error}
</Typography>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={handleClose} sx={{ color: "#58a6ff" }} disabled={submitting}>
Cancel
</Button>
<Button
onClick={handleSubmit}
variant="outlined"
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
disabled={submitting}
>
{submitting ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,13 @@
import React from "react";
import DeviceList from "./Device_List.jsx";
export default function AgentDevices(props) {
return (
<DeviceList
{...props}
filterMode="agent"
title="Agent Devices"
showAddButton={false}
/>
);
}

View File

@@ -0,0 +1,505 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/Server/WebUI/src/Admin/Device_Approvals.jsx
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
FormControl,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import {
CheckCircleOutline as ApproveIcon,
HighlightOff as DenyIcon,
Refresh as RefreshIcon,
Security as SecurityIcon,
} from "@mui/icons-material";
const STATUS_OPTIONS = [
{ value: "all", label: "All" },
{ value: "pending", label: "Pending" },
{ value: "approved", label: "Approved" },
{ value: "completed", label: "Completed" },
{ value: "denied", label: "Denied" },
{ value: "expired", label: "Expired" },
];
const statusChipColor = {
pending: "warning",
approved: "info",
completed: "success",
denied: "default",
expired: "default",
};
const formatDateTime = (value) => {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
};
const formatFingerprint = (fp) => {
if (!fp) return "—";
const normalized = fp.replace(/[^a-f0-9]/gi, "").toLowerCase();
if (!normalized) return fp;
return normalized.match(/.{1,4}/g)?.join(" ") ?? normalized;
};
const normalizeStatus = (status) => {
if (!status) return "pending";
if (status === "completed") return "completed";
return status.toLowerCase();
};
function DeviceApprovals() {
const [approvals, setApprovals] = useState([]);
const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [feedback, setFeedback] = useState(null);
const [guidInputs, setGuidInputs] = useState({});
const [actioningId, setActioningId] = useState(null);
const [conflictPrompt, setConflictPrompt] = useState(null);
const loadApprovals = useCallback(async () => {
setLoading(true);
setError("");
try {
const query = statusFilter === "all" ? "" : `?status=${encodeURIComponent(statusFilter)}`;
const resp = await fetch(`/api/admin/device-approvals${query}`, { credentials: "include" });
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new Error(body.error || `Request failed (${resp.status})`);
}
const data = await resp.json();
setApprovals(Array.isArray(data.approvals) ? data.approvals : []);
} catch (err) {
setError(err.message || "Unable to load device approvals");
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => {
loadApprovals();
}, [loadApprovals]);
const dedupedApprovals = useMemo(() => {
const normalized = approvals
.map((record) => ({ ...record, status: normalizeStatus(record.status) }))
.sort((a, b) => {
const left = new Date(a.created_at || 0).getTime();
const right = new Date(b.created_at || 0).getTime();
return left - right;
});
if (statusFilter !== "pending") {
return normalized;
}
const seen = new Set();
const unique = [];
for (const record of normalized) {
const key = record.ssl_key_fingerprint_claimed || record.hostname_claimed || record.id;
if (seen.has(key)) continue;
seen.add(key);
unique.push(record);
}
return unique;
}, [approvals, statusFilter]);
const handleGuidChange = useCallback((id, value) => {
setGuidInputs((prev) => ({ ...prev, [id]: value }));
}, []);
const submitApproval = useCallback(
async (record, overrides = {}) => {
if (!record?.id) return;
setActioningId(record.id);
setFeedback(null);
setError("");
try {
const manualGuid = (guidInputs[record.id] || "").trim();
const payload = {};
const overrideGuidRaw = overrides.guid;
let overrideGuid = "";
if (typeof overrideGuidRaw === "string") {
overrideGuid = overrideGuidRaw.trim();
} else if (overrideGuidRaw != null) {
overrideGuid = String(overrideGuidRaw).trim();
}
if (overrideGuid) {
payload.guid = overrideGuid;
} else if (manualGuid) {
payload.guid = manualGuid;
}
const resolutionRaw = overrides.conflictResolution || overrides.resolution;
if (typeof resolutionRaw === "string" && resolutionRaw.trim()) {
payload.conflict_resolution = resolutionRaw.trim().toLowerCase();
}
const resp = await fetch(`/api/admin/device-approvals/${encodeURIComponent(record.id)}/approve`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.keys(payload).length ? payload : {}),
});
const body = await resp.json().catch(() => ({}));
if (!resp.ok) {
if (resp.status === 409 && body.error === "conflict_resolution_required") {
const conflict = record.hostname_conflict;
const fallbackAlternate =
record.alternate_hostname ||
(record.hostname_claimed ? `${record.hostname_claimed}-1` : "");
if (conflict) {
setConflictPrompt({
record,
conflict,
alternate: fallbackAlternate || "",
});
}
return;
}
throw new Error(body.error || `Approval failed (${resp.status})`);
}
const appliedResolution = (body.conflict_resolution || payload.conflict_resolution || "").toLowerCase();
let successMessage = "Enrollment approved";
if (appliedResolution === "overwrite") {
successMessage = "Enrollment approved; existing device overwritten";
} else if (appliedResolution === "coexist") {
successMessage = "Enrollment approved; devices will co-exist";
} else if (appliedResolution === "auto_merge_fingerprint") {
successMessage = "Enrollment approved; device reconnected with its existing identity";
}
setFeedback({ type: "success", message: successMessage });
await loadApprovals();
} catch (err) {
setFeedback({ type: "error", message: err.message || "Unable to approve request" });
} finally {
setActioningId(null);
}
},
[guidInputs, loadApprovals]
);
const startApprove = useCallback(
(record) => {
if (!record?.id) return;
const status = normalizeStatus(record.status);
if (status !== "pending") return;
const manualGuid = (guidInputs[record.id] || "").trim();
const conflict = record.hostname_conflict;
const requiresPrompt = Boolean(conflict?.requires_prompt ?? record.conflict_requires_prompt);
if (requiresPrompt && !manualGuid) {
const fallbackAlternate =
record.alternate_hostname ||
(record.hostname_claimed ? `${record.hostname_claimed}-1` : "");
setConflictPrompt({
record,
conflict,
alternate: fallbackAlternate || "",
});
return;
}
submitApproval(record);
},
[guidInputs, submitApproval]
);
const handleConflictCancel = useCallback(() => {
setConflictPrompt(null);
}, []);
const handleConflictOverwrite = useCallback(() => {
if (!conflictPrompt?.record) {
setConflictPrompt(null);
return;
}
const { record, conflict } = conflictPrompt;
setConflictPrompt(null);
const conflictGuid = conflict?.guid != null ? String(conflict.guid).trim() : "";
submitApproval(record, {
guid: conflictGuid,
conflictResolution: "overwrite",
});
}, [conflictPrompt, submitApproval]);
const handleConflictCoexist = useCallback(() => {
if (!conflictPrompt?.record) {
setConflictPrompt(null);
return;
}
const { record } = conflictPrompt;
setConflictPrompt(null);
submitApproval(record, {
conflictResolution: "coexist",
});
}, [conflictPrompt, submitApproval]);
const conflictRecord = conflictPrompt?.record;
const conflictInfo = conflictPrompt?.conflict;
const conflictHostname = conflictRecord?.hostname_claimed || conflictRecord?.hostname || "";
const conflictSiteName = conflictInfo?.site_name || "";
const conflictSiteDescriptor = conflictInfo
? conflictSiteName
? `under site ${conflictSiteName}`
: "under site (not assigned)"
: "under site (not assigned)";
const conflictAlternate =
conflictPrompt?.alternate ||
(conflictHostname ? `${conflictHostname}-1` : "hostname-1");
const conflictGuidDisplay = conflictInfo?.guid || "";
const handleDeny = useCallback(
async (record) => {
if (!record?.id) return;
const confirmDeny = window.confirm("Deny this enrollment request?");
if (!confirmDeny) return;
setActioningId(record.id);
setFeedback(null);
setError("");
try {
const resp = await fetch(`/api/admin/device-approvals/${encodeURIComponent(record.id)}/deny`, {
method: "POST",
credentials: "include",
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new Error(body.error || `Deny failed (${resp.status})`);
}
setFeedback({ type: "success", message: "Enrollment denied" });
await loadApprovals();
} catch (err) {
setFeedback({ type: "error", message: err.message || "Unable to deny request" });
} finally {
setActioningId(null);
}
},
[loadApprovals]
);
return (
<Box sx={{ p: 3, display: "flex", flexDirection: "column", gap: 3 }}>
<Stack direction="row" alignItems="center" spacing={2}>
<SecurityIcon color="primary" />
<Typography variant="h5">Device Approval Queue</Typography>
</Stack>
<Paper sx={{ p: 2, display: "flex", flexDirection: "column", gap: 2 }}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel id="approval-status-filter-label">Status</InputLabel>
<Select
labelId="approval-status-filter-label"
label="Status"
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
>
{STATUS_OPTIONS.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={loadApprovals}
disabled={loading}
>
Refresh
</Button>
</Stack>
{feedback ? (
<Alert severity={feedback.type} variant="outlined" onClose={() => setFeedback(null)}>
{feedback.message}
</Alert>
) : null}
{error ? (
<Alert severity="error" variant="outlined">
{error}
</Alert>
) : null}
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 480 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Status</TableCell>
<TableCell>Hostname</TableCell>
<TableCell>Fingerprint</TableCell>
<TableCell>Enrollment Code</TableCell>
<TableCell>Created</TableCell>
<TableCell>Updated</TableCell>
<TableCell>Approved By</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} align="center">
<Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
<CircularProgress size={20} />
<Typography variant="body2">Loading approvals</Typography>
</Stack>
</TableCell>
</TableRow>
) : dedupedApprovals.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center">
<Typography variant="body2" color="text.secondary">
No enrollment requests match this filter.
</Typography>
</TableCell>
</TableRow>
) : (
dedupedApprovals.map((record) => {
const status = normalizeStatus(record.status);
const showActions = status === "pending";
const guidValue = guidInputs[record.id] || "";
const approverDisplay = record.approved_by_username || record.approved_by_user_id;
return (
<TableRow hover key={record.id}>
<TableCell>
<Chip
size="small"
label={status}
color={statusChipColor[status] || "default"}
variant="outlined"
/>
</TableCell>
<TableCell>{record.hostname_claimed || "—"}</TableCell>
<TableCell sx={{ fontFamily: "monospace", whiteSpace: "nowrap" }}>
{formatFingerprint(record.ssl_key_fingerprint_claimed)}
</TableCell>
<TableCell sx={{ fontFamily: "monospace" }}>
{record.enrollment_code_id || "—"}
</TableCell>
<TableCell>{formatDateTime(record.created_at)}</TableCell>
<TableCell>{formatDateTime(record.updated_at)}</TableCell>
<TableCell>{approverDisplay || "—"}</TableCell>
<TableCell align="right">
{showActions ? (
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems="center">
<TextField
size="small"
label="Optional GUID"
placeholder="Leave empty to auto-generate"
value={guidValue}
onChange={(event) => handleGuidChange(record.id, event.target.value)}
sx={{ minWidth: 200 }}
/>
<Stack direction="row" spacing={1}>
<Tooltip title="Approve enrollment">
<span>
<IconButton
color="success"
onClick={() => startApprove(record)}
disabled={actioningId === record.id}
>
{actioningId === record.id ? (
<CircularProgress color="success" size={20} />
) : (
<ApproveIcon fontSize="small" />
)}
</IconButton>
</span>
</Tooltip>
<Tooltip title="Deny enrollment">
<span>
<IconButton
color="error"
onClick={() => handleDeny(record)}
disabled={actioningId === record.id}
>
<DenyIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Stack>
</Stack>
) : (
<Typography variant="body2" color="text.secondary">
No actions available
</Typography>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
<Dialog
open={Boolean(conflictPrompt)}
onClose={handleConflictCancel}
maxWidth="sm"
fullWidth
>
<DialogTitle>Hostname Conflict</DialogTitle>
<DialogContent dividers>
<Stack spacing={2}>
<DialogContentText>
{conflictHostname
? `Device ${conflictHostname} already exists in the database ${conflictSiteDescriptor}.`
: `A device with this hostname already exists in the database ${conflictSiteDescriptor}.`}
</DialogContentText>
<DialogContentText>
Do you want this device to overwrite the existing device, or allow both to co-exist?
</DialogContentText>
<DialogContentText>
{`Device will be renamed ${conflictAlternate} if you choose to allow both to co-exist.`}
</DialogContentText>
{conflictGuidDisplay ? (
<Typography variant="body2" color="text.secondary">
Existing device GUID: {conflictGuidDisplay}
</Typography>
) : null}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={handleConflictCancel}>Cancel</Button>
<Button onClick={handleConflictCoexist} color="info" variant="outlined">
Allow Both
</Button>
<Button
onClick={handleConflictOverwrite}
color="primary"
variant="contained"
disabled={!conflictGuidDisplay}
>
Overwrite Existing
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
export default React.memo(DeviceApprovals);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,371 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/Server/WebUI/src/Admin/Enrollment_Codes.jsx
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
FormControl,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tooltip,
Typography,
} from "@mui/material";
import {
ContentCopy as CopyIcon,
DeleteOutline as DeleteIcon,
Refresh as RefreshIcon,
Key as KeyIcon,
} from "@mui/icons-material";
const TTL_PRESETS = [
{ value: 1, label: "1 hour" },
{ value: 3, label: "3 hours" },
{ value: 6, label: "6 hours" },
{ value: 12, label: "12 hours" },
{ value: 24, label: "24 hours" },
];
const statusColor = {
active: "success",
used: "default",
expired: "warning",
};
const maskCode = (code) => {
if (!code) return "—";
const parts = code.split("-");
if (parts.length <= 1) {
const prefix = code.slice(0, 4);
return `${prefix}${"•".repeat(Math.max(0, code.length - prefix.length))}`;
}
return parts
.map((part, idx) => (idx === 0 || idx === parts.length - 1 ? part : "•".repeat(part.length)))
.join("-");
};
const formatDateTime = (value) => {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
};
const determineStatus = (record) => {
if (!record) return "expired";
const maxUses = Number.isFinite(record?.max_uses) ? record.max_uses : 1;
const useCount = Number.isFinite(record?.use_count) ? record.use_count : 0;
if (useCount >= Math.max(1, maxUses || 1)) return "used";
if (!record.expires_at) return "expired";
const expires = new Date(record.expires_at);
if (Number.isNaN(expires.getTime())) return "expired";
return expires.getTime() > Date.now() ? "active" : "expired";
};
function EnrollmentCodes() {
const [codes, setCodes] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [feedback, setFeedback] = useState(null);
const [statusFilter, setStatusFilter] = useState("all");
const [ttlHours, setTtlHours] = useState(6);
const [generating, setGenerating] = useState(false);
const [maxUses, setMaxUses] = useState(2);
const filteredCodes = useMemo(() => {
if (statusFilter === "all") return codes;
return codes.filter((code) => determineStatus(code) === statusFilter);
}, [codes, statusFilter]);
const fetchCodes = useCallback(async () => {
setLoading(true);
setError("");
try {
const query = statusFilter === "all" ? "" : `?status=${encodeURIComponent(statusFilter)}`;
const resp = await fetch(`/api/admin/enrollment-codes${query}`, {
credentials: "include",
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new Error(body.error || `Request failed (${resp.status})`);
}
const data = await resp.json();
setCodes(Array.isArray(data.codes) ? data.codes : []);
} catch (err) {
setError(err.message || "Unable to load enrollment codes");
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => {
fetchCodes();
}, [fetchCodes]);
const handleGenerate = useCallback(async () => {
setGenerating(true);
setError("");
try {
const resp = await fetch("/api/admin/enrollment-codes", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ttl_hours: ttlHours, max_uses: maxUses }),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new Error(body.error || `Request failed (${resp.status})`);
}
const created = await resp.json();
setFeedback({ type: "success", message: `Installer code ${created.code} created` });
await fetchCodes();
} catch (err) {
setFeedback({ type: "error", message: err.message || "Failed to create code" });
} finally {
setGenerating(false);
}
}, [fetchCodes, ttlHours, maxUses]);
const handleDelete = useCallback(
async (id) => {
if (!id) return;
const confirmDelete = window.confirm("Delete this unused installer code?");
if (!confirmDelete) return;
setError("");
try {
const resp = await fetch(`/api/admin/enrollment-codes/${encodeURIComponent(id)}`, {
method: "DELETE",
credentials: "include",
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new Error(body.error || `Request failed (${resp.status})`);
}
setFeedback({ type: "success", message: "Installer code deleted" });
await fetchCodes();
} catch (err) {
setFeedback({ type: "error", message: err.message || "Failed to delete code" });
}
},
[fetchCodes]
);
const handleCopy = useCallback((code) => {
if (!code) return;
try {
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(code);
setFeedback({ type: "success", message: "Code copied to clipboard" });
} else {
const textArea = document.createElement("textarea");
textArea.value = code;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
setFeedback({ type: "success", message: "Code copied to clipboard" });
}
} catch (err) {
setFeedback({ type: "error", message: err.message || "Unable to copy code" });
}
}, []);
const renderStatusChip = (record) => {
const status = determineStatus(record);
return <Chip size="small" label={status} color={statusColor[status] || "default"} variant="outlined" />;
};
return (
<Box sx={{ p: 3, display: "flex", flexDirection: "column", gap: 3 }}>
<Stack direction="row" alignItems="center" spacing={2}>
<KeyIcon color="primary" />
<Typography variant="h5">Enrollment Installer Codes</Typography>
</Stack>
<Paper sx={{ p: 2, display: "flex", flexDirection: "column", gap: 2 }}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel id="status-filter-label">Filter</InputLabel>
<Select
labelId="status-filter-label"
label="Filter"
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="active">Active</MenuItem>
<MenuItem value="used">Used</MenuItem>
<MenuItem value="expired">Expired</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel id="ttl-select-label">Duration</InputLabel>
<Select
labelId="ttl-select-label"
label="Duration"
value={ttlHours}
onChange={(event) => setTtlHours(Number(event.target.value))}
>
{TTL_PRESETS.map((preset) => (
<MenuItem key={preset.value} value={preset.value}>
{preset.label}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel id="uses-select-label">Allowed Uses</InputLabel>
<Select
labelId="uses-select-label"
label="Allowed Uses"
value={maxUses}
onChange={(event) => setMaxUses(Number(event.target.value))}
>
{[1, 2, 3, 5].map((uses) => (
<MenuItem key={uses} value={uses}>
{uses === 1 ? "Single use" : `${uses} uses`}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
color="primary"
onClick={handleGenerate}
disabled={generating}
startIcon={generating ? <CircularProgress size={16} color="inherit" /> : null}
>
{generating ? "Generating…" : "Generate Code"}
</Button>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={fetchCodes}
disabled={loading}
>
Refresh
</Button>
</Stack>
{feedback ? (
<Alert
severity={feedback.type}
onClose={() => setFeedback(null)}
variant="outlined"
>
{feedback.message}
</Alert>
) : null}
{error ? (
<Alert severity="error" variant="outlined">
{error}
</Alert>
) : null}
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 420 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Status</TableCell>
<TableCell>Installer Code</TableCell>
<TableCell>Expires At</TableCell>
<TableCell>Created By</TableCell>
<TableCell>Usage</TableCell>
<TableCell>Last Used</TableCell>
<TableCell>Consumed At</TableCell>
<TableCell>Used By GUID</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} align="center">
<Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
<CircularProgress size={20} />
<Typography variant="body2">Loading installer codes</Typography>
</Stack>
</TableCell>
</TableRow>
) : filteredCodes.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center">
<Typography variant="body2" color="text.secondary">
No installer codes match this filter.
</Typography>
</TableCell>
</TableRow>
) : (
filteredCodes.map((record) => {
const status = determineStatus(record);
const maxAllowed = Math.max(1, Number.isFinite(record?.max_uses) ? record.max_uses : 1);
const usageCount = Math.max(0, Number.isFinite(record?.use_count) ? record.use_count : 0);
const disableDelete = usageCount !== 0;
return (
<TableRow hover key={record.id}>
<TableCell>{renderStatusChip(record)}</TableCell>
<TableCell sx={{ fontFamily: "monospace" }}>{maskCode(record.code)}</TableCell>
<TableCell>{formatDateTime(record.expires_at)}</TableCell>
<TableCell>{record.created_by_user_id || "—"}</TableCell>
<TableCell sx={{ fontFamily: "monospace" }}>{`${usageCount} / ${maxAllowed}`}</TableCell>
<TableCell>{formatDateTime(record.last_used_at)}</TableCell>
<TableCell>{formatDateTime(record.used_at)}</TableCell>
<TableCell sx={{ fontFamily: "monospace" }}>
{record.used_by_guid || "—"}
</TableCell>
<TableCell align="right">
<Tooltip title="Copy code">
<span>
<IconButton
size="small"
onClick={() => handleCopy(record.code)}
disabled={!record.code}
>
<CopyIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title={disableDelete ? "Only unused codes can be deleted" : "Delete code"}>
<span>
<IconButton
size="small"
onClick={() => handleDelete(record.id)}
disabled={disableDelete}
>
<DeleteIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Box>
);
}
export default React.memo(EnrollmentCodes);

View File

@@ -0,0 +1,480 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Paper,
Box,
Typography,
Button,
IconButton,
Table,
TableHead,
TableBody,
TableRow,
TableCell,
TableSortLabel,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
CircularProgress
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
import RefreshIcon from "@mui/icons-material/Refresh";
import { ConfirmDeleteDialog } from "../Dialogs.jsx";
import AddDevice from "./Add_Device.jsx";
const tableStyles = {
"& th, & td": {
color: "#ddd",
borderColor: "#2a2a2a",
fontSize: 13,
py: 0.75
},
"& th": {
fontWeight: 600
},
"& th .MuiTableSortLabel-root": { color: "#ddd" },
"& th .MuiTableSortLabel-root.Mui-active": { color: "#ddd" }
};
const defaultForm = {
hostname: "",
address: "",
description: "",
operating_system: ""
};
export default function SSHDevices({ type = "ssh" }) {
const typeLabel = type === "winrm" ? "WinRM" : "SSH";
const apiBase = type === "winrm" ? "/api/winrm_devices" : "/api/ssh_devices";
const pageTitle = `${typeLabel} Devices`;
const addButtonLabel = `Add ${typeLabel} Device`;
const addressLabel = `${typeLabel} Address`;
const loadingLabel = `Loading ${typeLabel} devices…`;
const emptyLabel = `No ${typeLabel} devices have been added yet.`;
const descriptionText = type === "winrm"
? "Manage remote endpoints reachable via WinRM for playbook execution."
: "Manage remote endpoints reachable via SSH for playbook execution.";
const editDialogTitle = `Edit ${typeLabel} Device`;
const newDialogTitle = `New ${typeLabel} Device`;
const [rows, setRows] = useState([]);
const [orderBy, setOrderBy] = useState("hostname");
const [order, setOrder] = useState("asc");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(defaultForm);
const [formError, setFormError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [editTarget, setEditTarget] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const [addDeviceOpen, setAddDeviceOpen] = useState(false);
const isEdit = Boolean(editTarget);
const loadDevices = useCallback(async () => {
setLoading(true);
setError("");
try {
const resp = await fetch(apiBase);
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data?.error || `HTTP ${resp.status}`);
}
const data = await resp.json();
const list = Array.isArray(data?.devices) ? data.devices : [];
setRows(list);
} catch (err) {
setError(String(err.message || err));
setRows([]);
} finally {
setLoading(false);
}
}, [apiBase]);
useEffect(() => {
loadDevices();
}, [loadDevices]);
const sortedRows = useMemo(() => {
const list = [...rows];
list.sort((a, b) => {
const getKey = (row) => {
switch (orderBy) {
case "created_at":
return Number(row.created_at || 0);
case "address":
return (row.connection_endpoint || "").toLowerCase();
case "description":
return (row.description || "").toLowerCase();
default:
return (row.hostname || "").toLowerCase();
}
};
const aKey = getKey(a);
const bKey = getKey(b);
if (aKey < bKey) return order === "asc" ? -1 : 1;
if (aKey > bKey) return order === "asc" ? 1 : -1;
return 0;
});
return list;
}, [rows, order, orderBy]);
const handleSort = (column) => () => {
if (orderBy === column) {
setOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setOrderBy(column);
setOrder("asc");
}
};
const openCreate = () => {
setAddDeviceOpen(true);
setFormError("");
};
const openEdit = (row) => {
setEditTarget(row);
setForm({
hostname: row.hostname || "",
address: row.connection_endpoint || "",
description: row.description || "",
operating_system: row.summary?.operating_system || ""
});
setDialogOpen(true);
setFormError("");
};
const handleDialogClose = () => {
if (submitting) return;
setDialogOpen(false);
setForm(defaultForm);
setEditTarget(null);
setFormError("");
};
const handleSubmit = async () => {
if (submitting) return;
const payload = {
hostname: form.hostname.trim(),
address: form.address.trim(),
description: form.description.trim(),
operating_system: form.operating_system.trim()
};
if (!payload.hostname) {
setFormError("Hostname is required.");
return;
}
if (!payload.address) {
setFormError("Address is required.");
return;
}
setSubmitting(true);
setFormError("");
try {
const endpoint = isEdit
? `${apiBase}/${encodeURIComponent(editTarget.hostname)}`
: apiBase;
const resp = await fetch(endpoint, {
method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
setDialogOpen(false);
setForm(defaultForm);
setEditTarget(null);
setFormError("");
setRows((prev) => {
const next = [...prev];
if (data?.device) {
const idx = next.findIndex((row) => row.hostname === data.device.hostname);
if (idx >= 0) next[idx] = data.device;
else next.push(data.device);
return next;
}
return prev;
});
// Ensure latest ordering by triggering refresh
loadDevices();
} catch (err) {
setFormError(String(err.message || err));
} finally {
setSubmitting(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleteBusy(true);
try {
const resp = await fetch(`${apiBase}/${encodeURIComponent(deleteTarget.hostname)}`, {
method: "DELETE"
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
setRows((prev) => prev.filter((row) => row.hostname !== deleteTarget.hostname));
setDeleteTarget(null);
} catch (err) {
setError(String(err.message || err));
} finally {
setDeleteBusy(false);
}
};
return (
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
p: 2,
borderBottom: "1px solid #2a2a2a"
}}
>
<Box>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
{pageTitle}
</Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}>
{descriptionText}
</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<RefreshIcon />}
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
onClick={loadDevices}
disabled={loading}
>
Refresh
</Button>
<Button
size="small"
variant="contained"
startIcon={<AddIcon />}
sx={{ bgcolor: "#58a6ff", color: "#0b0f19" }}
onClick={openCreate}
>
{addButtonLabel}
</Button>
</Box>
</Box>
{error && (
<Box sx={{ px: 2, py: 1.5, color: "#ff8080" }}>
<Typography variant="body2">{error}</Typography>
</Box>
)}
{loading && (
<Box sx={{ px: 2, py: 1.5, display: "flex", alignItems: "center", gap: 1, color: "#7db7ff" }}>
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
<Typography variant="body2">{loadingLabel}</Typography>
</Box>
)}
<Table size="small" sx={tableStyles}>
<TableHead>
<TableRow>
<TableCell sortDirection={orderBy === "hostname" ? order : false}>
<TableSortLabel
active={orderBy === "hostname"}
direction={orderBy === "hostname" ? order : "asc"}
onClick={handleSort("hostname")}
>
Hostname
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "address" ? order : false}>
<TableSortLabel
active={orderBy === "address"}
direction={orderBy === "address" ? order : "asc"}
onClick={handleSort("address")}
>
{addressLabel}
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "description" ? order : false}>
<TableSortLabel
active={orderBy === "description"}
direction={orderBy === "description" ? order : "asc"}
onClick={handleSort("description")}
>
Description
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "created_at" ? order : false}>
<TableSortLabel
active={orderBy === "created_at"}
direction={orderBy === "created_at" ? order : "asc"}
onClick={handleSort("created_at")}
>
Added
</TableSortLabel>
</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedRows.map((row) => {
const createdTs = Number(row.created_at || 0) * 1000;
const createdDisplay = createdTs
? new Date(createdTs).toLocaleString()
: (row.summary?.created || "");
return (
<TableRow key={row.hostname}>
<TableCell>{row.hostname}</TableCell>
<TableCell>{row.connection_endpoint || ""}</TableCell>
<TableCell>{row.description || ""}</TableCell>
<TableCell>{createdDisplay}</TableCell>
<TableCell align="right">
<IconButton size="small" sx={{ color: "#7db7ff" }} onClick={() => openEdit(row)}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" sx={{ color: "#ff8080" }} onClick={() => setDeleteTarget(row)}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
);
})}
{!sortedRows.length && !loading && (
<TableRow>
<TableCell colSpan={5} sx={{ textAlign: "center", color: "#888" }}>
{emptyLabel}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Dialog
open={dialogOpen}
onClose={handleDialogClose}
fullWidth
maxWidth="sm"
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
>
<DialogTitle>{isEdit ? editDialogTitle : newDialogTitle}</DialogTitle>
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}>
<TextField
label="Hostname"
value={form.hostname}
disabled={isEdit}
onChange={(e) => setForm((prev) => ({ ...prev, hostname: e.target.value }))}
fullWidth
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
helperText="Hostname used within Borealis (unique)."
/>
<TextField
label={addressLabel}
value={form.address}
onChange={(e) => setForm((prev) => ({ ...prev, address: e.target.value }))}
fullWidth
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
helperText={`IP or FQDN Borealis can reach over ${typeLabel}.`}
/>
<TextField
label="Description"
value={form.description}
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
fullWidth
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
/>
<TextField
label="Operating System"
value={form.operating_system}
onChange={(e) => setForm((prev) => ({ ...prev, operating_system: e.target.value }))}
fullWidth
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
/>
{error && (
<Typography variant="body2" sx={{ color: "#ff8080" }}>
{error}
</Typography>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={handleDialogClose} sx={{ color: "#58a6ff" }} disabled={submitting}>
Cancel
</Button>
<Button
onClick={handleSubmit}
variant="outlined"
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
disabled={submitting}
>
{submitting ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>
<ConfirmDeleteDialog
open={Boolean(deleteTarget)}
message={
deleteTarget
? `Remove ${typeLabel} device '${deleteTarget.hostname}' from inventory?`
: ""
}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleDelete}
confirmDisabled={deleteBusy}
/>
<AddDevice
open={addDeviceOpen}
defaultType={type}
onClose={() => setAddDeviceOpen(false)}
onCreated={() => {
setAddDeviceOpen(false);
loadDevices();
}}
/>
</Paper>
);
}

View File

@@ -0,0 +1,6 @@
import React from "react";
import SSHDevices from "./SSH_Devices.jsx";
export default function WinRMDevices(props) {
return <SSHDevices {...props} type="winrm" />;
}

View File

@@ -0,0 +1,514 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Dialogs.jsx
import React from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Button,
Menu,
MenuItem,
TextField
} from "@mui/material";
export function CloseAllDialog({ open, onClose, onConfirm }) {
return (
<Dialog open={open} onClose={onClose} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Close All Flow Tabs?</DialogTitle>
<DialogContent>
<DialogContentText sx={{ color: "#ccc" }}>
This will remove all existing flow tabs and create a fresh tab named Flow 1.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onConfirm} sx={{ color: "#ff4f4f" }}>Close All</Button>
</DialogActions>
</Dialog>
);
}
export function NotAuthorizedDialog({ open, onClose }) {
return (
<Dialog
open={open}
onClose={onClose}
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
>
<DialogTitle>Not Authorized</DialogTitle>
<DialogContent>
<DialogContentText sx={{ color: "#ccc" }}>
You are not authorized to access this section.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} sx={{ color: "#58a6ff" }}>OK</Button>
</DialogActions>
</Dialog>
);
}
export function CreditsDialog({ open, onClose }) {
return (
<Dialog open={open} onClose={onClose} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogContent sx={{ textAlign: "center", pt: 3 }}>
<img
src="/Borealis_Logo.png"
alt="Borealis Logo"
style={{ width: "120px", marginBottom: "12px" }}
/>
<DialogTitle sx={{ p: 0, mb: 1 }}>Borealis - Automation Platform</DialogTitle>
<DialogContentText sx={{ color: "#ccc" }}>
Designed by Nicole Rappe @{" "}
<a
href="https://bunny-lab.io"
target="_blank"
rel="noopener noreferrer"
style={{ color: "#58a6ff", textDecoration: "none" }}
>
Bunny Lab
</a>
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} sx={{ color: "#58a6ff" }}>Close</Button>
</DialogActions>
</Dialog>
);
}
export function RenameTabDialog({ open, value, onChange, onCancel, onSave }) {
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Rename Tab</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Tab Name"
fullWidth
variant="outlined"
value={value}
onChange={(e) => onChange(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#2a2a2a",
color: "#ccc",
"& fieldset": {
borderColor: "#444"
},
"&:hover fieldset": {
borderColor: "#666"
}
},
label: { color: "#aaa" },
mt: 1
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
</DialogActions>
</Dialog>
);
}
export function RenameWorkflowDialog({ open, value, onChange, onCancel, onSave }) {
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Rename Workflow</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Workflow Name"
fullWidth
variant="outlined"
value={value}
onChange={(e) => onChange(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#2a2a2a",
color: "#ccc",
"& fieldset": {
borderColor: "#444"
},
"&:hover fieldset": {
borderColor: "#666"
}
},
label: { color: "#aaa" },
mt: 1
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
</DialogActions>
</Dialog>
);
}
export function RenameFolderDialog({
open,
value,
onChange,
onCancel,
onSave,
title = "Folder Name",
confirmText = "Save"
}) {
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Folder Name"
fullWidth
variant="outlined"
value={value}
onChange={(e) => onChange(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#2a2a2a",
color: "#ccc",
"& fieldset": { borderColor: "#444" },
"&:hover fieldset": { borderColor: "#666" }
},
label: { color: "#aaa" },
mt: 1
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>{confirmText}</Button>
</DialogActions>
</Dialog>
);
}
export function NewWorkflowDialog({ open, value, onChange, onCancel, onCreate }) {
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>New Workflow</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Workflow Name"
fullWidth
variant="outlined"
value={value}
onChange={(e) => onChange(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#2a2a2a",
color: "#ccc",
"& fieldset": { borderColor: "#444" },
"&:hover fieldset": { borderColor: "#666" }
},
label: { color: "#aaa" },
mt: 1
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onCreate} sx={{ color: "#58a6ff" }}>Create</Button>
</DialogActions>
</Dialog>
);
}
export function ClearDeviceActivityDialog({ open, onCancel, onConfirm }) {
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Clear Device Activity</DialogTitle>
<DialogContent>
<DialogContentText sx={{ color: "#ccc" }}>
All device activity history will be cleared, are you sure?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onConfirm} sx={{ color: "#ff4f4f" }}>Clear</Button>
</DialogActions>
</Dialog>
);
}
export function SaveWorkflowDialog({ open, value, onChange, onCancel, onSave }) {
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Save Workflow</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Workflow Name"
fullWidth
variant="outlined"
value={value}
onChange={(e) => onChange(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#2a2a2a",
color: "#ccc",
"& fieldset": { borderColor: "#444" },
"&:hover fieldset": { borderColor: "#666" }
},
label: { color: "#aaa" },
mt: 1
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
</DialogActions>
</Dialog>
);
}
export function ConfirmDeleteDialog({ open, message, onCancel, onConfirm }) {
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogContent>
<DialogContentText sx={{ color: "#ccc" }}>{message}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onConfirm} sx={{ color: "#58a6ff" }}>Confirm</Button>
</DialogActions>
</Dialog>
);
}
export function DeleteDeviceDialog({ open, onCancel, onConfirm }) {
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Remove Device</DialogTitle>
<DialogContent>
<DialogContentText sx={{ color: "#ccc" }}>
Are you sure you want to remove this device? If the agent is still running, it will automatically re-enroll the device.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button
onClick={onConfirm}
sx={{ bgcolor: "#ff4f4f", color: "#fff", "&:hover": { bgcolor: "#e04444" } }}
>
Remove
</Button>
</DialogActions>
</Dialog>
);
}
export function TabContextMenu({ anchor, onClose, onRename, onCloseTab }) {
return (
<Menu
open={Boolean(anchor)}
onClose={onClose}
anchorReference="anchorPosition"
anchorPosition={anchor ? { top: anchor.y, left: anchor.x } : undefined}
PaperProps={{
sx: {
bgcolor: "#1e1e1e",
color: "#fff",
fontSize: "13px"
}
}}
>
<MenuItem onClick={onRename}>Rename</MenuItem>
<MenuItem onClick={onCloseTab}>Close Workflow</MenuItem>
</Menu>
);
}
export function CreateCustomViewDialog({ open, value, onChange, onCancel, onSave }) {
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Create a New Custom View</DialogTitle>
<DialogContent>
<DialogContentText sx={{ color: "#ccc", mb: 1 }}>
Saving a view will save column order, visibility, and filters.
</DialogContentText>
<TextField
autoFocus
fullWidth
margin="dense"
label="View Name"
variant="outlined"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Add a name for this custom view"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#2a2a2a",
color: "#ccc",
"& fieldset": { borderColor: "#444" },
"&:hover fieldset": { borderColor: "#666" }
},
label: { color: "#aaa" },
mt: 1
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
</DialogActions>
</Dialog>
);
}
export function RenameCustomViewDialog({ open, value, onChange, onCancel, onSave }) {
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Rename Custom View</DialogTitle>
<DialogContent>
<TextField
autoFocus
fullWidth
margin="dense"
label="View Name"
variant="outlined"
value={value}
onChange={(e) => onChange(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#2a2a2a",
color: "#ccc",
"& fieldset": { borderColor: "#444" },
"&:hover fieldset": { borderColor: "#666" }
},
label: { color: "#aaa" },
mt: 1
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
</DialogActions>
</Dialog>
);
}
export function CreateSiteDialog({ open, onCancel, onCreate }) {
const [name, setName] = React.useState("");
const [description, setDescription] = React.useState("");
React.useEffect(() => {
if (open) {
setName("");
setDescription("");
}
}, [open]);
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Create Site</DialogTitle>
<DialogContent>
<DialogContentText sx={{ color: "#ccc", mb: 1 }}>
Create a new site and optionally add a description.
</DialogContentText>
<TextField
autoFocus
fullWidth
margin="dense"
label="Site Name"
variant="outlined"
value={name}
onChange={(e) => setName(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#2a2a2a",
color: "#ccc",
"& fieldset": { borderColor: "#444" },
"&:hover fieldset": { borderColor: "#666" }
},
label: { color: "#aaa" },
mt: 1
}}
/>
<TextField
fullWidth
multiline
minRows={3}
margin="dense"
label="Description"
variant="outlined"
value={description}
onChange={(e) => setDescription(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#2a2a2a",
color: "#ccc",
"& fieldset": { borderColor: "#444" },
"&:hover fieldset": { borderColor: "#666" }
},
label: { color: "#aaa" },
mt: 2
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button
onClick={() => {
const nm = (name || '').trim();
if (!nm) return;
onCreate && onCreate(nm, description || '');
}}
sx={{ color: "#58a6ff" }}
>
Create
</Button>
</DialogActions>
</Dialog>
);
}
export function RenameSiteDialog({ open, value, onChange, onCancel, onSave }) {
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Rename Site</DialogTitle>
<DialogContent>
<TextField
autoFocus
fullWidth
margin="dense"
label="Site Name"
variant="outlined"
value={value}
onChange={(e) => onChange(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#2a2a2a",
color: "#ccc",
"& fieldset": { borderColor: "#444" },
"&:hover fieldset": { borderColor: "#666" }
},
label: { color: "#aaa" },
mt: 1
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,415 @@
import React, { useState, useEffect } from "react";
import { Box, Typography, Tabs, Tab, TextField, MenuItem, Button, Slider, IconButton, Tooltip } from "@mui/material";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import ContentPasteIcon from "@mui/icons-material/ContentPaste";
import RestoreIcon from "@mui/icons-material/Restore";
import { SketchPicker } from "react-color";
const SIDEBAR_WIDTH = 400;
const DEFAULT_EDGE_STYLE = {
type: "bezier",
animated: true,
style: { strokeDasharray: "6 3", stroke: "#58a6ff", strokeWidth: 1 },
label: "",
labelStyle: { fill: "#fff", fontWeight: "bold" },
labelBgStyle: { fill: "#2c2c2c", fillOpacity: 0.85, rx: 16, ry: 16 },
labelBgPadding: [8, 4],
};
let globalEdgeClipboard = null;
function clone(obj) {
return JSON.parse(JSON.stringify(obj));
}
export default function Context_Menu_Sidebar({
open,
onClose,
edge,
updateEdge,
}) {
const [activeTab, setActiveTab] = useState(0);
const [editState, setEditState] = useState(() => (edge ? clone(edge) : {}));
const [colorPicker, setColorPicker] = useState({ field: null, anchor: null });
useEffect(() => {
if (edge && edge.id !== editState.id) setEditState(clone(edge));
// eslint-disable-next-line
}, [edge]);
const handleChange = (field, value) => {
setEditState((prev) => {
const updated = { ...prev };
if (field === "label") updated.label = value;
else if (field === "labelStyle.fill") updated.labelStyle = { ...updated.labelStyle, fill: value };
else if (field === "labelBgStyle.fill") updated.labelBgStyle = { ...updated.labelBgStyle, fill: value };
else if (field === "labelBgStyle.rx") updated.labelBgStyle = { ...updated.labelBgStyle, rx: value, ry: value };
else if (field === "labelBgPadding") updated.labelBgPadding = value;
else if (field === "labelBgStyle.fillOpacity") updated.labelBgStyle = { ...updated.labelBgStyle, fillOpacity: value };
else if (field === "type") updated.type = value;
else if (field === "animated") updated.animated = value;
else if (field === "style.stroke") updated.style = { ...updated.style, stroke: value };
else if (field === "style.strokeDasharray") updated.style = { ...updated.style, strokeDasharray: value };
else if (field === "style.strokeWidth") updated.style = { ...updated.style, strokeWidth: value };
else if (field === "labelStyle.fontWeight") updated.labelStyle = { ...updated.labelStyle, fontWeight: value };
else updated[field] = value;
if (field === "style.strokeDasharray") {
if (value === "") {
updated.animated = false;
updated.style = { ...updated.style, strokeDasharray: "" };
} else {
updated.animated = true;
updated.style = { ...updated.style, strokeDasharray: value };
}
}
updateEdge({ ...updated, id: prev.id });
return updated;
});
};
// Color Picker with right alignment
const openColorPicker = (field, event) => {
setColorPicker({ field, anchor: event.currentTarget });
};
const closeColorPicker = () => {
setColorPicker({ field: null, anchor: null });
};
const handleColorChange = (color) => {
handleChange(colorPicker.field, color.hex);
closeColorPicker();
};
// Reset, Copy, Paste logic
const handleReset = () => {
setEditState(clone({ ...DEFAULT_EDGE_STYLE, id: edge.id }));
updateEdge({ ...DEFAULT_EDGE_STYLE, id: edge.id });
};
const handleCopy = () => { globalEdgeClipboard = clone(editState); };
const handlePaste = () => {
if (globalEdgeClipboard) {
setEditState(clone({ ...globalEdgeClipboard, id: edge.id }));
updateEdge({ ...globalEdgeClipboard, id: edge.id });
}
};
const renderColorButton = (label, field, value) => (
<span style={{ display: "inline-block", verticalAlign: "middle", position: "relative" }}>
<Button
variant="outlined"
size="small"
onClick={(e) => openColorPicker(field, e)}
sx={{
ml: 1,
borderColor: "#444",
color: "#ccc",
minWidth: 0,
width: 32,
height: 24,
p: 0,
bgcolor: "#232323",
}}
>
<span style={{
display: "inline-block",
width: 20,
height: 16,
background: value,
borderRadius: 3,
border: "1px solid #888",
}} />
</Button>
{colorPicker.field === field && (
<Box sx={{
position: "absolute",
top: "32px",
right: 0,
zIndex: 1302,
boxShadow: "0 2px 16px rgba(0,0,0,0.24)"
}}>
<SketchPicker
color={value}
onChange={handleColorChange}
disableAlpha
presetColors={[
"#fff", "#000", "#58a6ff", "#ff4f4f", "#2c2c2c", "#00d18c",
"#e3e3e3", "#0475c2", "#ff8c00", "#6b21a8", "#0e7490"
]}
/>
</Box>
)}
</span>
);
// Label tab
const renderLabelTab = () => (
<Box sx={{ px: 2, pt: 1, pb: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Label</Typography>
</Box>
<TextField
fullWidth
size="small"
variant="outlined"
value={editState.label || ""}
onChange={e => handleChange("label", e.target.value)}
sx={{
mb: 2,
input: { color: "#fff", bgcolor: "#1e1e1e", fontSize: "0.95rem" },
"& fieldset": { borderColor: "#444" },
}}
/>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Text Color</Typography>
{renderColorButton("Label Text Color", "labelStyle.fill", editState.labelStyle?.fill || "#fff")}
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Background</Typography>
{renderColorButton("Label Background Color", "labelBgStyle.fill", editState.labelBgStyle?.fill || "#2c2c2c")}
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Padding</Typography>
<TextField
size="small"
type="text"
value={editState.labelBgPadding ? editState.labelBgPadding.join(",") : "8,4"}
onChange={e => {
const val = e.target.value.split(",").map(x => parseInt(x.trim())).filter(x => !isNaN(x));
if (val.length === 2) handleChange("labelBgPadding", val);
}}
sx={{ width: 80, input: { color: "#fff", bgcolor: "#1e1e1e", fontSize: "0.95rem" } }}
/>
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Background Style</Typography>
<TextField
select
size="small"
value={(editState.labelBgStyle?.rx ?? 11) >= 11 ? "rounded" : "square"}
onChange={e => {
handleChange("labelBgStyle.rx", e.target.value === "rounded" ? 11 : 0);
}}
sx={{
width: 150,
bgcolor: "#1e1e1e",
"& .MuiSelect-select": { color: "#fff" }
}}
>
<MenuItem value="rounded">Rounded</MenuItem>
<MenuItem value="square">Square</MenuItem>
</TextField>
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Background Opacity</Typography>
<Slider
value={editState.labelBgStyle?.fillOpacity ?? 0.85}
min={0}
max={1}
step={0.05}
onChange={(_, v) => handleChange("labelBgStyle.fillOpacity", v)}
sx={{ width: 100, ml: 2 }}
/>
<TextField
size="small"
type="number"
value={editState.labelBgStyle?.fillOpacity ?? 0.85}
onChange={e => handleChange("labelBgStyle.fillOpacity", parseFloat(e.target.value) || 0)}
sx={{ width: 60, ml: 2, input: { color: "#fff", bgcolor: "#1e1e1e", fontSize: "0.95rem" } }}
/>
</Box>
</Box>
);
const renderStyleTab = () => (
<Box sx={{ px: 2, pt: 1, pb: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Edge Style</Typography>
<TextField
select
size="small"
value={editState.type || "bezier"}
onChange={e => handleChange("type", e.target.value)}
sx={{
width: 200,
bgcolor: "#1e1e1e",
"& .MuiSelect-select": { color: "#fff" }
}}
>
<MenuItem value="step">Step</MenuItem>
<MenuItem value="bezier">Curved (Bezier)</MenuItem>
<MenuItem value="straight">Straight</MenuItem>
<MenuItem value="smoothstep">Smoothstep</MenuItem>
</TextField>
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Edge Animation</Typography>
<TextField
select
size="small"
value={
editState.style?.strokeDasharray === "6 3" ? "dashes"
: editState.style?.strokeDasharray === "2 4" ? "dots"
: "solid"
}
onChange={e => {
const val = e.target.value;
handleChange("style.strokeDasharray",
val === "dashes" ? "6 3" :
val === "dots" ? "2 4" : ""
);
}}
sx={{
width: 200,
bgcolor: "#1e1e1e",
"& .MuiSelect-select": { color: "#fff" }
}}
>
<MenuItem value="dashes">Dashes</MenuItem>
<MenuItem value="dots">Dots</MenuItem>
<MenuItem value="solid">Solid</MenuItem>
</TextField>
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Color</Typography>
{renderColorButton("Edge Color", "style.stroke", editState.style?.stroke || "#58a6ff")}
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<Typography variant="body2" sx={{ color: "#ccc", flex: 1 }}>Edge Width</Typography>
<Slider
value={editState.style?.strokeWidth ?? 2}
min={1}
max={10}
step={1}
onChange={(_, v) => handleChange("style.strokeWidth", v)}
sx={{ width: 100, ml: 2 }}
/>
<TextField
size="small"
type="number"
value={editState.style?.strokeWidth ?? 2}
onChange={e => handleChange("style.strokeWidth", parseInt(e.target.value) || 1)}
sx={{ width: 60, ml: 2, input: { color: "#fff", bgcolor: "#1e1e1e", fontSize: "0.95rem" } }}
/>
</Box>
</Box>
);
// Always render the sidebar for animation!
if (!edge) return null;
return (
<>
{/* Overlay */}
<Box
onClick={onClose}
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.3)",
opacity: open ? 1 : 0,
pointerEvents: open ? "auto" : "none",
transition: "opacity 0.6s ease",
zIndex: 10
}}
/>
{/* Sidebar */}
<Box
sx={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: SIDEBAR_WIDTH,
bgcolor: "#2C2C2C",
color: "#ccc",
borderLeft: "1px solid #333",
padding: 0,
zIndex: 11,
display: "flex",
flexDirection: "column",
height: "100%",
transform: open ? "translateX(0)" : `translateX(${SIDEBAR_WIDTH}px)`,
transition: "transform 0.3s cubic-bezier(.77,0,.18,1)"
}}
onClick={e => e.stopPropagation()}
>
<Box sx={{ backgroundColor: "#232323", borderBottom: "1px solid #333" }}>
<Box sx={{ padding: "12px 16px", display: "flex", alignItems: "center" }}>
<Typography variant="h7" sx={{ color: "#0475c2", fontWeight: "bold", flex: 1 }}>
Edit Edge Properties
</Typography>
</Box>
<Tabs
value={activeTab}
onChange={(_, v) => setActiveTab(v)}
variant="fullWidth"
textColor="inherit"
TabIndicatorProps={{ style: { backgroundColor: "#ccc" } }}
sx={{
borderTop: "1px solid #333",
borderBottom: "1px solid #333",
minHeight: "36px",
height: "36px"
}}
>
<Tab label="Label" sx={{
color: "#ccc",
"&.Mui-selected": { color: "#ccc" },
minHeight: "36px",
height: "36px",
textTransform: "none"
}} />
<Tab label="Style" sx={{
color: "#ccc",
"&.Mui-selected": { color: "#ccc" },
minHeight: "36px",
height: "36px",
textTransform: "none"
}} />
</Tabs>
</Box>
{/* Main fields scrollable */}
<Box sx={{ flex: 1, overflowY: "auto" }}>
{activeTab === 0 && renderLabelTab()}
{activeTab === 1 && renderStyleTab()}
</Box>
{/* Sticky footer bar */}
<Box sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
px: 2, py: 1,
borderTop: "1px solid #333",
backgroundColor: "#232323",
flexShrink: 0
}}>
<Box>
<Tooltip title="Copy Style"><IconButton onClick={handleCopy}><ContentCopyIcon /></IconButton></Tooltip>
<Tooltip title="Paste Style"><IconButton onClick={handlePaste}><ContentPasteIcon /></IconButton></Tooltip>
</Box>
<Box>
<Tooltip title="Reset to Default"><Button variant="outlined" size="small" startIcon={<RestoreIcon />} onClick={handleReset} sx={{
color: "#58a6ff", borderColor: "#58a6ff", textTransform: "none"
}}>Reset to Default</Button></Tooltip>
</Box>
</Box>
</Box>
</>
);
}

View File

@@ -0,0 +1,374 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Flow_Editor.jsx
// Import Node Configuration Sidebar and new Context Menu Sidebar
import NodeConfigurationSidebar from "./Node_Configuration_Sidebar";
import ContextMenuSidebar from "./Context_Menu_Sidebar";
import React, { useState, useEffect, useCallback, useRef } from "react";
import ReactFlow, {
Background,
addEdge,
applyNodeChanges,
applyEdgeChanges,
useReactFlow
} from "reactflow";
import { Menu, MenuItem, Box } from "@mui/material";
import {
Polyline as PolylineIcon,
DeleteForever as DeleteForeverIcon,
Edit as EditIcon
} from "@mui/icons-material";
import "reactflow/dist/style.css";
export default function FlowEditor({
flowId,
nodes,
edges,
setNodes,
setEdges,
nodeTypes,
categorizedNodes
}) {
// Node Configuration Sidebar State
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedNodeId, setSelectedNodeId] = useState(null);
// Edge Properties Sidebar State
const [edgeSidebarOpen, setEdgeSidebarOpen] = useState(false);
const [edgeSidebarEdgeId, setEdgeSidebarEdgeId] = useState(null);
// Context Menus
const [nodeContextMenu, setNodeContextMenu] = useState(null); // { mouseX, mouseY, nodeId }
const [edgeContextMenu, setEdgeContextMenu] = useState(null); // { mouseX, mouseY, edgeId }
// Drag/snap helpers (untouched)
const wrapperRef = useRef(null);
const { project } = useReactFlow();
const [guides, setGuides] = useState([]);
const [activeGuides, setActiveGuides] = useState([]);
const movingFlowSize = useRef({ width: 0, height: 0 });
// ----- Node/Edge Definitions -----
const selectedNode = nodes.find((n) => n.id === selectedNodeId);
const selectedEdge = edges.find((e) => e.id === edgeSidebarEdgeId);
// --------- Context Menu Handlers ----------
const handleRightClick = (e, node) => {
e.preventDefault();
setNodeContextMenu({ mouseX: e.clientX + 2, mouseY: e.clientY - 6, nodeId: node.id });
};
const handleEdgeRightClick = (e, edge) => {
e.preventDefault();
setEdgeContextMenu({ mouseX: e.clientX + 2, mouseY: e.clientY - 6, edgeId: edge.id });
};
// --------- Node Context Menu Actions ---------
const handleDisconnectAllEdges = (nodeId) => {
setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId));
setNodeContextMenu(null);
};
const handleRemoveNode = (nodeId) => {
setNodes((nds) => nds.filter((n) => n.id !== nodeId));
setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId));
setNodeContextMenu(null);
};
const handleEditNodeProps = (nodeId) => {
setSelectedNodeId(nodeId);
setDrawerOpen(true);
setNodeContextMenu(null);
};
// --------- Edge Context Menu Actions ---------
const handleUnlinkEdge = (edgeId) => {
setEdges((eds) => eds.filter((e) => e.id !== edgeId));
setEdgeContextMenu(null);
};
const handleEditEdgeProps = (edgeId) => {
setEdgeSidebarEdgeId(edgeId);
setEdgeSidebarOpen(true);
setEdgeContextMenu(null);
};
// ----- Sidebar Closing -----
const handleCloseNodeSidebar = () => {
setDrawerOpen(false);
setSelectedNodeId(null);
};
const handleCloseEdgeSidebar = () => {
setEdgeSidebarOpen(false);
setEdgeSidebarEdgeId(null);
};
// ----- Update Edge Callback for Sidebar -----
const updateEdge = (updatedEdgeObj) => {
setEdges((eds) =>
eds.map((e) => (e.id === updatedEdgeObj.id ? { ...e, ...updatedEdgeObj } : e))
);
};
// ----- Drag/Drop, Guides, Node Snap Logic (unchanged) -----
const computeGuides = useCallback((dragNode) => {
if (!wrapperRef.current) return;
const parentRect = wrapperRef.current.getBoundingClientRect();
const dragEl = wrapperRef.current.querySelector(
`.react-flow__node[data-id="${dragNode.id}"]`
);
if (dragEl) {
const dr = dragEl.getBoundingClientRect();
const relLeft = dr.left - parentRect.left;
const relTop = dr.top - parentRect.top;
const relRight = relLeft + dr.width;
const relBottom = relTop + dr.height;
const pTL = project({ x: relLeft, y: relTop });
const pTR = project({ x: relRight, y: relTop });
const pBL = project({ x: relLeft, y: relBottom });
movingFlowSize.current = { width: pTR.x - pTL.x, height: pBL.y - pTL.y };
}
const lines = [];
nodes.forEach((n) => {
if (n.id === dragNode.id) return;
const el = wrapperRef.current.querySelector(
`.react-flow__node[data-id="${n.id}"]`
);
if (!el) return;
const r = el.getBoundingClientRect();
const relLeft = r.left - parentRect.left;
const relTop = r.top - parentRect.top;
const relRight = relLeft + r.width;
const relBottom = relTop + r.height;
const pTL = project({ x: relLeft, y: relTop });
const pTR = project({ x: relRight, y: relTop });
const pBL = project({ x: relLeft, y: relBottom });
lines.push({ xFlow: pTL.x, xPx: relLeft });
lines.push({ xFlow: pTR.x, xPx: relRight });
lines.push({ yFlow: pTL.y, yPx: relTop });
lines.push({ yFlow: pBL.y, yPx: relBottom });
});
setGuides(lines);
}, [nodes, project]);
const onNodeDrag = useCallback((_, node) => {
const threshold = 5;
let snapX = null, snapY = null;
const show = [];
const { width: fw, height: fh } = movingFlowSize.current;
guides.forEach((ln) => {
if (ln.xFlow != null) {
if (Math.abs(node.position.x - ln.xFlow) < threshold) { snapX = ln.xFlow; show.push({ xPx: ln.xPx }); }
else if (Math.abs(node.position.x + fw - ln.xFlow) < threshold) { snapX = ln.xFlow - fw; show.push({ xPx: ln.xPx }); }
}
if (ln.yFlow != null) {
if (Math.abs(node.position.y - ln.yFlow) < threshold) { snapY = ln.yFlow; show.push({ yPx: ln.yPx }); }
else if (Math.abs(node.position.y + fh - ln.yFlow) < threshold) { snapY = ln.yFlow - fh; show.push({ yPx: ln.yPx }); }
}
});
if (snapX !== null || snapY !== null) {
setNodes((nds) =>
applyNodeChanges(
[{
id: node.id,
type: "position",
position: {
x: snapX !== null ? snapX : node.position.x,
y: snapY !== null ? snapY : node.position.y
}
}],
nds
)
);
setActiveGuides(show);
} else {
setActiveGuides([]);
}
}, [guides, setNodes]);
const onDrop = useCallback((event) => {
event.preventDefault();
const type = event.dataTransfer.getData("application/reactflow");
if (!type) return;
const bounds = wrapperRef.current.getBoundingClientRect();
const position = project({
x: event.clientX - bounds.left,
y: event.clientY - bounds.top
});
const id = "node-" + Date.now();
const nodeMeta = Object.values(categorizedNodes).flat().find((n) => n.type === type);
// Seed config defaults:
const configDefaults = {};
(nodeMeta?.config || []).forEach(cfg => {
if (cfg.defaultValue !== undefined) {
configDefaults[cfg.key] = cfg.defaultValue;
}
});
const newNode = {
id,
type,
position,
data: {
label: nodeMeta?.label || type,
content: nodeMeta?.content,
...configDefaults
},
dragHandle: ".borealis-node-header"
};
setNodes((nds) => [...nds, newNode]);
}, [project, setNodes, categorizedNodes]);
const onDragOver = useCallback((event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}, []);
const onConnect = useCallback((params) => {
setEdges((eds) =>
addEdge({
...params,
type: "bezier",
animated: true,
style: { strokeDasharray: "6 3", stroke: "#58a6ff" }
}, eds)
);
}, [setEdges]);
const onNodesChange = useCallback((changes) => {
setNodes((nds) => applyNodeChanges(changes, nds));
}, [setNodes]);
const onEdgesChange = useCallback((changes) => {
setEdges((eds) => applyEdgeChanges(changes, eds));
}, [setEdges]);
useEffect(() => {
const nodeCountEl = document.getElementById("nodeCount");
if (nodeCountEl) nodeCountEl.innerText = nodes.length;
}, [nodes]);
const nodeDef = selectedNode
? Object.values(categorizedNodes).flat().find((def) => def.type === selectedNode.type)
: null;
// --------- MAIN RENDER ----------
return (
<div
className="flow-editor-container"
ref={wrapperRef}
style={{ position: "relative" }}
>
{/* Node Config Sidebar */}
<NodeConfigurationSidebar
drawerOpen={drawerOpen}
setDrawerOpen={setDrawerOpen}
title={selectedNode ? selectedNode.data?.label || selectedNode.id : ""}
nodeData={
selectedNode && nodeDef
? {
config: nodeDef.config,
usage_documentation: nodeDef.usage_documentation,
...selectedNode.data,
nodeId: selectedNode.id
}
: null
}
setNodes={setNodes}
selectedNode={selectedNode}
/>
{/* Edge Properties Sidebar */}
<ContextMenuSidebar
open={edgeSidebarOpen}
onClose={handleCloseEdgeSidebar}
edge={selectedEdge ? { ...selectedEdge } : null}
updateEdge={edge => {
// Provide id if missing
if (!edge.id && edgeSidebarEdgeId) edge.id = edgeSidebarEdgeId;
updateEdge(edge);
}}
/>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onDrop={onDrop}
onDragOver={onDragOver}
onNodeContextMenu={handleRightClick}
onEdgeContextMenu={handleEdgeRightClick}
defaultViewport={{ x: 0, y: 0, zoom: 1.5 }}
edgeOptions={{ type: "bezier", animated: true, style: { strokeDasharray: "6 3", stroke: "#58a6ff" } }}
proOptions={{ hideAttribution: true }}
onNodeDragStart={(_, node) => computeGuides(node)}
onNodeDrag={onNodeDrag}
onNodeDragStop={() => { setGuides([]); setActiveGuides([]); }}
>
<Background id={flowId} variant="lines" gap={65} size={1} color="rgba(255,255,255,0.2)" />
</ReactFlow>
{/* Helper lines for snapping */}
{activeGuides.map((ln, i) =>
ln.xPx != null ? (
<div
key={i}
className="helper-line helper-line-vertical"
style={{ left: ln.xPx + "px", top: 0 }}
/>
) : (
<div
key={i}
className="helper-line helper-line-horizontal"
style={{ top: ln.yPx + "px", left: 0 }}
/>
)
)}
{/* Node Context Menu */}
<Menu
open={Boolean(nodeContextMenu)}
onClose={() => setNodeContextMenu(null)}
anchorReference="anchorPosition"
anchorPosition={nodeContextMenu ? { top: nodeContextMenu.mouseY, left: nodeContextMenu.mouseX } : undefined}
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
>
<MenuItem onClick={() => handleEditNodeProps(nodeContextMenu.nodeId)}>
<EditIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} />
Edit Properties
</MenuItem>
<MenuItem onClick={() => handleDisconnectAllEdges(nodeContextMenu.nodeId)}>
<PolylineIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} />
Disconnect All Edges
</MenuItem>
<MenuItem onClick={() => handleRemoveNode(nodeContextMenu.nodeId)}>
<DeleteForeverIcon sx={{ fontSize: 18, color: "#ff4f4f", mr: 1 }} />
Remove Node
</MenuItem>
</Menu>
{/* Edge Context Menu */}
<Menu
open={Boolean(edgeContextMenu)}
onClose={() => setEdgeContextMenu(null)}
anchorReference="anchorPosition"
anchorPosition={edgeContextMenu ? { top: edgeContextMenu.mouseY, left: edgeContextMenu.mouseX } : undefined}
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
>
<MenuItem onClick={() => handleEditEdgeProps(edgeContextMenu.edgeId)}>
<EditIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} />
Edit Properties
</MenuItem>
<MenuItem onClick={() => handleUnlinkEdge(edgeContextMenu.edgeId)}>
<DeleteForeverIcon sx={{ fontSize: 18, color: "#ff4f4f", mr: 1 }} />
Unlink Edge
</MenuItem>
</Menu>
</div>
);
}

View File

@@ -0,0 +1,100 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Flow_Tabs.jsx
import React from "react";
import { Box, Tabs, Tab, Tooltip } from "@mui/material";
import { Add as AddIcon } from "@mui/icons-material";
/**
* Renders the tab bar (including the "add tab" button).
*
* Props:
* - tabs (array of {id, tab_name, nodes, edges})
* - activeTabId (string)
* - onTabChange(newActiveTabId: string)
* - onAddTab()
* - onTabRightClick(evt: MouseEvent, tabId: string)
*/
export default function FlowTabs({
tabs,
activeTabId,
onTabChange,
onAddTab,
onTabRightClick
}) {
// Determine the currently active tab index
const activeIndex = (() => {
const idx = tabs.findIndex((t) => t.id === activeTabId);
return idx >= 0 ? idx : 0;
})();
// Handle tab clicks
const handleChange = (event, newValue) => {
if (newValue === "__addtab__") {
// The "plus" tab
onAddTab();
} else {
// normal tab index
const newTab = tabs[newValue];
if (newTab) {
onTabChange(newTab.id);
}
}
};
return (
<Box
sx={{
display: "flex",
alignItems: "center",
backgroundColor: "#232323",
borderBottom: "1px solid #333",
height: "36px"
}}
>
<Tabs
value={activeIndex}
onChange={handleChange}
variant="scrollable"
scrollButtons="auto"
textColor="inherit"
TabIndicatorProps={{
style: { backgroundColor: "#58a6ff" }
}}
sx={{
minHeight: "36px",
height: "36px",
flexGrow: 1
}}
>
{tabs.map((tab, index) => (
<Tab
key={tab.id}
label={tab.tab_name}
value={index}
onContextMenu={(evt) => onTabRightClick(evt, tab.id)}
sx={{
minHeight: "36px",
height: "36px",
textTransform: "none",
backgroundColor: tab.id === activeTabId ? "#2C2C2C" : "transparent",
color: "#58a6ff"
}}
/>
))}
{/* The "plus" tab has a special value */}
<Tooltip title="Create a New Concurrent Tab" arrow>
<Tab
icon={<AddIcon />}
value="__addtab__"
sx={{
minHeight: "36px",
height: "36px",
color: "#58a6ff",
textTransform: "none"
}}
/>
</Tooltip>
</Tabs>
</Box>
);
}

View File

@@ -0,0 +1,485 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Node_Configuration_Sidebar.jsx
import { Box, Typography, Tabs, Tab, TextField, MenuItem, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, Button, Tooltip } from "@mui/material";
import React, { useState } from "react";
import { useReactFlow } from "reactflow";
import ReactMarkdown from "react-markdown"; // Used for Node Usage Documentation
import EditIcon from "@mui/icons-material/Edit";
import PaletteIcon from "@mui/icons-material/Palette";
import { SketchPicker } from "react-color";
// ---- NEW: Brightness utility for gradient ----
function darkenColor(hex, percent = 0.7) {
if (!/^#[0-9A-Fa-f]{6}$/.test(hex)) return hex;
let r = parseInt(hex.slice(1, 3), 16);
let g = parseInt(hex.slice(3, 5), 16);
let b = parseInt(hex.slice(5, 7), 16);
r = Math.round(r * percent);
g = Math.round(g * percent);
b = Math.round(b * percent);
return `#${r.toString(16).padStart(2,"0")}${g.toString(16).padStart(2,"0")}${b.toString(16).padStart(2,"0")}`;
}
// --------------------------------------------
export default function NodeConfigurationSidebar({ drawerOpen, setDrawerOpen, title, nodeData, setNodes, selectedNode }) {
const [activeTab, setActiveTab] = useState(0);
const contextSetNodes = useReactFlow().setNodes;
// Use setNodes from props if provided, else fallback to context (for backward compatibility)
const effectiveSetNodes = setNodes || contextSetNodes;
const handleTabChange = (_, newValue) => setActiveTab(newValue);
// Rename dialog state
const [renameOpen, setRenameOpen] = useState(false);
const [renameValue, setRenameValue] = useState(title || "");
// ---- NEW: Accent Color Picker ----
const [colorDialogOpen, setColorDialogOpen] = useState(false);
const accentColor = selectedNode?.data?.accentColor || "#58a6ff";
// ----------------------------------
const renderConfigFields = () => {
const config = nodeData?.config || [];
const nodeId = nodeData?.nodeId;
const normalizeOptions = (opts = []) =>
opts.map((opt) => {
if (typeof opt === "string") {
return { value: opt, label: opt, disabled: false };
}
if (opt && typeof opt === "object") {
const val =
opt.value ??
opt.id ??
opt.handle ??
(typeof opt.label === "string" ? opt.label : "");
const label =
opt.label ??
opt.name ??
opt.title ??
(typeof val !== "undefined" ? String(val) : "");
return {
value: typeof val === "undefined" ? "" : String(val),
label: typeof label === "undefined" ? "" : String(label),
disabled: Boolean(opt.disabled)
};
}
return { value: String(opt ?? ""), label: String(opt ?? ""), disabled: false };
});
return config.map((field, index) => {
const value = nodeData?.[field.key] ?? "";
const isReadOnly = Boolean(field.readOnly);
// ---- DYNAMIC DROPDOWN SUPPORT ----
if (field.type === "select") {
let options = field.options || [];
if (field.optionsKey && Array.isArray(nodeData?.[field.optionsKey])) {
options = nodeData[field.optionsKey];
} else if (field.dynamicOptions && nodeData?.windowList && Array.isArray(nodeData.windowList)) {
options = nodeData.windowList
.map((win) => ({
value: String(win.handle),
label: `${win.title} (${win.handle})`
}))
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
}
options = normalizeOptions(options);
// Handle dynamic options for things like Target Window
if (field.dynamicOptions && (!nodeData?.windowList || !Array.isArray(nodeData.windowList))) {
options = [];
}
return (
<Box key={index} sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ color: "#ccc", mb: 0.5 }}>
{field.label || field.key}
</Typography>
<TextField
select
fullWidth
size="small"
value={value}
onChange={(e) => {
if (isReadOnly) return;
const newValue = e.target.value;
if (!nodeId) return;
effectiveSetNodes((nds) =>
nds.map((n) =>
n.id === nodeId
? { ...n, data: { ...n.data, [field.key]: newValue } }
: n
)
);
window.BorealisValueBus[nodeId] = newValue;
}}
SelectProps={{
MenuProps: {
PaperProps: {
sx: {
bgcolor: "#1e1e1e",
color: "#ccc",
border: "1px solid #58a6ff",
"& .MuiMenuItem-root": {
color: "#ccc",
fontSize: "0.85rem",
"&:hover": {
backgroundColor: "#2a2a2a"
},
"&.Mui-selected": {
backgroundColor: "#2c2c2c !important",
color: "#58a6ff"
},
"&.Mui-selected:hover": {
backgroundColor: "#2a2a2a !important"
}
}
}
}
}
}}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1e1e1e",
color: "#ccc",
fontSize: "0.85rem",
"& fieldset": {
borderColor: "#444"
},
"&:hover fieldset": {
borderColor: "#58a6ff"
},
"&.Mui-focused fieldset": {
borderColor: "#58a6ff"
}
},
"& .MuiSelect-select": {
backgroundColor: "#1e1e1e"
}
}}
>
{options.length === 0 ? (
<MenuItem disabled value="">
{field.label === "Target Window"
? "No windows detected"
: "No options"}
</MenuItem>
) : (
options.map((opt, idx) => (
<MenuItem key={idx} value={opt.value} disabled={opt.disabled}>
{opt.label}
</MenuItem>
))
)}
</TextField>
</Box>
);
}
// ---- END DYNAMIC DROPDOWN SUPPORT ----
return (
<Box key={index} sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ color: "#ccc", mb: 0.5 }}>
{field.label || field.key}
</Typography>
<TextField
variant="outlined"
size="small"
fullWidth
value={value}
disabled={isReadOnly}
InputProps={{
readOnly: isReadOnly,
sx: {
color: "#ccc",
backgroundColor: "#1e1e1e",
"& fieldset": { borderColor: "#444" },
"&:hover fieldset": { borderColor: "#666" },
"&.Mui-focused fieldset": { borderColor: "#58a6ff" }
}
}}
onChange={(e) => {
if (isReadOnly) return;
const newValue = e.target.value;
if (!nodeId) return;
effectiveSetNodes((nds) =>
nds.map((n) =>
n.id === nodeId
? { ...n, data: { ...n.data, [field.key]: newValue } }
: n
)
);
window.BorealisValueBus[nodeId] = newValue;
}}
/>
</Box>
);
});
};
// ---- NEW: Accent Color Button ----
const renderAccentColorButton = () => (
<Tooltip title="Override Node Header/Accent Color">
<IconButton
size="small"
aria-label="Override Node Color"
onClick={() => setColorDialogOpen(true)}
sx={{
ml: 1,
border: "1px solid #58a6ff",
background: accentColor,
color: "#222",
width: 28, height: 28, p: 0
}}
>
<PaletteIcon fontSize="small" />
</IconButton>
</Tooltip>
);
// ----------------------------------
return (
<>
<Box
onClick={() => setDrawerOpen(false)}
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.3)",
opacity: drawerOpen ? 1 : 0,
pointerEvents: drawerOpen ? "auto" : "none",
transition: "opacity 0.6s ease",
zIndex: 10
}}
/>
<Box
sx={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: 400,
bgcolor: "#2C2C2C",
color: "#ccc",
borderLeft: "1px solid #333",
padding: 0,
zIndex: 11,
overflowY: "auto",
transform: drawerOpen ? "translateX(0)" : "translateX(100%)",
transition: "transform 0.3s ease"
}}
onClick={(e) => e.stopPropagation()}
>
<Box sx={{ backgroundColor: "#232323", borderBottom: "1px solid #333" }}>
<Box sx={{ padding: "12px 16px" }}>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<Typography variant="h7" sx={{ color: "#0475c2", fontWeight: "bold" }}>
{"Edit " + (title || "Node")}
</Typography>
<Box sx={{ display: "flex", alignItems: "center" }}>
<IconButton
size="small"
aria-label="Rename Node"
onClick={() => {
setRenameValue(title || "");
setRenameOpen(true);
}}
sx={{ ml: 1, color: "#58a6ff" }}
>
<EditIcon fontSize="small" />
</IconButton>
{/* ---- NEW: Accent Color Picker button next to pencil ---- */}
{renderAccentColorButton()}
{/* ------------------------------------------------------ */}
</Box>
</Box>
</Box>
<Tabs
value={activeTab}
onChange={handleTabChange}
variant="fullWidth"
textColor="inherit"
TabIndicatorProps={{ style: { backgroundColor: "#ccc" } }}
sx={{
borderTop: "1px solid #333",
borderBottom: "1px solid #333",
minHeight: "36px",
height: "36px"
}}
>
<Tab
label="Config"
sx={{
color: "#ccc",
"&.Mui-selected": { color: "#ccc" },
minHeight: "36px",
height: "36px",
textTransform: "none"
}}
/>
<Tab
label="Usage Docs"
sx={{
color: "#ccc",
"&.Mui-selected": { color: "#ccc" },
minHeight: "36px",
height: "36px",
textTransform: "none"
}}
/>
</Tabs>
</Box>
<Box sx={{ padding: 2 }}>
{activeTab === 0 && renderConfigFields()}
{activeTab === 1 && (
<Box sx={{ fontSize: "0.85rem", color: "#aaa" }}>
<ReactMarkdown
children={nodeData?.usage_documentation || "No usage documentation provided for this node."}
components={{
h3: ({ node, ...props }) => (
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 1 }} {...props} />
),
p: ({ node, ...props }) => (
<Typography paragraph sx={{ mb: 1.5 }} {...props} />
),
ul: ({ node, ...props }) => (
<ul style={{ marginBottom: "1em", paddingLeft: "1.2em" }} {...props} />
),
li: ({ node, ...props }) => (
<li style={{ marginBottom: "0.5em" }} {...props} />
)
}}
/>
</Box>
)}
</Box>
</Box>
{/* Rename Node Dialog */}
<Dialog
open={renameOpen}
onClose={() => setRenameOpen(false)}
PaperProps={{ sx: { bgcolor: "#232323" } }}
>
<DialogTitle>Rename Node</DialogTitle>
<DialogContent>
<TextField
autoFocus
fullWidth
variant="outlined"
label="Node Title"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
sx={{
mt: 1,
bgcolor: "#1e1e1e",
"& .MuiOutlinedInput-root": {
color: "#ccc",
backgroundColor: "#1e1e1e",
"& fieldset": { borderColor: "#444" }
},
label: { color: "#aaa" }
}}
/>
</DialogContent>
<DialogActions>
<Button sx={{ color: "#aaa" }} onClick={() => setRenameOpen(false)}>
Cancel
</Button>
<Button
sx={{ color: "#58a6ff" }}
onClick={() => {
// Use selectedNode (passed as prop) or nodeData?.nodeId as fallback
const nodeId = selectedNode?.id || nodeData?.nodeId;
if (!nodeId) {
setRenameOpen(false);
return;
}
effectiveSetNodes((nds) =>
nds.map((n) =>
n.id === nodeId
? { ...n, data: { ...n.data, label: renameValue } }
: n
)
);
setRenameOpen(false);
}}
>
Save
</Button>
</DialogActions>
</Dialog>
{/* ---- Accent Color Picker Dialog ---- */}
<Dialog
open={colorDialogOpen}
onClose={() => setColorDialogOpen(false)}
PaperProps={{ sx: { bgcolor: "#232323" } }}
>
<DialogTitle>Pick Node Header/Accent Color</DialogTitle>
<DialogContent>
<SketchPicker
color={accentColor}
onChangeComplete={(color) => {
const nodeId = selectedNode?.id || nodeData?.nodeId;
if (!nodeId) return;
const accent = color.hex;
const accentDark = darkenColor(accent, 0.7);
effectiveSetNodes((nds) =>
nds.map((n) =>
n.id === nodeId
? {
...n,
data: { ...n.data, accentColor: accent },
style: {
...n.style,
"--borealis-accent": accent,
"--borealis-accent-dark": accentDark,
"--borealis-title": accent,
},
}
: n
)
);
}}
disableAlpha
presetColors={[
"#58a6ff", "#0475c2", "#00d18c", "#ff4f4f", "#ff8c00",
"#6b21a8", "#0e7490", "#888", "#fff", "#000"
]}
/>
<Box sx={{ mt: 2 }}>
<Typography variant="body2">
The node's header text and accent gradient will use your selected color.<br />
The accent gradient fades to a slightly darker version.
</Typography>
<Box sx={{ mt: 2, display: "flex", alignItems: "center" }}>
<span style={{
display: "inline-block",
width: 48,
height: 22,
borderRadius: 4,
border: "1px solid #888",
background: `linear-gradient(to bottom, ${accentColor} 0%, ${darkenColor(accentColor, 0.7)} 100%)`
}} />
<span style={{ marginLeft: 10, color: accentColor, fontWeight: "bold" }}>
{accentColor}
</span>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setColorDialogOpen(false)} sx={{ color: "#aaa" }}>Close</Button>
</DialogActions>
</Dialog>
{/* ---- END ACCENT COLOR PICKER DIALOG ---- */}
</>
);
}

View File

@@ -0,0 +1,260 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Node_Sidebar.jsx
import React, { useState } from "react";
import {
Accordion,
AccordionSummary,
AccordionDetails,
Button,
Tooltip,
Typography,
Box
} from "@mui/material";
import {
ExpandMore as ExpandMoreIcon,
SaveAlt as SaveAltIcon,
Save as SaveIcon,
FileOpen as FileOpenIcon,
DeleteForever as DeleteForeverIcon,
DragIndicator as DragIndicatorIcon,
Polyline as PolylineIcon,
ChevronLeft as ChevronLeftIcon,
ChevronRight as ChevronRightIcon
} from "@mui/icons-material";
import { SaveWorkflowDialog } from "../Dialogs";
export default function NodeSidebar({
categorizedNodes,
handleExportFlow,
handleImportFlow,
handleSaveFlow,
handleOpenCloseAllDialog,
fileInputRef,
onFileInputChange,
currentTabName
}) {
const [expandedCategory, setExpandedCategory] = useState(null);
const [collapsed, setCollapsed] = useState(false);
const [saveOpen, setSaveOpen] = useState(false);
const [saveName, setSaveName] = useState("");
const handleAccordionChange = (category) => (_, isExpanded) => {
setExpandedCategory(isExpanded ? category : null);
};
return (
<div
style={{
width: collapsed ? 40 : 300,
backgroundColor: "#121212",
borderRight: "1px solid #333",
overflow: "hidden",
display: "flex",
flexDirection: "column",
height: "100%"
}}
>
<div style={{ flex: 1, overflowY: "auto" }}>
{!collapsed && (
<>
{/* Workflows Section */}
<Accordion
defaultExpanded
square
disableGutters
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
backgroundColor: "#2c2c2c",
minHeight: "36px",
"& .MuiAccordionSummary-content": { margin: 0 }
}}
>
<Typography sx={{ fontSize: "0.9rem", color: "#0475c2" }}>
<b>Workflows</b>
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
<Tooltip title="Save Current Flow to Workflows Folder" placement="right" arrow>
<Button
fullWidth
startIcon={<SaveIcon />}
onClick={() => {
setSaveName(currentTabName || "workflow");
setSaveOpen(true);
}}
sx={buttonStyle}
>
Save Workflow
</Button>
</Tooltip>
<Tooltip title="Import JSON File into New Flow Tab" placement="right" arrow>
<Button fullWidth startIcon={<FileOpenIcon />} onClick={handleImportFlow} sx={buttonStyle}>
Import Workflow (JSON)
</Button>
</Tooltip>
<Tooltip title="Export Current Tab to a JSON File" placement="right" arrow>
<Button fullWidth startIcon={<SaveAltIcon />} onClick={handleExportFlow} sx={buttonStyle}>
Export Workflow (JSON)
</Button>
</Tooltip>
</AccordionDetails>
</Accordion>
{/* Nodes Section */}
<Accordion
defaultExpanded
square
disableGutters
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
backgroundColor: "#2c2c2c",
minHeight: "36px",
"& .MuiAccordionSummary-content": { margin: 0 }
}}
>
<Typography sx={{ fontSize: "0.9rem", color: "#0475c2" }}>
<b>Nodes</b>
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ p: 0 }}>
{Object.entries(categorizedNodes).map(([category, items]) => (
<Accordion
key={category}
square
expanded={expandedCategory === category}
onChange={handleAccordionChange(category)}
disableGutters
sx={{
bgcolor: "#232323",
"&:before": { display: "none" },
margin: 0,
border: 0
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
bgcolor: "#1e1e1e",
px: 2,
minHeight: "32px",
"& .MuiAccordionSummary-content": { margin: 0 }
}}
>
<Typography sx={{ color: "#888", fontSize: "0.75rem" }}>
{category}
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 1, py: 0 }}>
{items.map((nodeDef) => (
<Tooltip
key={`${category}-${nodeDef.type}`}
title={
<span style={{ whiteSpace: "pre-line", wordWrap: "break-word", maxWidth: 220 }}>
{nodeDef.description || "Drag & Drop into Editor"}
</span>
}
placement="right"
arrow
>
<Button
fullWidth
sx={nodeButtonStyle}
draggable
onDragStart={(event) => {
event.dataTransfer.setData("application/reactflow", nodeDef.type);
event.dataTransfer.effectAllowed = "move";
}}
startIcon={<DragIndicatorIcon sx={{ color: "#666", fontSize: 18 }} />}
>
<span style={{ flexGrow: 1, textAlign: "left" }}>{nodeDef.label}</span>
<PolylineIcon sx={{ color: "#58a6ff", fontSize: 18, ml: 1 }} />
</Button>
</Tooltip>
))}
</AccordionDetails>
</Accordion>
))}
</AccordionDetails>
</Accordion>
{/* Hidden file input */}
<input
type="file"
accept=".json,application/json"
style={{ display: "none" }}
ref={fileInputRef}
onChange={onFileInputChange}
/>
</>
)}
</div>
{/* Bottom toggle button */}
<Tooltip title={collapsed ? "Expand Sidebar" : "Collapse Sidebar"} placement="left">
<Box
onClick={() => setCollapsed(!collapsed)}
sx={{
height: "36px",
borderTop: "1px solid #333",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#888",
backgroundColor: "#121212",
transition: "background-color 0.2s ease",
"&:hover": {
backgroundColor: "#1e1e1e"
},
"&:active": {
backgroundColor: "#2a2a2a"
}
}}
>
{collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
</Box>
</Tooltip>
<SaveWorkflowDialog
open={saveOpen}
value={saveName}
onChange={setSaveName}
onCancel={() => setSaveOpen(false)}
onSave={() => {
setSaveOpen(false);
handleSaveFlow(saveName);
}}
/>
</div>
);
}
const buttonStyle = {
color: "#ccc",
backgroundColor: "#232323",
justifyContent: "flex-start",
pl: 2,
fontSize: "0.9rem",
textTransform: "none",
"&:hover": {
backgroundColor: "#2a2a2a"
}
};
const nodeButtonStyle = {
color: "#ccc",
backgroundColor: "#232323",
justifyContent: "space-between",
pl: 2,
pr: 1,
fontSize: "0.9rem",
textTransform: "none",
"&:hover": {
backgroundColor: "#2a2a2a"
}
};

View File

@@ -0,0 +1,332 @@
import React, { useMemo, useState } from "react";
import { Box, TextField, Button, Typography } from "@mui/material";
export default function Login({ onLogin }) {
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [step, setStep] = useState("credentials"); // 'credentials' | 'mfa'
const [pendingToken, setPendingToken] = useState("");
const [mfaStage, setMfaStage] = useState(null);
const [mfaCode, setMfaCode] = useState("");
const [setupSecret, setSetupSecret] = useState("");
const [setupQr, setSetupQr] = useState("");
const [setupUri, setSetupUri] = useState("");
const formattedSecret = useMemo(() => {
if (!setupSecret) return "";
return setupSecret.replace(/(.{4})/g, "$1 ").trim();
}, [setupSecret]);
const sha512 = async (text) => {
try {
if (window.crypto && window.crypto.subtle && window.isSecureContext) {
const encoder = new TextEncoder();
const data = encoder.encode(text);
const hashBuffer = await window.crypto.subtle.digest("SHA-512", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
}
} catch (_) {
// fall through to return null
}
// Not a secure context or subtle crypto unavailable
return null;
};
const resetMfaState = () => {
setStep("credentials");
setPendingToken("");
setMfaStage(null);
setMfaCode("");
setSetupSecret("");
setSetupQr("");
setSetupUri("");
};
const handleCredentialsSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
setError("");
try {
const hash = await sha512(password);
const body = hash
? { username, password_sha512: hash }
: { username, password };
const resp = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body)
});
const data = await resp.json();
if (!resp.ok) {
throw new Error(data?.error || "Invalid username or password");
}
if (data?.status === "mfa_required") {
setPendingToken(data.pending_token || "");
setMfaStage(data.stage || "verify");
setStep("mfa");
setMfaCode("");
setSetupSecret(data.secret || "");
setSetupQr(data.qr_image || "");
setSetupUri(data.otpauth_url || "");
setError("");
setPassword("");
return;
}
if (data?.token) {
try {
document.cookie = `borealis_auth=${data.token}; Path=/; SameSite=Lax`;
} catch (_) {}
}
onLogin({ username: data.username, role: data.role });
} catch (err) {
const msg = err?.message || "Unable to log in";
setError(msg);
resetMfaState();
} finally {
setIsSubmitting(false);
}
};
const handleMfaSubmit = async (e) => {
e.preventDefault();
if (!pendingToken) {
setError("Your MFA session expired. Please log in again.");
resetMfaState();
return;
}
if (!mfaCode || mfaCode.trim().length < 6) {
setError("Enter the 6-digit code from your authenticator app.");
return;
}
setIsSubmitting(true);
setError("");
try {
const resp = await fetch("/api/auth/mfa/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ pending_token: pendingToken, code: mfaCode })
});
const data = await resp.json();
if (!resp.ok) {
const errKey = data?.error;
if (errKey === "expired" || errKey === "invalid_session" || errKey === "mfa_pending") {
setError("Your MFA session expired. Please log in again.");
resetMfaState();
return;
}
const msgMap = {
invalid_code: "Incorrect code. Please try again.",
mfa_not_configured: "MFA is not configured for this account."
};
setError(msgMap[errKey] || data?.error || "Failed to verify code.");
return;
}
if (data?.token) {
try {
document.cookie = `borealis_auth=${data.token}; Path=/; SameSite=Lax`;
} catch (_) {}
}
setError("");
onLogin({ username: data.username, role: data.role });
} catch (err) {
setError("Failed to verify code.");
} finally {
setIsSubmitting(false);
}
};
const handleBackToLogin = () => {
resetMfaState();
setPassword("");
setError("");
};
const onCodeChange = (event) => {
const raw = event.target.value || "";
const digits = raw.replace(/\D/g, "").slice(0, 6);
setMfaCode(digits);
};
const formTitle = step === "mfa"
? "Multi-Factor Authentication"
: "Borealis - Automation Platform";
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
backgroundColor: "#2b2b2b",
}}
>
<Box
component="form"
onSubmit={step === "mfa" ? handleMfaSubmit : handleCredentialsSubmit}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
width: 320,
}}
>
<img
src="/Borealis_Logo.png"
alt="Borealis Logo"
style={{ width: "120px", marginBottom: "16px" }}
/>
<Typography variant="h6" sx={{ mb: 2, textAlign: "center" }}>
{formTitle}
</Typography>
{step === "credentials" ? (
<>
<TextField
label="Username"
variant="outlined"
fullWidth
value={username}
disabled={isSubmitting}
onChange={(e) => setUsername(e.target.value)}
margin="normal"
/>
<TextField
label="Password"
type="password"
variant="outlined"
fullWidth
value={password}
disabled={isSubmitting}
onChange={(e) => setPassword(e.target.value)}
margin="normal"
/>
{error && (
<Typography color="error" sx={{ mt: 1 }}>
{error}
</Typography>
)}
<Button
type="submit"
variant="contained"
fullWidth
disabled={isSubmitting}
sx={{ mt: 2, bgcolor: "#58a6ff", "&:hover": { bgcolor: "#1d82d3" } }}
>
{isSubmitting ? "Signing In..." : "Login"}
</Button>
</>
) : (
<>
{mfaStage === "setup" ? (
<>
<Typography variant="body2" sx={{ color: "#ccc", textAlign: "center", mb: 2 }}>
Scan the QR code with your authenticator app, then enter the 6-digit code to complete setup for {username}.
</Typography>
{setupQr ? (
<img
src={setupQr}
alt="MFA enrollment QR code"
style={{ width: "180px", height: "180px", marginBottom: "12px" }}
/>
) : null}
{formattedSecret ? (
<Box
sx={{
bgcolor: "#1d1d1d",
borderRadius: 1,
px: 2,
py: 1,
mb: 1.5,
width: "100%",
}}
>
<Typography variant="caption" sx={{ color: "#999" }}>
Manual code
</Typography>
<Typography
variant="body1"
sx={{
fontFamily: "monospace",
letterSpacing: "0.3rem",
color: "#fff",
mt: 0.5,
textAlign: "center",
wordBreak: "break-word",
}}
>
{formattedSecret}
</Typography>
</Box>
) : null}
{setupUri ? (
<Typography
variant="caption"
sx={{
color: "#888",
mb: 2,
wordBreak: "break-all",
textAlign: "center",
}}
>
{setupUri}
</Typography>
) : null}
</>
) : (
<Typography variant="body2" sx={{ color: "#ccc", textAlign: "center", mb: 2 }}>
Enter the 6-digit code from your authenticator app for {username}.
</Typography>
)}
<TextField
label="6-digit code"
variant="outlined"
fullWidth
value={mfaCode}
onChange={onCodeChange}
disabled={isSubmitting}
margin="normal"
inputProps={{
inputMode: "numeric",
pattern: "[0-9]*",
maxLength: 6,
style: { letterSpacing: "0.4rem", textAlign: "center", fontSize: "1.2rem" }
}}
autoComplete="one-time-code"
/>
{error && (
<Typography color="error" sx={{ mt: 1, textAlign: "center" }}>
{error}
</Typography>
)}
<Button
type="submit"
variant="contained"
fullWidth
disabled={isSubmitting || mfaCode.length < 6}
sx={{ mt: 2, bgcolor: "#58a6ff", "&:hover": { bgcolor: "#1d82d3" } }}
>
{isSubmitting ? "Verifying..." : "Verify Code"}
</Button>
<Button
type="button"
variant="text"
fullWidth
disabled={isSubmitting}
onClick={handleBackToLogin}
sx={{ mt: 1, color: "#58a6ff" }}
>
Use a different account
</Button>
</>
)}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,409 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Navigation_Sidebar.jsx
import React, { useState } from "react";
import {
Accordion,
AccordionSummary,
AccordionDetails,
Typography,
Box,
ListItemButton,
ListItemText
} from "@mui/material";
import {
ExpandMore as ExpandMoreIcon,
Devices as DevicesIcon,
FilterAlt as FilterIcon,
Groups as GroupsIcon,
Work as JobsIcon,
Polyline as WorkflowsIcon,
Code as ScriptIcon,
PeopleOutline as CommunityIcon,
Apps as AssembliesIcon
} from "@mui/icons-material";
import { LocationCity as SitesIcon } from "@mui/icons-material";
import {
Dns as ServerInfoIcon,
VpnKey as CredentialIcon,
PersonOutline as UserIcon,
GitHub as GitHubIcon,
Key as KeyIcon,
AdminPanelSettings as AdminPanelSettingsIcon
} from "@mui/icons-material";
function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
const [expandedNav, setExpandedNav] = useState({
sites: true,
devices: true,
automation: true,
filters: true,
access: true,
admin: true
});
const NavItem = ({ icon, label, pageKey, indent = 0 }) => {
const active = currentPage === pageKey;
return (
<ListItemButton
onClick={() => onNavigate(pageKey)}
sx={{
pl: indent ? 4 : 2,
py: 1,
color: active ? "#e6f2ff" : "#ccc",
position: "relative",
background: active
? "linear-gradient(90deg, rgba(88,166,255,0.10) 0%, rgba(88,166,255,0.03) 60%, rgba(88,166,255,0.00) 100%)"
: "transparent",
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
boxShadow: active
? "inset 0 0 0 1px rgba(88,166,255,0.25)"
: "none",
transition: "background 160ms ease, box-shadow 160ms ease, color 160ms ease",
"&:hover": {
background: active
? "linear-gradient(90deg, rgba(88,166,255,0.14) 0%, rgba(88,166,255,0.06) 60%, rgba(88,166,255,0.00) 100%)"
: "#2c2c2c"
}
}}
selected={active}
>
<Box
sx={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: active ? 3 : 0,
bgcolor: "#58a6ff",
borderTopRightRadius: 2,
borderBottomRightRadius: 2,
boxShadow: active ? "0 0 6px rgba(88,166,255,0.35)" : "none",
transition: "width 180ms ease, box-shadow 200ms ease"
}}
/>
{icon && (
<Box
sx={{
mr: 1,
display: "flex",
alignItems: "center",
color: active ? "#7db7ff" : "#58a6ff",
transition: "color 160ms ease"
}}
>
{icon}
</Box>
)}
<ListItemText
primary={label}
primaryTypographyProps={{ fontSize: "0.75rem", fontWeight: active ? 600 : 400 }}
/>
</ListItemButton>
);
};
return (
<Box
sx={{
width: 260,
bgcolor: "#121212",
borderRight: "1px solid #333",
display: "flex",
flexDirection: "column",
overflow: "hidden"
}}
>
<Box sx={{ flex: 1, overflowY: "auto" }}>
{/* Sites */}
{(() => {
const groupActive = currentPage === "sites";
return (
<Accordion
expanded={expandedNav.sites}
onChange={(_, e) => setExpandedNav((s) => ({ ...s, sites: e }))}
square
disableGutters
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
position: "relative",
background: groupActive
? "linear-gradient(90deg, rgba(88,166,255,0.08) 0%, rgba(88,166,255,0.00) 100%)"
: "#2c2c2c",
minHeight: "36px",
"& .MuiAccordionSummary-content": { margin: 0 },
"&::before": {
content: '""',
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: groupActive ? 3 : 0,
bgcolor: "#58a6ff",
borderTopRightRadius: 2,
borderBottomRightRadius: 2,
transition: "width 160ms ease"
}
}}
>
<Typography sx={{ fontSize: "0.85rem", color: "#58a6ff" }}>
<b>Sites</b>
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
<NavItem icon={<SitesIcon fontSize="small" />} label="All Sites" pageKey="sites" />
</AccordionDetails>
</Accordion>
);
})()}
{/* Inventory */}
{(() => {
const groupActive = ["devices", "ssh_devices", "winrm_devices", "agent_devices"].includes(currentPage);
return (
<Accordion
expanded={expandedNav.devices}
onChange={(_, e) => setExpandedNav((s) => ({ ...s, devices: e }))}
square
disableGutters
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
position: "relative",
background: groupActive
? "linear-gradient(90deg, rgba(88,166,255,0.08) 0%, rgba(88,166,255,0.00) 100%)"
: "#2c2c2c",
minHeight: "36px",
"& .MuiAccordionSummary-content": { margin: 0 },
"&::before": {
content: '""',
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: groupActive ? 3 : 0,
bgcolor: "#58a6ff",
borderTopRightRadius: 2,
borderBottomRightRadius: 2,
transition: "width 160ms ease"
}
}}
>
<Typography sx={{ fontSize: "0.85rem", color: "#58a6ff" }}>
<b>Inventory</b>
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
<NavItem icon={<AdminPanelSettingsIcon fontSize="small" />} label="Device Approvals" pageKey="admin_device_approvals" />
<NavItem icon={<KeyIcon fontSize="small" />} label="Enrollment Codes" pageKey="admin_enrollment_codes" indent />
<NavItem icon={<DevicesIcon fontSize="small" />} label="Devices" pageKey="devices" />
<NavItem icon={<DevicesIcon fontSize="small" />} label="Agent Devices" pageKey="agent_devices" indent />
<NavItem icon={<DevicesIcon fontSize="small" />} label="SSH Devices" pageKey="ssh_devices" indent />
<NavItem icon={<DevicesIcon fontSize="small" />} label="WinRM Devices" pageKey="winrm_devices" indent />
</AccordionDetails>
</Accordion>
);
})()}
{/* Automation */}
{(() => {
const groupActive = ["jobs", "assemblies", "community"].includes(currentPage);
return (
<Accordion
expanded={expandedNav.automation}
onChange={(_, e) => setExpandedNav((s) => ({ ...s, automation: e }))}
square
disableGutters
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
position: "relative",
background: groupActive
? "linear-gradient(90deg, rgba(88,166,255,0.08) 0%, rgba(88,166,255,0.00) 100%)"
: "#2c2c2c",
minHeight: "36px",
"& .MuiAccordionSummary-content": { margin: 0 },
"&::before": {
content: '""',
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: groupActive ? 3 : 0,
bgcolor: "#58a6ff",
borderTopRightRadius: 2,
borderBottomRightRadius: 2,
transition: "width 160ms ease"
}
}}
>
<Typography sx={{ fontSize: "0.85rem", color: "#58a6ff" }}>
<b>Automation</b>
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
<NavItem icon={<AssembliesIcon fontSize="small" />} label="Assemblies" pageKey="assemblies" />
<NavItem icon={<JobsIcon fontSize="small" />} label="Scheduled Jobs" pageKey="jobs" />
<NavItem icon={<CommunityIcon fontSize="small" />} label="Community Content" pageKey="community" />
</AccordionDetails>
</Accordion>
);
})()}
{/* Filters & Groups */}
{(() => {
const groupActive = currentPage === "filters" || currentPage === "groups";
return (
<Accordion
expanded={expandedNav.filters}
onChange={(_, e) => setExpandedNav((s) => ({ ...s, filters: e }))}
square
disableGutters
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
position: "relative",
background: groupActive
? "linear-gradient(90deg, rgba(88,166,255,0.08) 0%, rgba(88,166,255,0.00) 100%)"
: "#2c2c2c",
minHeight: "36px",
"& .MuiAccordionSummary-content": { margin: 0 },
"&::before": {
content: '""',
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: groupActive ? 3 : 0,
bgcolor: "#58a6ff",
borderTopRightRadius: 2,
borderBottomRightRadius: 2,
transition: "width 160ms ease"
}
}}
>
<Typography sx={{ fontSize: "0.85rem", color: "#58a6ff" }}>
<b>Filters & Groups</b>
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
<NavItem icon={<FilterIcon fontSize="small" />} label="Filters" pageKey="filters" />
<NavItem icon={<GroupsIcon fontSize="small" />} label="Groups" pageKey="groups" />
</AccordionDetails>
</Accordion>
);
})()}
{/* Access Management */}
{(() => {
if (!isAdmin) return null;
const groupActive =
currentPage === "access_credentials" ||
currentPage === "access_users" ||
currentPage === "access_github_token";
return (
<Accordion
expanded={expandedNav.access}
onChange={(_, e) => setExpandedNav((s) => ({ ...s, access: e }))}
square
disableGutters
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
position: "relative",
background: groupActive
? "linear-gradient(90deg, rgba(88,166,255,0.08) 0%, rgba(88,166,255,0.00) 100%)"
: "#2c2c2c",
minHeight: "36px",
"& .MuiAccordionSummary-content": { margin: 0 },
"&::before": {
content: '""',
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: groupActive ? 3 : 0,
bgcolor: "#58a6ff",
borderTopRightRadius: 2,
borderBottomRightRadius: 2,
transition: "width 160ms ease"
}
}}
>
<Typography sx={{ fontSize: "0.85rem", color: "#58a6ff" }}>
<b>Access Management</b>
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
<NavItem icon={<CredentialIcon fontSize="small" />} label="Credentials" pageKey="access_credentials" />
<NavItem icon={<GitHubIcon fontSize="small" />} label="GitHub API Token" pageKey="access_github_token" />
<NavItem icon={<UserIcon fontSize="small" />} label="Users" pageKey="access_users" />
</AccordionDetails>
</Accordion>
);
})()}
{/* Admin */}
{(() => {
if (!isAdmin) return null;
const groupActive =
currentPage === "server_info" ||
currentPage === "admin_enrollment_codes" ||
currentPage === "admin_device_approvals";
return (
<Accordion
expanded={expandedNav.admin}
onChange={(_, e) => setExpandedNav((s) => ({ ...s, admin: e }))}
square
disableGutters
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
position: "relative",
background: groupActive
? "linear-gradient(90deg, rgba(88,166,255,0.08) 0%, rgba(88,166,255,0.00) 100%)"
: "#2c2c2c",
minHeight: "36px",
"& .MuiAccordionSummary-content": { margin: 0 },
"&::before": {
content: '""',
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: groupActive ? 3 : 0,
bgcolor: "#58a6ff",
borderTopRightRadius: 2,
borderBottomRightRadius: 2,
transition: "width 160ms ease"
}
}}
>
<Typography sx={{ fontSize: "0.85rem", color: "#58a6ff" }}>
<b>Admin Settings</b>
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
<NavItem icon={<ServerInfoIcon fontSize="small" />} label="Server Info" pageKey="server_info" />
</AccordionDetails>
</Accordion>
);
})()}
</Box>
</Box>
);
}
export default React.memo(NavigationSidebar);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,593 @@
import React, { useEffect, useState, useCallback } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
Paper,
FormControlLabel,
Checkbox,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
CircularProgress
} from "@mui/material";
import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material";
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
function buildTree(items, folders, rootLabel = "Scripts") {
const map = {};
const rootNode = {
id: "root",
label: rootLabel,
path: "",
isFolder: true,
children: []
};
map[rootNode.id] = rootNode;
(folders || []).forEach((f) => {
const parts = (f || "").split("/");
let children = rootNode.children;
let parentPath = "";
parts.forEach((part) => {
const path = parentPath ? `${parentPath}/${part}` : part;
let node = children.find((n) => n.id === path);
if (!node) {
node = { id: path, label: part, path, isFolder: true, children: [] };
children.push(node);
map[path] = node;
}
children = node.children;
parentPath = path;
});
});
(items || []).forEach((s) => {
const parts = (s.rel_path || "").split("/");
let children = rootNode.children;
let parentPath = "";
parts.forEach((part, idx) => {
const path = parentPath ? `${parentPath}/${part}` : part;
const isFile = idx === parts.length - 1;
let node = children.find((n) => n.id === path);
if (!node) {
node = {
id: path,
label: isFile ? (s.name || s.file_name || part) : part,
path,
isFolder: !isFile,
fileName: s.file_name,
script: isFile ? s : null,
children: []
};
children.push(node);
map[path] = node;
}
if (!isFile) {
children = node.children;
parentPath = path;
}
});
});
return { root: [rootNode], map };
}
export default function QuickJob({ open, onClose, hostnames = [] }) {
const [tree, setTree] = useState([]);
const [nodeMap, setNodeMap] = useState({});
const [selectedPath, setSelectedPath] = useState("");
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
const [runAsCurrentUser, setRunAsCurrentUser] = useState(false);
const [mode, setMode] = useState("scripts"); // 'scripts' | 'ansible'
const [credentials, setCredentials] = useState([]);
const [credentialsLoading, setCredentialsLoading] = useState(false);
const [credentialsError, setCredentialsError] = useState("");
const [selectedCredentialId, setSelectedCredentialId] = useState("");
const [useSvcAccount, setUseSvcAccount] = useState(true);
const [variables, setVariables] = useState([]);
const [variableValues, setVariableValues] = useState({});
const [variableErrors, setVariableErrors] = useState({});
const [variableStatus, setVariableStatus] = useState({ loading: false, error: "" });
const loadTree = useCallback(async () => {
try {
const island = mode === 'ansible' ? 'ansible' : 'scripts';
const resp = await fetch(`/api/assembly/list?island=${island}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const { root, map } = buildTree(data.items || [], data.folders || [], mode === 'ansible' ? 'Ansible Playbooks' : 'Scripts');
setTree(root);
setNodeMap(map);
} catch (err) {
console.error("Failed to load scripts:", err);
setTree([]);
setNodeMap({});
}
}, [mode]);
useEffect(() => {
if (open) {
setSelectedPath("");
setError("");
setVariables([]);
setVariableValues({});
setVariableErrors({});
setVariableStatus({ loading: false, error: "" });
setUseSvcAccount(true);
setSelectedCredentialId("");
loadTree();
}
}, [open, loadTree]);
useEffect(() => {
if (!open || mode !== "ansible") return;
let canceled = false;
setCredentialsLoading(true);
setCredentialsError("");
(async () => {
try {
const resp = await fetch("/api/credentials");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (canceled) return;
const list = Array.isArray(data?.credentials)
? data.credentials.filter((cred) => {
const conn = String(cred.connection_type || "").toLowerCase();
return conn === "ssh" || conn === "winrm";
})
: [];
list.sort((a, b) => String(a?.name || "").localeCompare(String(b?.name || "")));
setCredentials(list);
} catch (err) {
if (!canceled) {
setCredentials([]);
setCredentialsError(String(err.message || err));
}
} finally {
if (!canceled) setCredentialsLoading(false);
}
})();
return () => {
canceled = true;
};
}, [open, mode]);
useEffect(() => {
if (!open) {
setSelectedCredentialId("");
}
}, [open]);
useEffect(() => {
if (mode !== "ansible" || useSvcAccount) return;
if (!credentials.length) {
setSelectedCredentialId("");
return;
}
if (!selectedCredentialId || !credentials.some((cred) => String(cred.id) === String(selectedCredentialId))) {
setSelectedCredentialId(String(credentials[0].id));
}
}, [mode, credentials, selectedCredentialId, useSvcAccount]);
const renderNodes = (nodes = []) =>
nodes.map((n) => (
<TreeItem
key={n.id}
itemId={n.id}
label={
<Box sx={{ display: "flex", alignItems: "center" }}>
{n.isFolder ? (
<FolderIcon fontSize="small" sx={{ mr: 1, color: "#ccc" }} />
) : (
<DescriptionIcon fontSize="small" sx={{ mr: 1, color: "#ccc" }} />
)}
<Typography variant="body2" sx={{ color: "#e6edf3" }}>{n.label}</Typography>
</Box>
}
>
{n.children && n.children.length ? renderNodes(n.children) : null}
</TreeItem>
));
const onItemSelect = (_e, itemId) => {
const node = nodeMap[itemId];
if (node && !node.isFolder) {
setSelectedPath(node.path);
setError("");
setVariableErrors({});
}
};
const normalizeVariables = (list) => {
if (!Array.isArray(list)) return [];
return list
.map((raw) => {
if (!raw || typeof raw !== "object") return null;
const name = typeof raw.name === "string" ? raw.name.trim() : typeof raw.key === "string" ? raw.key.trim() : "";
if (!name) return null;
const type = typeof raw.type === "string" ? raw.type.toLowerCase() : "string";
const label = typeof raw.label === "string" && raw.label.trim() ? raw.label.trim() : name;
const description = typeof raw.description === "string" ? raw.description : "";
const required = Boolean(raw.required);
const defaultValue = raw.hasOwnProperty("default")
? raw.default
: raw.hasOwnProperty("defaultValue")
? raw.defaultValue
: raw.hasOwnProperty("default_value")
? raw.default_value
: "";
return { name, label, type, description, required, default: defaultValue };
})
.filter(Boolean);
};
const deriveInitialValue = (variable) => {
const { type, default: defaultValue } = variable;
if (type === "boolean") {
if (typeof defaultValue === "boolean") return defaultValue;
if (defaultValue == null) return false;
const str = String(defaultValue).trim().toLowerCase();
if (!str) return false;
return ["true", "1", "yes", "on"].includes(str);
}
if (type === "number") {
if (defaultValue == null || defaultValue === "") return "";
if (typeof defaultValue === "number" && Number.isFinite(defaultValue)) {
return String(defaultValue);
}
const parsed = Number(defaultValue);
return Number.isFinite(parsed) ? String(parsed) : "";
}
return defaultValue == null ? "" : String(defaultValue);
};
useEffect(() => {
if (!selectedPath) {
setVariables([]);
setVariableValues({});
setVariableErrors({});
setVariableStatus({ loading: false, error: "" });
return;
}
let canceled = false;
const loadAssembly = async () => {
setVariableStatus({ loading: true, error: "" });
try {
const island = mode === "ansible" ? "ansible" : "scripts";
const trimmed = (selectedPath || "").replace(/\\/g, "/").replace(/^\/+/, "").trim();
if (!trimmed) {
setVariables([]);
setVariableValues({});
setVariableErrors({});
setVariableStatus({ loading: false, error: "" });
return;
}
let relPath = trimmed;
if (island === "scripts" && relPath.toLowerCase().startsWith("scripts/")) {
relPath = relPath.slice("Scripts/".length);
} else if (island === "ansible" && relPath.toLowerCase().startsWith("ansible_playbooks/")) {
relPath = relPath.slice("Ansible_Playbooks/".length);
}
const resp = await fetch(`/api/assembly/load?island=${island}&path=${encodeURIComponent(relPath)}`);
if (!resp.ok) throw new Error(`Failed to load assembly (HTTP ${resp.status})`);
const data = await resp.json();
const defs = normalizeVariables(data?.assembly?.variables || []);
if (!canceled) {
setVariables(defs);
const initialValues = {};
defs.forEach((v) => {
initialValues[v.name] = deriveInitialValue(v);
});
setVariableValues(initialValues);
setVariableErrors({});
setVariableStatus({ loading: false, error: "" });
}
} catch (err) {
if (!canceled) {
setVariables([]);
setVariableValues({});
setVariableErrors({});
setVariableStatus({ loading: false, error: err?.message || String(err) });
}
}
};
loadAssembly();
return () => {
canceled = true;
};
}, [selectedPath, mode]);
const handleVariableChange = (variable, rawValue) => {
const { name, type } = variable;
if (!name) return;
setVariableValues((prev) => ({
...prev,
[name]: type === "boolean" ? Boolean(rawValue) : rawValue
}));
setVariableErrors((prev) => {
if (!prev[name]) return prev;
const next = { ...prev };
delete next[name];
return next;
});
};
const buildVariablePayload = () => {
const payload = {};
variables.forEach((variable) => {
if (!variable?.name) return;
const { name, type } = variable;
const hasOverride = Object.prototype.hasOwnProperty.call(variableValues, name);
const raw = hasOverride ? variableValues[name] : deriveInitialValue(variable);
if (type === "boolean") {
payload[name] = Boolean(raw);
} else if (type === "number") {
if (raw === "" || raw === null || raw === undefined) {
payload[name] = "";
} else {
const num = Number(raw);
payload[name] = Number.isFinite(num) ? num : "";
}
} else {
payload[name] = raw == null ? "" : String(raw);
}
});
return payload;
};
const onRun = async () => {
if (!selectedPath) {
setError(mode === 'ansible' ? "Please choose a playbook to run." : "Please choose a script to run.");
return;
}
if (mode === 'ansible' && !useSvcAccount && !selectedCredentialId) {
setError("Select a credential to run this playbook.");
return;
}
if (variables.length) {
const errors = {};
variables.forEach((variable) => {
if (!variable) return;
if (!variable.required) return;
if (variable.type === "boolean") return;
const hasOverride = Object.prototype.hasOwnProperty.call(variableValues, variable.name);
const raw = hasOverride ? variableValues[variable.name] : deriveInitialValue(variable);
if (raw == null || raw === "") {
errors[variable.name] = "Required";
}
});
if (Object.keys(errors).length) {
setVariableErrors(errors);
setError("Please fill in all required variable values.");
return;
}
}
setRunning(true);
setError("");
try {
let resp;
const variableOverrides = buildVariablePayload();
if (mode === 'ansible') {
const playbook_path = selectedPath; // relative to ansible island
resp = await fetch("/api/ansible/quick_run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
playbook_path,
hostnames,
variable_values: variableOverrides,
credential_id: !useSvcAccount && selectedCredentialId ? Number(selectedCredentialId) : null,
use_service_account: Boolean(useSvcAccount)
})
});
} else {
// quick_run expects a path relative to Assemblies root with 'Scripts/' prefix
const script_path = selectedPath.startsWith('Scripts/') ? selectedPath : `Scripts/${selectedPath}`;
resp = await fetch("/api/scripts/quick_run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
script_path,
hostnames,
run_mode: runAsCurrentUser ? "current_user" : "system",
variable_values: variableOverrides
})
});
}
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
onClose && onClose();
} catch (err) {
setError(String(err.message || err));
} finally {
setRunning(false);
}
};
const credentialRequired = mode === "ansible" && !useSvcAccount;
const disableRun =
running ||
!selectedPath ||
(credentialRequired && (!selectedCredentialId || !credentials.length));
return (
<Dialog open={open} onClose={running ? undefined : onClose} fullWidth maxWidth="md"
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
>
<DialogTitle>Quick Job</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Button size="small" variant={mode === 'scripts' ? 'outlined' : 'text'} onClick={() => setMode('scripts')} sx={{ textTransform: 'none', color: '#58a6ff', borderColor: '#58a6ff' }}>Scripts</Button>
<Button size="small" variant={mode === 'ansible' ? 'outlined' : 'text'} onClick={() => setMode('ansible')} sx={{ textTransform: 'none', color: '#58a6ff', borderColor: '#58a6ff' }}>Ansible</Button>
</Box>
<Typography variant="body2" sx={{ color: "#aaa", mb: 1 }}>
Select a {mode === 'ansible' ? 'playbook' : 'script'} to run on {hostnames.length} device{hostnames.length !== 1 ? "s" : ""}.
</Typography>
{mode === 'ansible' && (
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap", mb: 2 }}>
<FormControlLabel
control={
<Checkbox
checked={useSvcAccount}
onChange={(e) => {
const checked = e.target.checked;
setUseSvcAccount(checked);
if (checked) {
setSelectedCredentialId("");
} else if (!selectedCredentialId && credentials.length) {
setSelectedCredentialId(String(credentials[0].id));
}
}}
size="small"
/>
}
label="Use Configured svcBorealis Account"
sx={{ mr: 2 }}
/>
<FormControl
size="small"
sx={{ minWidth: 260 }}
disabled={useSvcAccount || credentialsLoading || !credentials.length}
>
<InputLabel sx={{ color: "#aaa" }}>Credential</InputLabel>
<Select
value={selectedCredentialId}
label="Credential"
onChange={(e) => setSelectedCredentialId(e.target.value)}
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
>
{credentials.map((cred) => {
const conn = String(cred.connection_type || "").toUpperCase();
return (
<MenuItem key={cred.id} value={String(cred.id)}>
{cred.name}
{conn ? ` (${conn})` : ""}
</MenuItem>
);
})}
</Select>
</FormControl>
{useSvcAccount && (
<Typography variant="body2" sx={{ color: "#aaa" }}>
Runs with the agent&apos;s svcBorealis account.
</Typography>
)}
{credentialsLoading && <CircularProgress size={18} sx={{ color: "#58a6ff" }} />}
{!credentialsLoading && credentialsError && (
<Typography variant="body2" sx={{ color: "#ff8080" }}>{credentialsError}</Typography>
)}
{!useSvcAccount && !credentialsLoading && !credentialsError && !credentials.length && (
<Typography variant="body2" sx={{ color: "#ff8080" }}>
No SSH or WinRM credentials available. Create one under Access Management.
</Typography>
)}
</Box>
)}
<Box sx={{ display: "flex", gap: 2 }}>
<Paper sx={{ flex: 1, p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
<SimpleTreeView sx={{ color: "#e6edf3" }} onItemSelectionToggle={onItemSelect}>
{tree.length ? renderNodes(tree) : (
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>
{mode === 'ansible' ? 'No playbooks found.' : 'No scripts found.'}
</Typography>
)}
</SimpleTreeView>
</Paper>
<Box sx={{ width: 320 }}>
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Selection</Typography>
<Typography variant="body2" sx={{ color: selectedPath ? "#e6edf3" : "#888" }}>
{selectedPath || (mode === 'ansible' ? 'No playbook selected' : 'No script selected')}
</Typography>
<Box sx={{ mt: 2 }}>
{mode !== 'ansible' && (
<>
<FormControlLabel
control={<Checkbox size="small" checked={runAsCurrentUser} onChange={(e) => setRunAsCurrentUser(e.target.checked)} />}
label={<Typography variant="body2">Run as currently logged-in user</Typography>}
/>
<Typography variant="caption" sx={{ color: "#888" }}>
Unchecked = Run-As BUILTIN\SYSTEM
</Typography>
</>
)}
</Box>
<Box sx={{ mt: 3 }}>
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Variables</Typography>
{variableStatus.loading ? (
<Typography variant="body2" sx={{ color: "#888" }}>Loading variables</Typography>
) : variableStatus.error ? (
<Typography variant="body2" sx={{ color: "#ff4f4f" }}>{variableStatus.error}</Typography>
) : variables.length ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{variables.map((variable) => (
<Box key={variable.name}>
{variable.type === "boolean" ? (
<FormControlLabel
control={(
<Checkbox
size="small"
checked={Boolean(variableValues[variable.name])}
onChange={(e) => handleVariableChange(variable, e.target.checked)}
/>
)}
label={
<Typography variant="body2">
{variable.label}
{variable.required ? " *" : ""}
</Typography>
}
/>
) : (
<TextField
fullWidth
size="small"
label={`${variable.label}${variable.required ? " *" : ""}`}
type={variable.type === "number" ? "number" : variable.type === "credential" ? "password" : "text"}
value={variableValues[variable.name] ?? ""}
onChange={(e) => handleVariableChange(variable, e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{
"& .MuiOutlinedInput-root": { bgcolor: "#1b1b1b", color: "#e6edf3" },
"& .MuiInputBase-input": { color: "#e6edf3" }
}}
error={Boolean(variableErrors[variable.name])}
helperText={variableErrors[variable.name] || variable.description || ""}
/>
)}
{variable.type === "boolean" && variable.description ? (
<Typography variant="caption" sx={{ color: "#888", ml: 3 }}>
{variable.description}
</Typography>
) : null}
</Box>
))}
</Box>
) : (
<Typography variant="body2" sx={{ color: "#888" }}>No variables defined for this assembly.</Typography>
)}
</Box>
{error && (
<Typography variant="body2" sx={{ color: "#ff4f4f", mt: 1 }}>{error}</Typography>
)}
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={running} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onRun} disabled={disableRun}
sx={{ color: disableRun ? "#666" : "#58a6ff" }}
>
Run
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,685 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Scheduled_Jobs_List.jsx
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState
} from "react";
import {
Paper,
Box,
Typography,
Button,
Switch,
Dialog,
DialogTitle,
DialogActions,
CircularProgress
} from "@mui/material";
import { AgGridReact } from "ag-grid-react";
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
ModuleRegistry.registerModules([AllCommunityModule]);
const myTheme = themeQuartz.withParams({
accentColor: "#FFA6FF",
backgroundColor: "#1f2836",
browserColorScheme: "dark",
chromeBackgroundColor: {
ref: "foregroundColor",
mix: 0.07,
onto: "backgroundColor"
},
fontFamily: {
googleFont: "IBM Plex Sans"
},
foregroundColor: "#FFF",
headerFontSize: 14
});
const themeClassName = myTheme.themeName || "ag-theme-quartz";
const gridFontFamily = '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif';
const iconFontFamily = '"Quartz Regular"';
function ResultsBar({ counts }) {
const total = Math.max(1, Number(counts?.total_targets || 0));
const sections = [
{ key: "success", color: "#00d18c" },
{ key: "running", color: "#58a6ff" },
{ key: "failed", color: "#ff4f4f" },
{ key: "timed_out", color: "#b36ae2" },
{ key: "expired", color: "#777777" },
{ key: "pending", color: "#999999" }
];
const labelFor = (key) =>
key === "pending"
? "Scheduled"
: key
.replace(/_/g, " ")
.replace(/^./, (c) => c.toUpperCase());
const hasNonPending = sections
.filter((section) => section.key !== "pending")
.some((section) => Number(counts?.[section.key] || 0) > 0);
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 0.25,
lineHeight: 1.7,
fontFamily: gridFontFamily
}}
>
<Box
sx={{
display: "flex",
borderRadius: 1,
overflow: "hidden",
width: 220,
height: 6
}}
>
{sections.map((section) => {
const value = Number(counts?.[section.key] || 0);
if (!value) return null;
const width = `${Math.round((value / total) * 100)}%`;
return (
<Box
key={section.key}
component="span"
sx={{ display: "block", height: "100%", width, backgroundColor: section.color }}
/>
);
})}
</Box>
<Box
sx={{
display: "flex",
flexWrap: "wrap",
columnGap: 0.75,
rowGap: 0.25,
color: "#aaa",
fontSize: 11,
fontFamily: gridFontFamily
}}
>
{(() => {
if (!hasNonPending && Number(counts?.pending || 0) > 0) {
return <Box component="span">Scheduled</Box>;
}
return sections
.filter((section) => Number(counts?.[section.key] || 0) > 0)
.map((section) => (
<Box
key={section.key}
component="span"
sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}
>
<Box
component="span"
sx={{
width: 6,
height: 6,
borderRadius: 1,
backgroundColor: section.color
}}
/>
{counts?.[section.key]} {labelFor(section.key)}
</Box>
));
})()}
</Box>
</Box>
);
}
export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken }) {
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState(() => new Set());
const gridApiRef = useRef(null);
const loadJobs = useCallback(
async ({ showLoading = false } = {}) => {
if (showLoading) {
setLoading(true);
setError("");
}
try {
const resp = await fetch("/api/scheduled_jobs");
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
const pretty = (st) => {
const s = String(st || "").toLowerCase();
const map = {
immediately: "Immediately",
once: "Once",
every_5_minutes: "Every 5 Minutes",
every_10_minutes: "Every 10 Minutes",
every_15_minutes: "Every 15 Minutes",
every_30_minutes: "Every 30 Minutes",
every_hour: "Every Hour",
daily: "Daily",
weekly: "Weekly",
monthly: "Monthly",
yearly: "Yearly"
};
if (map[s]) return map[s];
try {
return s.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
} catch {
return String(st || "");
}
};
const fmt = (ts) => {
if (!ts) return "";
try {
const d = new Date(Number(ts) * 1000);
if (Number.isNaN(d?.getTime())) return "";
return d.toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "numeric",
minute: "2-digit"
});
} catch {
return "";
}
};
const mappedRows = (data?.jobs || []).map((j) => {
const compName = (Array.isArray(j.components) && j.components[0]?.name) || "Demonstration Component";
const targetText = Array.isArray(j.targets)
? `${j.targets.length} device${j.targets.length !== 1 ? "s" : ""}`
: "";
const occurrence = pretty(j.schedule_type || "immediately");
const resultsCounts = {
total_targets: Array.isArray(j.targets) ? j.targets.length : 0,
pending: Array.isArray(j.targets) ? j.targets.length : 0,
...(j.result_counts || {})
};
if (resultsCounts && resultsCounts.total_targets == null) {
resultsCounts.total_targets = Array.isArray(j.targets) ? j.targets.length : 0;
}
return {
id: j.id,
name: j.name,
scriptWorkflow: compName,
target: targetText,
occurrence,
lastRun: fmt(j.last_run_ts),
nextRun: fmt(j.next_run_ts || j.start_ts),
result: j.last_status || (j.next_run_ts ? "Scheduled" : ""),
resultsCounts,
enabled: Boolean(j.enabled),
raw: j
};
});
setRows(mappedRows);
setError("");
setSelectedIds((prev) => {
if (!prev.size) return prev;
const valid = new Set(
mappedRows.map((row, index) => row.id ?? row.name ?? String(index))
);
let changed = false;
const next = new Set();
prev.forEach((value) => {
if (valid.has(value)) {
next.add(value);
} else {
changed = true;
}
});
return changed ? next : prev;
});
} catch (err) {
setRows([]);
setSelectedIds(() => new Set());
setError(String(err?.message || err || "Failed to load scheduled jobs"));
} finally {
if (showLoading) {
setLoading(false);
}
}
},
[]
);
useEffect(() => {
let timer;
let isMounted = true;
(async () => {
if (!isMounted) return;
await loadJobs({ showLoading: true });
})();
timer = setInterval(() => {
loadJobs();
}, 5000);
return () => {
isMounted = false;
if (timer) clearInterval(timer);
};
}, [loadJobs, refreshToken]);
const handleGridReady = useCallback((params) => {
gridApiRef.current = params.api;
}, []);
useEffect(() => {
const api = gridApiRef.current;
if (!api) return;
if (loading) {
api.showLoadingOverlay();
} else if (!rows.length) {
api.showNoRowsOverlay();
} else {
api.hideOverlay();
}
}, [loading, rows]);
useEffect(() => {
const api = gridApiRef.current;
if (!api) return;
api.forEachNode((node) => {
const shouldSelect = selectedIds.has(node.id);
if (node.isSelected() !== shouldSelect) {
node.setSelected(shouldSelect);
}
});
}, [rows, selectedIds]);
const anySelected = selectedIds.size > 0;
const handleSelectionChanged = useCallback(() => {
const api = gridApiRef.current;
if (!api) return;
const selectedNodes = api.getSelectedNodes();
const next = new Set();
selectedNodes.forEach((node) => {
if (node?.id != null) {
next.add(String(node.id));
}
});
setSelectedIds(next);
}, []);
const getRowId = useCallback((params) => {
return (
params?.data?.id ??
params?.data?.name ??
String(params?.rowIndex ?? "")
);
}, []);
const nameCellRenderer = useCallback(
(params) => {
const row = params.data;
if (!row) return null;
const handleClick = (event) => {
event.preventDefault();
event.stopPropagation();
if (typeof onEditJob === "function") {
onEditJob(row.raw);
}
};
return (
<Button
onClick={handleClick}
sx={{
color: "#58a6ff",
textTransform: "none",
p: 0,
minWidth: 0,
fontFamily: gridFontFamily
}}
>
{row.name || "-"}
</Button>
);
},
[onEditJob]
);
const resultsCellRenderer = useCallback((params) => {
return <ResultsBar counts={params?.data?.resultsCounts} />;
}, []);
const enabledCellRenderer = useCallback(
(params) => {
const row = params.data;
if (!row) return null;
const handleToggle = async (event) => {
event.stopPropagation();
const nextEnabled = event.target.checked;
try {
await fetch(`/api/scheduled_jobs/${row.id}/toggle`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: nextEnabled })
});
} catch {
// ignore network errors for toggle
}
setRows((prev) =>
prev.map((job) => {
if ((job.id ?? job.name) === (row.id ?? row.name)) {
const updatedRaw = { ...(job.raw || {}), enabled: nextEnabled };
return { ...job, enabled: nextEnabled, raw: updatedRaw };
}
return job;
})
);
};
return (
<Switch
size="small"
checked={Boolean(row.enabled)}
onChange={handleToggle}
onClick={(event) => event.stopPropagation()}
sx={{
"& .MuiSwitch-switchBase.Mui-checked": {
color: "#58a6ff"
},
"& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track": {
bgcolor: "#58a6ff"
}
}}
/>
);
},
[]
);
const columnDefs = useMemo(
() => [
{
headerName: "",
field: "__checkbox__",
checkboxSelection: true,
headerCheckboxSelection: true,
maxWidth: 60,
minWidth: 60,
sortable: false,
filter: false,
resizable: false,
suppressMenu: true,
pinned: false
},
{
headerName: "Name",
field: "name",
cellRenderer: nameCellRenderer,
sort: "asc"
},
{
headerName: "Assembly(s)",
field: "scriptWorkflow",
valueGetter: (params) => params.data?.scriptWorkflow || "Demonstration Component"
},
{
headerName: "Target",
field: "target"
},
{
headerName: "Recurrence",
field: "occurrence"
},
{
headerName: "Last Run",
field: "lastRun"
},
{
headerName: "Next Run",
field: "nextRun"
},
{
headerName: "Results",
field: "resultsCounts",
minWidth: 280,
cellRenderer: resultsCellRenderer,
sortable: false,
filter: false
},
{
headerName: "Enabled",
field: "enabled",
minWidth: 140,
maxWidth: 160,
cellRenderer: enabledCellRenderer,
sortable: false,
filter: false,
resizable: false,
suppressMenu: true
}
],
[enabledCellRenderer, nameCellRenderer, resultsCellRenderer]
);
const defaultColDef = useMemo(
() => ({
sortable: true,
filter: "agTextColumnFilter",
resizable: true,
flex: 1,
minWidth: 140,
cellStyle: {
display: "flex",
alignItems: "center",
color: "#f5f7fa",
fontFamily: gridFontFamily,
fontSize: "13px"
},
headerClass: "scheduled-jobs-grid-header"
}),
[]
);
return (
<Paper
sx={{
m: 2,
p: 0,
bgcolor: "#1e1e1e",
color: "#f5f7fa",
fontFamily: gridFontFamily,
display: "flex",
flexDirection: "column",
flexGrow: 1,
minWidth: 0,
minHeight: 420
}}
elevation={2}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
p: 2,
borderBottom: "1px solid #2a2a2a"
}}
>
<Box>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0.3 }}>
Scheduled Jobs
</Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}>
List of automation jobs with schedules, results, and actions.
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<Button
variant="outlined"
size="small"
disabled={!anySelected}
sx={{
color: anySelected ? "#ff8080" : "#666",
borderColor: anySelected ? "#ff8080" : "#333",
textTransform: "none",
fontFamily: gridFontFamily,
"&:hover": {
borderColor: anySelected ? "#ff8080" : "#333"
}
}}
onClick={() => setBulkDeleteOpen(true)}
>
Delete Job
</Button>
<Button
variant="contained"
size="small"
sx={{
bgcolor: "#58a6ff",
color: "#0b0f19",
textTransform: "none",
fontFamily: gridFontFamily,
"&:hover": {
bgcolor: "#7db7ff"
}
}}
onClick={() => onCreateJob && onCreateJob()}
>
Create Job
</Button>
</Box>
</Box>
{loading && (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
color: "#7db7ff",
px: 2,
py: 1.5,
borderBottom: "1px solid #2a2a2a"
}}
>
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
<Typography variant="body2">Loading scheduled jobs</Typography>
</Box>
)}
{error && (
<Box sx={{ px: 2, py: 1.5, color: "#ff8080", borderBottom: "1px solid #2a2a2a" }}>
<Typography variant="body2">{error}</Typography>
</Box>
)}
<Box
sx={{
flexGrow: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
mt: "10px",
px: 2,
pb: 2
}}
>
<Box
className={themeClassName}
sx={{
width: "100%",
height: "100%",
flexGrow: 1,
fontFamily: gridFontFamily,
"--ag-font-family": gridFontFamily,
"--ag-icon-font-family": iconFontFamily,
"--ag-row-border-style": "solid",
"--ag-row-border-color": "#2a2a2a",
"--ag-row-border-width": "1px",
"& .ag-root-wrapper": {
borderRadius: 1,
minHeight: 320
},
"& .ag-root, & .ag-header, & .ag-center-cols-container, & .ag-paging-panel": {
fontFamily: gridFontFamily
},
"& .ag-icon": {
fontFamily: iconFontFamily
},
"& .scheduled-jobs-grid-header": {
fontFamily: gridFontFamily,
fontWeight: 600,
color: "#f5f7fa"
}
}}
>
<AgGridReact
rowData={rows}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
animateRows
rowHeight={46}
headerHeight={44}
suppressCellFocus
rowSelection="multiple"
rowMultiSelectWithClick
suppressRowClickSelection
getRowId={getRowId}
overlayNoRowsTemplate="<span class='ag-overlay-no-rows-center'>No scheduled jobs found.</span>"
onGridReady={handleGridReady}
onSelectionChanged={handleSelectionChanged}
theme={myTheme}
style={{
width: "100%",
height: "100%",
fontFamily: gridFontFamily,
"--ag-icon-font-family": iconFontFamily
}}
/>
</Box>
</Box>
<Dialog
open={bulkDeleteOpen}
onClose={() => setBulkDeleteOpen(false)}
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
>
<DialogTitle>Are you sure you want to delete this job(s)?</DialogTitle>
<DialogActions>
<Button onClick={() => setBulkDeleteOpen(false)} sx={{ color: "#58a6ff" }}>
Cancel
</Button>
<Button
onClick={async () => {
try {
const ids = Array.from(selectedIds);
const idSet = new Set(ids);
await Promise.allSettled(
ids.map((id) => fetch(`/api/scheduled_jobs/${id}`, { method: "DELETE" }))
);
setRows((prev) =>
prev.filter((job, index) => {
const key = getRowId({ data: job, rowIndex: index });
return !idSet.has(key);
})
);
setSelectedIds(() => new Set());
} catch {
// ignore delete errors here; a fresh load will surface them
}
setBulkDeleteOpen(false);
await loadJobs({ showLoading: true });
}}
variant="outlined"
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
>
Confirm
</Button>
</DialogActions>
</Dialog>
</Paper>
);
}

View File

@@ -0,0 +1,385 @@
import React, { useEffect, useMemo, useState, useCallback, useRef } from "react";
import {
Paper,
Box,
Typography,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TableSortLabel,
Checkbox,
Button,
IconButton,
Popover,
TextField,
MenuItem
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import DeleteIcon from "@mui/icons-material/DeleteOutline";
import EditIcon from "@mui/icons-material/Edit";
import FilterListIcon from "@mui/icons-material/FilterList";
import ViewColumnIcon from "@mui/icons-material/ViewColumn";
import { CreateSiteDialog, ConfirmDeleteDialog, RenameSiteDialog } from "../Dialogs.jsx";
export default function SiteList({ onOpenDevicesForSite }) {
const [rows, setRows] = useState([]); // {id, name, description, device_count}
const [orderBy, setOrderBy] = useState("name");
const [order, setOrder] = useState("asc");
const [selectedIds, setSelectedIds] = useState(() => new Set());
// Columns configuration (similar style to Device_List)
const COL_LABELS = useMemo(() => ({
name: "Name",
description: "Description",
device_count: "Devices",
}), []);
const defaultColumns = useMemo(
() => [
{ id: "name", label: COL_LABELS.name },
{ id: "description", label: COL_LABELS.description },
{ id: "device_count", label: COL_LABELS.device_count },
],
[COL_LABELS]
);
const [columns, setColumns] = useState(defaultColumns);
const dragColId = useRef(null);
const [colChooserAnchor, setColChooserAnchor] = useState(null);
const [filters, setFilters] = useState({});
const [filterAnchor, setFilterAnchor] = useState(null); // { id, anchorEl }
const [createOpen, setCreateOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [renameOpen, setRenameOpen] = useState(false);
const [renameValue, setRenameValue] = useState("");
const fetchSites = useCallback(async () => {
try {
const res = await fetch("/api/sites");
const data = await res.json();
setRows(Array.isArray(data?.sites) ? data.sites : []);
} catch {
setRows([]);
}
}, []);
useEffect(() => { fetchSites(); }, [fetchSites]);
// Apply initial filters from global search
useEffect(() => {
try {
const json = localStorage.getItem('site_list_initial_filters');
if (json) {
const obj = JSON.parse(json);
if (obj && typeof obj === 'object') setFilters((prev) => ({ ...prev, ...obj }));
localStorage.removeItem('site_list_initial_filters');
}
} catch {}
}, []);
const handleSort = (col) => {
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
else { setOrderBy(col); setOrder("asc"); }
};
const filtered = useMemo(() => {
if (!filters || Object.keys(filters).length === 0) return rows;
return rows.filter((r) =>
Object.entries(filters).every(([k, v]) => {
const val = String(v || "").toLowerCase();
if (!val) return true;
return String(r[k] ?? "").toLowerCase().includes(val);
})
);
}, [rows, filters]);
const sorted = useMemo(() => {
const dir = order === "asc" ? 1 : -1;
const arr = [...filtered];
arr.sort((a, b) => {
if (orderBy === "device_count") return ((a.device_count||0) - (b.device_count||0)) * dir;
return String(a[orderBy] ?? "").localeCompare(String(b[orderBy] ?? "")) * dir;
});
return arr;
}, [filtered, orderBy, order]);
const onHeaderDragStart = (colId) => (e) => { dragColId.current = colId; try { e.dataTransfer.setData("text/plain", colId); } catch {} };
const onHeaderDragOver = (e) => { e.preventDefault(); };
const onHeaderDrop = (targetColId) => (e) => {
e.preventDefault();
const fromId = dragColId.current; if (!fromId || fromId === targetColId) return;
setColumns((prev) => {
const cur = [...prev];
const fromIdx = cur.findIndex((c) => c.id === fromId);
const toIdx = cur.findIndex((c) => c.id === targetColId);
if (fromIdx < 0 || toIdx < 0) return prev;
const [moved] = cur.splice(fromIdx, 1);
cur.splice(toIdx, 0, moved);
return cur;
});
dragColId.current = null;
};
const openFilter = (id) => (e) => setFilterAnchor({ id, anchorEl: e.currentTarget });
const closeFilter = () => setFilterAnchor(null);
const onFilterChange = (id) => (e) => setFilters((prev) => ({ ...prev, [id]: e.target.value }));
const isAllChecked = sorted.length > 0 && sorted.every((r) => selectedIds.has(r.id));
const isIndeterminate = selectedIds.size > 0 && !isAllChecked;
const toggleAll = (e) => {
const checked = e.target.checked;
setSelectedIds((prev) => {
const next = new Set(prev);
if (checked) sorted.forEach((r) => next.add(r.id));
else next.clear();
return next;
});
};
const toggleOne = (id) => (e) => {
const checked = e.target.checked;
setSelectedIds((prev) => {
const next = new Set(prev);
if (checked) next.add(id); else next.delete(id);
return next;
});
};
return (
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
<Box sx={{ p: 2, pb: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>Sites</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
variant="outlined"
size="small"
startIcon={<EditIcon />}
disabled={selectedIds.size !== 1}
onClick={() => {
// Prefill with the currently selected site's name
const selId = selectedIds.size === 1 ? Array.from(selectedIds)[0] : null;
if (selId != null) {
const site = rows.find((r) => r.id === selId);
setRenameValue(site?.name || "");
setRenameOpen(true);
}
}}
sx={{ color: selectedIds.size === 1 ? '#58a6ff' : '#666', borderColor: selectedIds.size === 1 ? '#58a6ff' : '#333', textTransform: 'none' }}
>
Rename
</Button>
<Button
variant="outlined"
size="small"
startIcon={<DeleteIcon />}
disabled={selectedIds.size === 0}
onClick={() => setDeleteOpen(true)}
sx={{ color: selectedIds.size ? '#ff8a8a' : '#666', borderColor: selectedIds.size ? '#ff4f4f' : '#333', textTransform: 'none' }}
>
Delete
</Button>
<Button
variant="outlined"
size="small"
startIcon={<AddIcon />}
onClick={() => setCreateOpen(true)}
sx={{ color: '#58a6ff', borderColor: '#58a6ff', textTransform: 'none' }}
>
Create Site
</Button>
</Box>
</Box>
<Table size="small" sx={{ minWidth: 700 }}>
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox indeterminate={isIndeterminate} checked={isAllChecked} onChange={toggleAll} sx={{ color: '#777' }} />
</TableCell>
{columns.map((col) => (
<TableCell key={col.id} sortDirection={orderBy === col.id ? order : false} draggable onDragStart={onHeaderDragStart(col.id)} onDragOver={onHeaderDragOver} onDrop={onHeaderDrop(col.id)}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TableSortLabel active={orderBy === col.id} direction={orderBy === col.id ? order : 'asc'} onClick={() => handleSort(col.id)}>
{col.label}
</TableSortLabel>
<IconButton size="small" onClick={openFilter(col.id)} sx={{ color: filters[col.id] ? '#58a6ff' : '#888' }}>
<FilterListIcon fontSize="inherit" />
</IconButton>
</Box>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{sorted.map((r) => (
<TableRow key={r.id} hover>
<TableCell padding="checkbox" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selectedIds.has(r.id)} onChange={toggleOne(r.id)} sx={{ color: '#777' }} />
</TableCell>
{columns.map((col) => {
switch (col.id) {
case 'name':
return (
<TableCell
key={col.id}
onClick={() => {
if (onOpenDevicesForSite) onOpenDevicesForSite(r.name);
}}
sx={{ color: '#58a6ff', '&:hover': { cursor: 'pointer', textDecoration: 'underline' } }}
>
{r.name}
</TableCell>
);
case 'description':
return <TableCell key={col.id}>{r.description || ''}</TableCell>;
case 'device_count':
return <TableCell key={col.id}>{r.device_count ?? 0}</TableCell>;
default:
return <TableCell key={col.id} />;
}
})}
</TableRow>
))}
{sorted.length === 0 && (
<TableRow>
<TableCell colSpan={columns.length + 1} sx={{ color: '#888' }}>No sites defined.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{/* Column chooser */}
<Popover
open={Boolean(colChooserAnchor)}
anchorEl={colChooserAnchor}
onClose={() => setColChooserAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
PaperProps={{ sx: { bgcolor: '#1e1e1e', color: '#fff', p: 1 } }}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, p: 1 }}>
{[
{ id: 'name', label: 'Name' },
{ id: 'description', label: 'Description' },
{ id: 'device_count', label: 'Devices' },
].map((opt) => (
<MenuItem key={opt.id} disableRipple onClick={(e) => e.stopPropagation()} sx={{ gap: 1 }}>
<Checkbox
size="small"
checked={columns.some((c) => c.id === opt.id)}
onChange={(e) => {
const checked = e.target.checked;
setColumns((prev) => {
const exists = prev.some((c) => c.id === opt.id);
if (checked) {
if (exists) return prev;
return [...prev, { id: opt.id, label: opt.label }];
}
return prev.filter((c) => c.id !== opt.id);
});
}}
sx={{ p: 0.3, color: '#bbb' }}
/>
<Typography variant="body2" sx={{ color: '#ddd' }}>{opt.label}</Typography>
</MenuItem>
))}
<Box sx={{ display: 'flex', gap: 1, pt: 0.5 }}>
<Button size="small" variant="outlined" onClick={() => setColumns(defaultColumns)} sx={{ textTransform: 'none', borderColor: '#555', color: '#bbb' }}>
Reset Default
</Button>
</Box>
</Box>
</Popover>
{/* Filter popover */}
<Popover
open={Boolean(filterAnchor)}
anchorEl={filterAnchor?.anchorEl || null}
onClose={closeFilter}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
PaperProps={{ sx: { bgcolor: '#1e1e1e', p: 1 } }}
>
{filterAnchor && (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
autoFocus
size="small"
placeholder={`Filter ${columns.find((c) => c.id === filterAnchor.id)?.label || ''}`}
value={filters[filterAnchor.id] || ''}
onChange={onFilterChange(filterAnchor.id)}
onKeyDown={(e) => { if (e.key === 'Escape') closeFilter(); }}
sx={{
input: { color: '#fff' },
minWidth: 220,
'& .MuiOutlinedInput-root': { '& fieldset': { borderColor: '#555' }, '&:hover fieldset': { borderColor: '#888' } },
}}
/>
<Button variant="outlined" size="small" onClick={() => { setFilters((prev) => ({ ...prev, [filterAnchor.id]: '' })); closeFilter(); }} sx={{ textTransform: 'none', borderColor: '#555', color: '#bbb' }}>
Clear
</Button>
</Box>
)}
</Popover>
{/* Create site dialog */}
<CreateSiteDialog
open={createOpen}
onCancel={() => setCreateOpen(false)}
onCreate={async (name, description) => {
try {
const res = await fetch('/api/sites', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, description }) });
if (!res.ok) return;
setCreateOpen(false);
await fetchSites();
} catch {}
}}
/>
{/* Delete confirmation */}
<ConfirmDeleteDialog
open={deleteOpen}
message={`Delete ${selectedIds.size} selected site(s)? This cannot be undone.`}
onCancel={() => setDeleteOpen(false)}
onConfirm={async () => {
try {
const ids = Array.from(selectedIds);
await fetch('/api/sites/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) });
} catch {}
setDeleteOpen(false);
setSelectedIds(new Set());
await fetchSites();
}}
/>
{/* Rename site dialog */}
<RenameSiteDialog
open={renameOpen}
value={renameValue}
onChange={setRenameValue}
onCancel={() => setRenameOpen(false)}
onSave={async () => {
const newName = (renameValue || '').trim();
if (!newName) return;
const selId = selectedIds.size === 1 ? Array.from(selectedIds)[0] : null;
if (selId == null) return;
try {
const res = await fetch('/api/sites/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: selId, new_name: newName })
});
if (!res.ok) {
// Keep dialog open on error; optionally log
try { const err = await res.json(); console.warn('Rename failed', err); } catch {}
return;
}
setRenameOpen(false);
await fetchSites();
} catch (e) {
console.warn('Rename error', e);
}
}}
/>
</Paper>
);
}

View File

@@ -0,0 +1,93 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Status_Bar.jsx
import React, { useEffect, useState } from "react";
import { Box, Button, Divider } from "@mui/material";
export default function StatusBar() {
const [apiStatus, setApiStatus] = useState("checking");
useEffect(() => {
fetch("/health")
.then((res) => (res.ok ? setApiStatus("online") : setApiStatus("offline")))
.catch(() => setApiStatus("offline"));
}, []);
const applyRate = () => {
const val = parseInt(
document.getElementById("updateRateInput")?.value
);
if (!isNaN(val) && val >= 50) {
window.BorealisUpdateRate = val;
console.log("Global update rate set to", val + "ms");
} else {
alert("Please enter a valid number (min 50).");
}
};
return (
<Box
component="footer"
sx={{
bgcolor: "#1e1e1e",
color: "white",
px: 2,
py: 1,
display: "flex",
alignItems: "center",
justifyContent: "space-between"
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<b>Nodes</b>: <span id="nodeCount">0</span>
<Divider orientation="vertical" flexItem sx={{ borderColor: "#444" }} />
<b>Update Rate (ms):</b>
<input
id="updateRateInput"
type="number"
min="50"
step="50"
defaultValue={window.BorealisUpdateRate}
style={{
width: "80px",
background: "#121212",
color: "#fff",
border: "1px solid #444",
borderRadius: "3px",
padding: "3px",
fontSize: "0.8rem"
}}
/>
<Button
variant="outlined"
size="small"
onClick={applyRate}
sx={{
color: "#58a6ff",
borderColor: "#58a6ff",
fontSize: "0.75rem",
textTransform: "none",
px: 1.5
}}
>
Apply Rate
</Button>
</Box>
<Box sx={{ fontSize: "1.0rem", display: "flex", alignItems: "center", gap: 1 }}>
<strong style={{ color: "#58a6ff" }}>Backend API Server</strong>:
<a
href="http://localhost:5000/health"
target="_blank"
rel="noopener noreferrer"
style={{
color: apiStatus === "online" ? "#00d18c" : "#ff4f4f",
textDecoration: "none",
fontWeight: "bold"
}}
>
{apiStatus === "checking" ? "..." : apiStatus.charAt(0).toUpperCase() + apiStatus.slice(1)}
</a>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,21 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
// Global Styles
import "normalize.css/normalize.css";
import "@fontsource/ibm-plex-sans/400.css";
import "@fontsource/ibm-plex-sans/500.css";
import "@fontsource/ibm-plex-sans/600.css";
import "@fortawesome/fontawesome-free/css/all.min.css";
import './Borealis.css'; // Global Theming for All of Borealis
import App from './App.jsx';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,554 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Agent/Node_Agent.jsx
import React, { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
// Modern Node: Borealis Agent (Sidebar Config Enabled)
const BorealisAgentNode = ({ id, data }) => {
const { getNodes, setNodes } = useReactFlow();
const edges = useStore((state) => state.edges);
const [agents, setAgents] = useState({});
const [sites, setSites] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const [siteMapping, setSiteMapping] = useState({});
const prevRolesRef = useRef([]);
const selectionRef = useRef({ host: "", mode: "", agentId: "", siteId: "" });
const selectedSiteId = data?.agent_site_id ? String(data.agent_site_id) : "";
const selectedHost = data?.agent_host || "";
const selectedMode =
(data?.agent_mode || "currentuser").toString().toLowerCase() === "system"
? "system"
: "currentuser";
const selectedAgent = data?.agent_id || "";
// Group agents by hostname and execution context
const agentsByHostname = useMemo(() => {
if (!agents || typeof agents !== "object") return {};
const grouped = {};
Object.entries(agents).forEach(([aid, info]) => {
if (!info || typeof info !== "object") return;
const status = (info.status || "").toString().toLowerCase();
if (status === "offline") return;
const host = (info.hostname || info.agent_hostname || "").trim() || "unknown";
const modeRaw = (info.service_mode || "").toString().toLowerCase();
const mode = modeRaw === "system" ? "system" : "currentuser";
if (!grouped[host]) {
grouped[host] = { currentuser: null, system: null };
}
grouped[host][mode] = {
agent_id: aid,
status: info.status || "offline",
last_seen: info.last_seen || 0,
info,
};
});
return grouped;
}, [agents]);
// Locale-aware, case-insensitive, numeric-friendly sorter (e.g., "host2" < "host10")
const hostCollator = useMemo(
() => new Intl.Collator(undefined, { sensitivity: "base", numeric: true }),
[]
);
const hostOptions = useMemo(() => {
const entries = Object.entries(agentsByHostname)
.map(([host, contexts]) => {
const candidates = [contexts.currentuser, contexts.system].filter(Boolean);
if (!candidates.length) return null;
// Label is just the hostname (you already simplified this earlier)
const label = host;
// Keep latest around if you use it elsewhere, but it no longer affects ordering
const latest = Math.max(...candidates.map((r) => r.last_seen || 0));
return { host, label, contexts, latest };
})
.filter(Boolean)
// Always alphabetical, case-insensitive, numeric-aware
.sort((a, b) => hostCollator.compare(a.host, b.host));
return entries;
}, [agentsByHostname, hostCollator]);
// Fetch Agents Periodically
useEffect(() => {
const fetchAgents = () => {
fetch("/api/agents")
.then((res) => res.json())
.then(setAgents)
.catch(() => {});
};
fetchAgents();
const interval = setInterval(fetchAgents, 10000); // Update Agent List Every 10 Seconds
return () => clearInterval(interval);
}, []);
// Fetch sites list
useEffect(() => {
const fetchSites = () => {
fetch("/api/sites")
.then((res) => res.json())
.then((data) => {
const siteEntries = Array.isArray(data?.sites) ? data.sites : [];
setSites(siteEntries);
})
.catch(() => setSites([]));
};
fetchSites();
}, []);
// Fetch site mapping for current host options
useEffect(() => {
const hostnames = hostOptions.map(({ host }) => host).filter(Boolean);
if (!hostnames.length) {
setSiteMapping({});
return;
}
const query = hostnames.map(encodeURIComponent).join(",");
fetch(`/api/sites/device_map?hostnames=${query}`)
.then((res) => res.json())
.then((data) => {
const mapping = data?.mapping && typeof data.mapping === "object" ? data.mapping : {};
setSiteMapping(mapping);
})
.catch(() => setSiteMapping({}));
}, [hostOptions]);
const filteredHostOptions = useMemo(() => {
if (!selectedSiteId) return hostOptions;
return hostOptions.filter(({ host }) => {
const mapping = siteMapping[host];
if (!mapping || typeof mapping.site_id === "undefined" || mapping.site_id === null) {
return false;
}
return String(mapping.site_id) === selectedSiteId;
});
}, [hostOptions, selectedSiteId, siteMapping]);
// Align selected site with known host mapping when available
useEffect(() => {
if (selectedSiteId || !selectedHost) return;
const mapping = siteMapping[selectedHost];
if (!mapping || typeof mapping.site_id === "undefined" || mapping.site_id === null) return;
const mappedId = String(mapping.site_id);
setNodes((nds) =>
nds.map((n) =>
n.id === id
? {
...n,
data: {
...n.data,
agent_site_id: mappedId,
},
}
: n
)
);
}, [selectedHost, selectedSiteId, siteMapping, id, setNodes]);
// Ensure host selection stays aligned with available agents
useEffect(() => {
if (!selectedHost) return;
const hostExists = filteredHostOptions.some((opt) => opt.host === selectedHost);
if (hostExists) return;
if (selectedAgent && agents[selectedAgent]) {
const info = agents[selectedAgent];
const inferredHost = (info?.hostname || info?.agent_hostname || "").trim() || "unknown";
const allowed = filteredHostOptions.some((opt) => opt.host === inferredHost);
if (allowed && inferredHost && inferredHost !== selectedHost) {
setNodes((nds) =>
nds.map((n) =>
n.id === id
? {
...n,
data: {
...n.data,
agent_host: inferredHost,
},
}
: n
)
);
return;
}
}
setNodes((nds) =>
nds.map((n) =>
n.id === id
? {
...n,
data: {
...n.data,
agent_host: "",
agent_id: "",
agent_mode: "currentuser",
},
}
: n
)
);
}, [filteredHostOptions, selectedHost, selectedAgent, agents, id, setNodes]);
const siteSelectOptions = useMemo(() => {
const entries = Array.isArray(sites) ? [...sites] : [];
entries.sort((a, b) =>
(a?.name || "").localeCompare(b?.name || "", undefined, { sensitivity: "base" })
);
const mapped = entries.map((site) => ({
value: String(site.id),
label: site.name || `Site ${site.id}`,
}));
return [{ value: "", label: "All Sites" }, ...mapped];
}, [sites]);
const hostSelectOptions = useMemo(() => {
const mapped = filteredHostOptions.map(({ host, label }) => ({
value: host,
label,
}));
return [{ value: "", label: "-- Select --" }, ...mapped];
}, [filteredHostOptions]);
const activeHostContexts = selectedHost ? agentsByHostname[selectedHost] : null;
const modeSelectOptions = useMemo(
() => [
{
value: "currentuser",
label: "CURRENTUSER (Screen Capture / Macros)",
disabled: !activeHostContexts?.currentuser,
},
{
value: "system",
label: "SYSTEM (Scripts)",
disabled: !activeHostContexts?.system,
},
],
[activeHostContexts]
);
useEffect(() => {
setNodes((nds) =>
nds.map((n) =>
n.id === id
? {
...n,
data: {
...n.data,
siteOptions: siteSelectOptions,
hostOptions: hostSelectOptions,
modeOptions: modeSelectOptions,
},
}
: n
)
);
}, [id, setNodes, siteSelectOptions, hostSelectOptions, modeSelectOptions]);
useEffect(() => {
if (!selectedHost) {
if (selectedAgent || selectedMode !== "currentuser") {
setNodes((nds) =>
nds.map((n) =>
n.id === id
? {
...n,
data: {
...n.data,
agent_id: "",
agent_mode: "currentuser",
},
}
: n
)
);
}
return;
}
const contexts = agentsByHostname[selectedHost];
if (!contexts) {
if (selectedAgent || selectedMode !== "currentuser") {
setNodes((nds) =>
nds.map((n) =>
n.id === id
? {
...n,
data: {
...n.data,
agent_id: "",
agent_mode: "currentuser",
},
}
: n
)
);
}
return;
}
if (!contexts[selectedMode]) {
const fallbackMode = contexts.currentuser
? "currentuser"
: contexts.system
? "system"
: "currentuser";
const fallbackAgentId = contexts[fallbackMode]?.agent_id || "";
if (fallbackMode !== selectedMode || fallbackAgentId !== selectedAgent) {
setNodes((nds) =>
nds.map((n) =>
n.id === id
? {
...n,
data: {
...n.data,
agent_mode: fallbackMode,
agent_id: fallbackAgentId,
},
}
: n
)
);
}
return;
}
const targetAgentId = contexts[selectedMode]?.agent_id || "";
if (targetAgentId !== selectedAgent) {
setNodes((nds) =>
nds.map((n) =>
n.id === id
? {
...n,
data: {
...n.data,
agent_id: targetAgentId,
},
}
: n
)
);
}
}, [selectedHost, selectedMode, agentsByHostname, selectedAgent, id, setNodes]);
useEffect(() => {
const prev = selectionRef.current;
const changed =
prev.host !== selectedHost ||
prev.mode !== selectedMode ||
prev.agentId !== selectedAgent ||
prev.siteId !== selectedSiteId;
if (!changed) return;
const selectionChangedAgent =
prev.agentId &&
(prev.agentId !== selectedAgent || prev.host !== selectedHost || prev.mode !== selectedMode);
if (selectionChangedAgent) {
setIsConnected(false);
prevRolesRef.current = [];
}
selectionRef.current = {
host: selectedHost,
mode: selectedMode,
agentId: selectedAgent,
siteId: selectedSiteId,
};
}, [selectedHost, selectedMode, selectedAgent, selectedSiteId]);
// Attached Roles logic
const attachedRoleIds = useMemo(
() =>
edges
.filter((e) => e.source === id && e.sourceHandle === "provisioner")
.map((e) => e.target),
[edges, id]
);
const getAttachedRoles = useCallback(() => {
const allNodes = getNodes();
return attachedRoleIds
.map((nid) => {
const fn = window.__BorealisInstructionNodes?.[nid];
return typeof fn === "function" ? fn() : null;
})
.filter((r) => r);
}, [attachedRoleIds, getNodes]);
// Provision Roles to Agent
const provisionRoles = useCallback((roles) => {
if (!selectedAgent) return;
fetch("/api/agent/provision", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_id: selectedAgent, roles })
})
.then(() => {
setIsConnected(true);
prevRolesRef.current = roles;
})
.catch(() => {});
}, [selectedAgent]);
const handleConnect = useCallback(() => {
const roles = getAttachedRoles();
provisionRoles(roles);
}, [getAttachedRoles, provisionRoles]);
const handleDisconnect = useCallback(() => {
if (!selectedAgent) return;
fetch("/api/agent/provision", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_id: selectedAgent, roles: [] })
})
.then(() => {
setIsConnected(false);
prevRolesRef.current = [];
})
.catch(() => {});
}, [selectedAgent]);
// Auto-provision on role change
useEffect(() => {
const newRoles = getAttachedRoles();
const prevSerialized = JSON.stringify(prevRolesRef.current || []);
const newSerialized = JSON.stringify(newRoles);
if (isConnected && newSerialized !== prevSerialized) {
provisionRoles(newRoles);
}
}, [attachedRoleIds, isConnected, getAttachedRoles, provisionRoles]);
// Status Label
const selectedAgentStatus = useMemo(() => {
if (!selectedHost) return "Unassigned";
const contexts = agentsByHostname[selectedHost];
if (!contexts) return "Offline";
const activeContext = contexts[selectedMode];
if (!selectedAgent || !activeContext) return "Unavailable";
const status = (activeContext.status || "").toString().toLowerCase();
if (status === "provisioned") return "Connected";
if (status === "orphaned") return "Available";
if (!status) return "Available";
return status.charAt(0).toUpperCase() + status.slice(1);
}, [agentsByHostname, selectedHost, selectedMode, selectedAgent]);
// Render (Sidebar handles config)
return (
<div className="borealis-node">
<Handle
type="source"
position={Position.Bottom}
id="provisioner"
className="borealis-handle"
style={{ top: "100%", background: "#58a6ff" }}
/>
<div className="borealis-node-header">Device Agent</div>
<div
className="borealis-node-content"
style={{
fontSize: "9px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
minHeight: "80px",
gap: "8px",
}}
>
<div style={{ fontSize: "8px", color: "#666" }}>Right-Click to Configure Agent</div>
<button
onClick={isConnected ? handleDisconnect : handleConnect}
style={{
padding: "6px 14px",
fontSize: "10px",
background: isConnected ? "#3a3a3a" : "#0475c2",
color: "#fff",
border: "1px solid #0475c2",
borderRadius: "4px",
cursor: selectedAgent ? "pointer" : "not-allowed",
opacity: selectedAgent ? 1 : 0.5,
minWidth: "150px",
}}
disabled={!selectedAgent}
>
{isConnected ? "Disconnect" : "Connect to Device"}
</button>
<div style={{ fontSize: "8px", color: "#777" }}>
{selectedHost ? `${selectedHost} · ${selectedMode.toUpperCase()}` : "No device selected"}
</div>
</div>
</div>
);
};
// Node Registration Object with sidebar config and docs
export default {
type: "Borealis_Agent",
label: "Device Agent",
description: `
Select and connect to a remote Borealis Agent.
- Assign roles to agent dynamically by connecting "Agent Role" nodes.
- Auto-provisions agent as role assignments change.
- See live agent status and re-connect/disconnect easily.
- Choose between CURRENTUSER and SYSTEM contexts for each device.
`.trim(),
content: "Select and manage an Agent with dynamic roles",
component: BorealisAgentNode,
config: [
{
key: "agent_site_id",
label: "Site",
type: "select",
optionsKey: "siteOptions",
defaultValue: ""
},
{
key: "agent_host",
label: "Device",
type: "select",
optionsKey: "hostOptions",
defaultValue: ""
},
{
key: "agent_mode",
label: "Agent Context",
type: "select",
optionsKey: "modeOptions",
defaultValue: "currentuser"
},
{
key: "agent_id",
label: "Agent ID",
type: "text",
readOnly: true,
defaultValue: ""
}
],
usage_documentation: `
### Borealis Agent Node
This node allows you to establish a connection with a device running a Borealis "Agent", so you can instruct the agent to do things from your workflow.
#### Features
- **Select** a site, then a device, then finally an agent context (CURRENTUSER vs SYSTEM).
- **Connect/Disconnect** from the agent at any time.
- **Attach roles** (by connecting "Agent Role" nodes to this node's output handle) to assign behaviors dynamically.
#### How to Use
1. **Drag and drop in a Borealis Agent node.**
2. **Pick an agent** from the dropdown list (auto-populates from API backend).
3. **Click "Connect to Agent"**.
4. **Attach Agent Role Nodes** (e.g., Screenshot, Macro Keypress) to the "provisioner" output handle to define what the agent should do.
5. Agent will automatically update its roles as you change connected Role Nodes.
#### Good to Know
- If an agent disconnects or goes offline, its status will show "Reconnecting..." until it returns.
- **Roles update LIVE**: Any time you change attached roles, the agent gets updated instantly.
`.trim()
};

View File

@@ -0,0 +1,310 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Agent Roles/Node_Agent_Role_Macro.jsx
import React, { useState, useEffect, useRef } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
import "react-simple-keyboard/build/css/index.css";
// Default update interval for window list refresh (in ms)
const WINDOW_LIST_REFRESH_MS = 4000;
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
const DEFAULT_OPERATION_MODE = "Continuous";
const OPERATION_MODES = [
"Run Once",
"Continuous",
"Trigger-Once",
"Trigger-Continuous"
];
const MACRO_TYPES = [
"keypress",
"typed_text"
];
const statusColors = {
idle: "#333",
running: "#00d18c",
error: "#ff4f4f",
success: "#00d18c"
};
const MacroKeyPressNode = ({ id, data }) => {
const { setNodes, getNodes } = useReactFlow();
const edges = useStore((state) => state.edges);
const [windowList, setWindowList] = useState([]);
const [status, setStatus] = useState({ state: "idle", message: "" });
const socketRef = useRef(null);
// Determine if agent is connected
const agentEdge = edges.find((e) => e.target === id && e.targetHandle === "agent");
const agentNode = agentEdge && getNodes().find((n) => n.id === agentEdge.source);
const agentConnection = !!(agentNode && agentNode.data && agentNode.data.agent_id);
const agent_id = agentNode && agentNode.data && agentNode.data.agent_id;
// Macro run/trigger state (sidebar sets this via config, but node UI just shows status)
const running = data?.active === true || data?.active === "true";
// Store for last macro error/status
const [lastMacroStatus, setLastMacroStatus] = useState({ success: true, message: "", timestamp: null });
// Setup WebSocket for agent macro status updates
useEffect(() => {
if (!window.BorealisSocket) return;
const socket = window.BorealisSocket;
socketRef.current = socket;
function handleMacroStatus(payload) {
if (
payload &&
payload.agent_id === agent_id &&
payload.node_id === id
) {
setLastMacroStatus({
success: !!payload.success,
message: payload.message || "",
timestamp: payload.timestamp || Date.now()
});
setStatus({
state: payload.success ? "success" : "error",
message: payload.message || (payload.success ? "Success" : "Error")
});
}
}
socket.on("macro_status", handleMacroStatus);
return () => {
socket.off("macro_status", handleMacroStatus);
};
}, [agent_id, id]);
// Auto-refresh window list from agent
useEffect(() => {
let intervalId = null;
async function fetchWindows() {
if (window.BorealisSocket && agentConnection) {
window.BorealisSocket.emit("list_agent_windows", {
agent_id
});
}
}
fetchWindows();
intervalId = setInterval(fetchWindows, WINDOW_LIST_REFRESH_MS);
// Listen for agent_window_list updates
function handleAgentWindowList(payload) {
if (payload?.agent_id === agent_id && Array.isArray(payload.windows)) {
setWindowList(payload.windows);
// Store windowList in node data for sidebar dynamic dropdowns
setNodes(nds =>
nds.map(n =>
n.id === id
? { ...n, data: { ...n.data, windowList: payload.windows } }
: n
)
);
}
}
if (window.BorealisSocket) {
window.BorealisSocket.on("agent_window_list", handleAgentWindowList);
}
return () => {
clearInterval(intervalId);
if (window.BorealisSocket) {
window.BorealisSocket.off("agent_window_list", handleAgentWindowList);
}
};
}, [agent_id, agentConnection, setNodes, id]);
// UI: Start/Pause Button
const handleToggleMacro = () => {
setNodes(nds =>
nds.map(n =>
n.id === id
? {
...n,
data: {
...n.data,
active: n.data?.active === true || n.data?.active === "true" ? "false" : "true"
}
}
: n
)
);
};
// Optional: Show which window is targeted by name
const selectedWindow = (windowList || []).find(w => String(w.handle) === String(data?.window_handle));
// Node UI (no config fields, only status + window list)
return (
<div className="borealis-node" style={{ minWidth: 280, position: "relative" }}>
{/* --- INPUT LABELS & HANDLES --- */}
<div style={{
position: "absolute",
left: -30,
top: 26,
fontSize: "8px",
color: "#6ef9fb",
letterSpacing: 0.5,
pointerEvents: "none"
}}>
Agent
</div>
<Handle
type="target"
position={Position.Left}
id="agent"
style={{
top: 25,
}}
className="borealis-handle"
/>
<div style={{
position: "absolute",
left: -34,
top: 70,
fontSize: "8px",
color: "#6ef9fb",
letterSpacing: 0.5,
pointerEvents: "none"
}}>
Trigger
</div>
<Handle
type="target"
position={Position.Left}
id="trigger"
style={{
top: 68,
}}
className="borealis-handle"
/>
<div className="borealis-node-header" style={{ position: "relative" }}>
Agent Role: Macro
<div
style={{
position: "absolute",
top: "50%",
right: "8px",
width: "10px",
transform: "translateY(-50%)",
height: "10px",
borderRadius: "50%",
backgroundColor:
status.state === "error"
? statusColors.error
: running
? statusColors.running
: statusColors.idle,
border: "1px solid #222"
}}
/>
</div>
<div className="borealis-node-content">
<strong>Status</strong>:{" "}
{status.state === "error"
? (
<span style={{ color: "#ff4f4f" }}>
Error{lastMacroStatus.message ? `: ${lastMacroStatus.message}` : ""}
</span>
)
: running
? (
<span style={{ color: "#00d18c" }}>
Running{lastMacroStatus.message ? ` (${lastMacroStatus.message})` : ""}
</span>
)
: "Idle"}
<br />
<strong>Agent Connection</strong>: {agentConnection ? "Connected" : "Not Connected"}
<br />
<strong>Target Window</strong>:{" "}
{selectedWindow
? `${selectedWindow.title} (${selectedWindow.handle})`
: data?.window_handle
? `Handle: ${data.window_handle}`
: <span style={{ color: "#888" }}>Not set</span>}
<br />
<strong>Mode</strong>: {data?.operation_mode || DEFAULT_OPERATION_MODE}
<br />
<strong>Macro Type</strong>: {data?.macro_type || "keypress"}
<br />
<button
onClick={handleToggleMacro}
style={{
marginTop: 8,
padding: "4px 10px",
background: running ? "#3a3a3a" : "#0475c2",
color: running ? "#fff" : "#fff",
border: "1px solid #0475c2",
borderRadius: 3,
fontSize: "11px",
cursor: "pointer"
}}
>
{running ? "Pause Macro" : "Start Macro"}
</button>
<br />
<span style={{ fontSize: "9px", color: "#aaa" }}>
{lastMacroStatus.timestamp
? `Last event: ${new Date(lastMacroStatus.timestamp).toLocaleTimeString()}`
: ""}
</span>
</div>
</div>
);
};
// ----- Node Catalog Export -----
export default {
type: "Macro_KeyPress",
label: "Agent Role: Macro",
description: `
Send automated key presses or typed text to any open application window on the connected agent.
Supports manual, continuous, trigger, and one-shot modes for automation and event-driven workflows.
`,
content: "Send Key Press or Typed Text to Window via Agent",
component: MacroKeyPressNode,
config: [
{ key: "window_handle", label: "Target Window", type: "select", dynamicOptions: true, defaultValue: "" },
{ key: "macro_type", label: "Macro Type", type: "select", options: ["keypress", "typed_text"], defaultValue: "keypress" },
{ key: "key", label: "Key", type: "text", defaultValue: "" },
{ key: "text", label: "Typed Text", type: "text", defaultValue: "" },
{ key: "interval_ms", label: "Interval (ms)", type: "text", defaultValue: "1000" },
{ key: "randomize_interval", label: "Randomize Interval", type: "select", options: ["true", "false"], defaultValue: "false" },
{ key: "random_min", label: "Random Min (ms)", type: "text", defaultValue: "750" },
{ key: "random_max", label: "Random Max (ms)", type: "text", defaultValue: "950" },
{ key: "operation_mode", label: "Operation Mode", type: "select", options: OPERATION_MODES, defaultValue: "Continuous" },
{ key: "active", label: "Macro Enabled", type: "select", options: ["true", "false"], defaultValue: "false" },
{ key: "trigger", label: "Trigger Value", type: "text", defaultValue: "0" }
],
usage_documentation: `
### Agent Role: Macro
**Modes:**
- **Continuous**: Macro sends input non-stop when started by button.
- **Trigger-Continuous**: Macro sends input as long as upstream trigger is "1".
- **Trigger-Once**: Macro fires once per upstream "1" (one-shot edge).
- **Run Once**: Macro runs only once when started by button.
**Macro Types:**
- **Single Keypress**: Press a single key.
- **Typed Text**: Types out a string.
**Window Target:**
- Dropdown of live windows from agent, stays updated.
**Event-Driven Support:**
- Chain with other Borealis nodes (text recognition, event triggers, etc).
**Live Status:**
- Displays last agent macro event and error feedback in node.
---
`.trim()
};

View File

@@ -0,0 +1,271 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Agent/Node_Agent_Role_Screenshot.jsx
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
import ShareIcon from "@mui/icons-material/Share";
import IconButton from "@mui/material/IconButton";
/*
Agent Role: Screenshot Node (Modern, Sidebar Config Enabled)
- Defines a screenshot region to be captured by a remote Borealis Agent.
- Pushes live base64 PNG data to downstream nodes.
- Region coordinates (x, y, w, h), visibility, overlay label, and interval are all persisted and synchronized.
- All configuration is moved to the right sidebar (Node Properties).
- Maintains full bi-directional write-back of coordinates and overlay settings.
*/
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
const AgentScreenshotNode = ({ id, data }) => {
const { setNodes, getNodes } = useReactFlow();
const edges = useStore(state => state.edges);
const resolveAgentData = useCallback(() => {
try {
const agentEdge = edges.find(e => e.target === id && e.sourceHandle === "provisioner");
const agentNode = getNodes().find(n => n.id === agentEdge?.source);
return agentNode?.data || null;
} catch (err) {
return null;
}
}, [edges, getNodes, id]);
// Core config values pulled from sidebar config (with defaults)
const interval = parseInt(data?.interval || 1000, 10) || 1000;
const region = {
x: parseInt(data?.x ?? 250, 10),
y: parseInt(data?.y ?? 100, 10),
w: parseInt(data?.w ?? 300, 10),
h: parseInt(data?.h ?? 200, 10)
};
const visible = (data?.visible ?? "true") === "true";
const alias = data?.alias || "";
const [imageBase64, setImageBase64] = useState(data?.value || "");
const agentData = resolveAgentData();
const targetModeLabel = ((agentData?.agent_mode || "").toString().toLowerCase() === "system")
? "SYSTEM Agent"
: "CURRENTUSER Agent";
const targetHostLabel = (agentData?.agent_host || "").toString();
// Always push current imageBase64 into BorealisValueBus at the global update rate
useEffect(() => {
const intervalId = setInterval(() => {
if (imageBase64) {
window.BorealisValueBus[id] = imageBase64;
setNodes(nds =>
nds.map(n =>
n.id === id ? { ...n, data: { ...n.data, value: imageBase64 } } : n
)
);
}
}, window.BorealisUpdateRate || 100);
return () => clearInterval(intervalId);
}, [id, imageBase64, setNodes]);
// Listen for agent screenshot and overlay region updates
useEffect(() => {
const socket = window.BorealisSocket;
if (!socket) return;
const handleScreenshot = (payload) => {
if (payload?.node_id !== id) return;
// Additionally ensure payload is from the agent connected upstream of this node
const agentData = resolveAgentData();
const selectedAgentId = agentData?.agent_id;
if (!selectedAgentId || payload?.agent_id !== selectedAgentId) return;
if (payload.image_base64) {
setImageBase64(payload.image_base64);
window.BorealisValueBus[id] = payload.image_base64;
}
const { x, y, w, h } = payload;
if (
x !== undefined &&
y !== undefined &&
w !== undefined &&
h !== undefined
) {
setNodes(nds =>
nds.map(n =>
n.id === id ? { ...n, data: { ...n.data, x, y, w, h } } : n
)
);
}
};
socket.on("agent_screenshot_task", handleScreenshot);
return () => socket.off("agent_screenshot_task", handleScreenshot);
}, [id, setNodes, resolveAgentData]);
// Register this node for the agent provisioning sync
window.__BorealisInstructionNodes = window.__BorealisInstructionNodes || {};
window.__BorealisInstructionNodes[id] = () => {
const agentData = resolveAgentData() || {};
const modeRaw = (agentData.agent_mode || "").toString().toLowerCase();
const targetMode = modeRaw === "system" ? "system" : "currentuser";
return {
node_id: id,
role: "screenshot",
interval,
visible,
alias,
target_agent_mode: targetMode,
target_agent_host: agentData.agent_host || "",
...region
};
};
// Manual live view copy button
const handleCopyLiveViewLink = () => {
const agentData = resolveAgentData();
const selectedAgentId = agentData?.agent_id;
if (!selectedAgentId) {
alert("No valid agent connection found.");
return;
}
const liveUrl = `${window.location.origin}/api/agent/${selectedAgentId}/node/${id}/screenshot/live`;
navigator.clipboard.writeText(liveUrl)
.then(() => console.log(`[Clipboard] Live View URL copied: ${liveUrl}`))
.catch(err => console.error("Clipboard copy failed:", err));
};
// Node card UI - config handled in sidebar
return (
<div className="borealis-node" style={{ position: "relative" }}>
<Handle type="target" position={Position.Left} className="borealis-handle" />
<Handle type="source" position={Position.Right} className="borealis-handle" />
<div className="borealis-node-header">
{data?.label || "Agent Role: Screenshot"}
</div>
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
<div>
<b>Region:</b> X:{region.x} Y:{region.y} W:{region.w} H:{region.h}
</div>
<div>
<b>Interval:</b> {interval} ms
</div>
<div>
<b>Agent Context:</b> {targetModeLabel}
</div>
<div>
<b>Target Host:</b>{" "}
{targetHostLabel ? (
targetHostLabel
) : (
<span style={{ color: "#666" }}>unknown</span>
)}
</div>
<div>
<b>Overlay:</b> {visible ? "Yes" : "No"}
</div>
<div>
<b>Label:</b> {alias || <span style={{ color: "#666" }}>none</span>}
</div>
<div style={{ textAlign: "center", fontSize: "8px", color: "#aaa" }}>
{imageBase64
? `Last image: ${Math.round(imageBase64.length / 1024)} KB`
: "Awaiting Screenshot Data..."}
</div>
</div>
<div style={{ position: "absolute", top: 4, right: 4 }}>
<IconButton size="small" onClick={handleCopyLiveViewLink}>
<ShareIcon style={{ fontSize: 14 }} />
</IconButton>
</div>
</div>
);
};
// Node registration for Borealis catalog (sidebar config enabled)
export default {
type: "Agent_Role_Screenshot",
label: "Agent Role: Screenshot",
description: `
Capture a live screenshot of a defined region from a remote Borealis Agent.
- Define region (X, Y, Width, Height)
- Select update interval (ms)
- Optionally show a visual overlay with a label
- Pushes base64 PNG stream to downstream nodes
- Use copy button to share live view URL
- Targets the CURRENTUSER or SYSTEM agent context selected upstream
`.trim(),
content: "Capture screenshot region via agent",
component: AgentScreenshotNode,
config: [
{
key: "interval",
label: "Update Interval (ms)",
type: "text",
defaultValue: "1000"
},
{
key: "x",
label: "Region X",
type: "text",
defaultValue: "250"
},
{
key: "y",
label: "Region Y",
type: "text",
defaultValue: "100"
},
{
key: "w",
label: "Region Width",
type: "text",
defaultValue: "300"
},
{
key: "h",
label: "Region Height",
type: "text",
defaultValue: "200"
},
{
key: "visible",
label: "Show Overlay on Agent",
type: "select",
options: ["true", "false"],
defaultValue: "true"
},
{
key: "alias",
label: "Overlay Label",
type: "text",
defaultValue: ""
}
],
usage_documentation: `
### Agent Role: Screenshot Node
This node defines a screenshot-capture role for a Borealis Agent.
**How It Works**
- The region (X, Y, W, H) is sent to the Agent for real-time screenshot capture.
- The interval determines how often the Agent captures and pushes new images.
- Optionally, an overlay with a label can be displayed on the Agent's screen for visual feedback.
- The captured screenshot (as a base64 PNG) is available to downstream nodes.
- Use the share button to copy a live viewing URL for the screenshot stream.
**Configuration**
- All fields are edited via the right sidebar.
- Coordinates update live if region is changed from the Agent.
**Warning**
- Changing region from the Agent UI will update this node's coordinates.
- Do not remove the bi-directional region write-back: if the region moves, this node updates immediately.
**Example Use Cases**
- Automated visual QA (comparing regions of apps)
- OCR on live application windows
- Remote monitoring dashboards
`.trim()
};

View File

@@ -0,0 +1,326 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: Node_Alert_Sound.jsx
/**
* ==================================================
* Borealis - Alert Sound Node (with Base64 Restore)
* ==================================================
*
* COMPONENT ROLE:
* Plays a sound when input = "1". Provides a visual indicator:
* - Green dot: input is 0
* - Red dot: input is 1
*
* Modes:
* - "Once": Triggers once when going 0 -> 1
* - "Constant": Triggers repeatedly every X ms while input = 1
*
* Supports embedding base64 audio directly into the workflow.
*/
import React, { useEffect, useRef, useState } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
const AlertSoundNode = ({ id, data }) => {
const edges = useStore(state => state.edges);
const { setNodes } = useReactFlow();
const [alertType, setAlertType] = useState(data?.alertType || "Once");
const [intervalMs, setIntervalMs] = useState(data?.interval || 1000);
const [prevInput, setPrevInput] = useState("0");
const [customAudioBase64, setCustomAudioBase64] = useState(data?.audio || null);
const [currentInput, setCurrentInput] = useState("0");
const audioRef = useRef(null);
const playSound = () => {
if (audioRef.current) {
console.log(`[Alert Node ${id}] Attempting to play sound`);
try {
audioRef.current.pause();
audioRef.current.currentTime = 0;
audioRef.current.load();
audioRef.current.play().then(() => {
console.log(`[Alert Node ${id}] Sound played successfully`);
}).catch((err) => {
console.warn(`[Alert Node ${id}] Audio play blocked or errored:`, err);
});
} catch (err) {
console.error(`[Alert Node ${id}] Failed to play sound:`, err);
}
} else {
console.warn(`[Alert Node ${id}] No audioRef loaded`);
}
};
const handleFileUpload = (event) => {
const file = event.target.files[0];
if (!file) return;
console.log(`[Alert Node ${id}] File selected:`, file.name, file.type);
const supportedTypes = ["audio/wav", "audio/mp3", "audio/mpeg", "audio/ogg"];
if (!supportedTypes.includes(file.type)) {
console.warn(`[Alert Node ${id}] Unsupported audio type: ${file.type}`);
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const base64 = e.target.result;
const mimeType = file.type || "audio/mpeg";
const safeURL = base64.startsWith("data:")
? base64
: `data:${mimeType};base64,${base64}`;
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = "";
audioRef.current.load();
audioRef.current = null;
}
const newAudio = new Audio();
newAudio.src = safeURL;
let readyFired = false;
newAudio.addEventListener("canplaythrough", () => {
if (readyFired) return;
readyFired = true;
console.log(`[Alert Node ${id}] Audio is decodable and ready: ${file.name}`);
setCustomAudioBase64(safeURL);
audioRef.current = newAudio;
newAudio.load();
setNodes(nds =>
nds.map(n =>
n.id === id
? { ...n, data: { ...n.data, audio: safeURL } }
: n
)
);
});
setTimeout(() => {
if (!readyFired) {
console.warn(`[Alert Node ${id}] WARNING: Audio not marked ready in time. May fail silently.`);
}
}, 2000);
};
reader.onerror = (e) => {
console.error(`[Alert Node ${id}] File read error:`, e);
};
reader.readAsDataURL(file);
};
// Restore embedded audio from saved workflow
useEffect(() => {
if (customAudioBase64) {
console.log(`[Alert Node ${id}] Loading embedded audio from workflow`);
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = "";
audioRef.current.load();
audioRef.current = null;
}
const loadedAudio = new Audio(customAudioBase64);
loadedAudio.addEventListener("canplaythrough", () => {
console.log(`[Alert Node ${id}] Embedded audio ready`);
});
audioRef.current = loadedAudio;
loadedAudio.load();
} else {
console.log(`[Alert Node ${id}] No custom audio, using fallback silent wav`);
audioRef.current = new Audio("data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAESsAACJWAAACABAAZGF0YRAAAAAA");
audioRef.current.load();
}
}, [customAudioBase64]);
useEffect(() => {
let currentRate = window.BorealisUpdateRate;
let intervalId = null;
const runLogic = () => {
const inputEdge = edges.find(e => e.target === id);
const sourceId = inputEdge?.source || null;
const val = sourceId ? (window.BorealisValueBus[sourceId] || "0") : "0";
setCurrentInput(val);
if (alertType === "Once") {
if (val === "1" && prevInput !== "1") {
console.log(`[Alert Node ${id}] Triggered ONCE playback`);
playSound();
}
}
setPrevInput(val);
};
const start = () => {
if (alertType === "Constant") {
intervalId = setInterval(() => {
const inputEdge = edges.find(e => e.target === id);
const sourceId = inputEdge?.source || null;
const val = sourceId ? (window.BorealisValueBus[sourceId] || "0") : "0";
setCurrentInput(val);
if (String(val) === "1") {
console.log(`[Alert Node ${id}] Triggered CONSTANT playback`);
playSound();
}
}, intervalMs);
} else {
intervalId = setInterval(runLogic, currentRate);
}
};
start();
const monitor = setInterval(() => {
const newRate = window.BorealisUpdateRate;
if (newRate !== currentRate && alertType === "Once") {
currentRate = newRate;
clearInterval(intervalId);
start();
}
}, 250);
return () => {
clearInterval(intervalId);
clearInterval(monitor);
};
}, [edges, alertType, intervalMs, prevInput]);
const indicatorColor = currentInput === "1" ? "#ff4444" : "#44ff44";
return (
<div className="borealis-node" style={{ position: "relative" }}>
<Handle type="target" position={Position.Left} className="borealis-handle" />
{/* Header with indicator dot */}
<div className="borealis-node-header" style={{ position: "relative" }}>
{data?.label || "Alert Sound"}
<div style={{
position: "absolute",
top: "50%",
right: "8px",
transform: "translateY(-50%)",
width: "10px",
height: "10px",
borderRadius: "50%",
backgroundColor: indicatorColor,
border: "1px solid #222"
}} />
</div>
<div className="borealis-node-content">
<div style={{ marginBottom: "8px", fontSize: "9px", color: "#ccc" }}>
Play a sound alert when input is "1"
</div>
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
Alerting Type:
</label>
<select
value={alertType}
onChange={(e) => setAlertType(e.target.value)}
style={dropdownStyle}
>
<option value="Once">Once</option>
<option value="Constant">Constant</option>
</select>
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
Alert Interval (ms):
</label>
<input
type="number"
min="100"
step="100"
value={intervalMs}
onChange={(e) => setIntervalMs(parseInt(e.target.value))}
disabled={alertType === "Once"}
style={{
...inputStyle,
background: alertType === "Once" ? "#2a2a2a" : "#1e1e1e"
}}
/>
<label style={{ fontSize: "9px", display: "block", marginTop: "6px", marginBottom: "4px" }}>
Custom Sound:
</label>
<div style={{ display: "flex", gap: "4px" }}>
<input
type="file"
accept=".wav,.mp3,.mpeg,.ogg"
onChange={handleFileUpload}
style={{ ...inputStyle, marginBottom: 0, flex: 1 }}
/>
<button
style={{
fontSize: "9px",
padding: "4px 8px",
backgroundColor: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
cursor: "pointer"
}}
onClick={playSound}
title="Test playback"
>
Test
</button>
</div>
</div>
</div>
);
};
const dropdownStyle = {
fontSize: "9px",
padding: "4px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
width: "100%",
marginBottom: "8px"
};
const inputStyle = {
fontSize: "9px",
padding: "4px",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
width: "100%",
marginBottom: "8px"
};
export default {
type: "AlertSoundNode",
label: "Alert Sound",
description: `
Plays a sound alert when input = "1"
- "Once" = Only when 0 -> 1 transition
- "Constant" = Repeats every X ms while input stays 1
- Custom audio supported (MP3/WAV/OGG)
- Base64 audio embedded in workflow and restored
- Visual status indicator (green = 0, red = 1)
- Manual "Test" button for validation
`.trim(),
content: "Sound alert when input value = 1",
component: AlertSoundNode
};

View File

@@ -0,0 +1,142 @@
import React, { useEffect, useRef, useState } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
// Ensure Borealis shared memory exists
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
const ArrayIndexExtractorNode = ({ id, data }) => {
const edges = useStore((state) => state.edges);
const { setNodes } = useReactFlow();
const [result, setResult] = useState("Line Does Not Exist");
const valueRef = useRef(result);
// Use config field, always 1-based for UX, fallback to 1
const lineNumber = parseInt(data?.lineNumber, 10) || 1;
useEffect(() => {
let intervalId = null;
let currentRate = window.BorealisUpdateRate;
const runNodeLogic = () => {
const inputEdge = edges.find((e) => e.target === id);
if (!inputEdge) {
valueRef.current = "Line Does Not Exist";
setResult("Line Does Not Exist");
window.BorealisValueBus[id] = "Line Does Not Exist";
return;
}
const upstreamValue = window.BorealisValueBus[inputEdge.source];
if (!Array.isArray(upstreamValue)) {
valueRef.current = "Line Does Not Exist";
setResult("Line Does Not Exist");
window.BorealisValueBus[id] = "Line Does Not Exist";
return;
}
const index = Math.max(0, lineNumber - 1); // 1-based to 0-based
const selected = upstreamValue[index] ?? "Line Does Not Exist";
if (selected !== valueRef.current) {
valueRef.current = selected;
setResult(selected);
window.BorealisValueBus[id] = selected;
}
};
intervalId = setInterval(runNodeLogic, currentRate);
// Monitor update rate live
const monitor = setInterval(() => {
const newRate = window.BorealisUpdateRate;
if (newRate !== currentRate) {
clearInterval(intervalId);
currentRate = newRate;
intervalId = setInterval(runNodeLogic, currentRate);
}
}, 300);
return () => {
clearInterval(intervalId);
clearInterval(monitor);
};
}, [id, edges, lineNumber]);
return (
<div className="borealis-node">
<Handle type="target" position={Position.Left} className="borealis-handle" />
<div className="borealis-node-header">
{data?.label || "Array Index Extractor"}
</div>
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
<div style={{ marginBottom: "6px", color: "#ccc" }}>
Output a specific line from an upstream array.
</div>
<div style={{ color: "#888", marginBottom: 4 }}>
Line Number: <b>{lineNumber}</b>
</div>
<label style={{ display: "block", marginBottom: "2px" }}>Output:</label>
<input
type="text"
value={result}
disabled
style={{
width: "100%",
fontSize: "9px",
background: "#2a2a2a",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
padding: "3px"
}}
/>
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
// ---- Node Registration Object with Sidebar Config & Markdown Docs ----
export default {
type: "ArrayIndexExtractor",
label: "Array Index Extractor",
description: `
Outputs a specific line from an upstream array, such as the result of OCR multi-line extraction.
- Specify the **line number** (1 = first line)
- Outputs the value at that index if present
- If index is out of bounds, outputs "Line Does Not Exist"
`.trim(),
content: "Output a Specific Array Index's Value",
component: ArrayIndexExtractorNode,
config: [
{
key: "lineNumber",
label: "Line Number (1 = First Line)",
type: "text",
defaultValue: "1"
}
],
usage_documentation: `
### Array Index Extractor Node
This node allows you to extract a specific line or item from an upstream array value.
**Typical Use:**
- Used after OCR or any node that outputs an array of lines or items.
- Set the **Line Number** (1-based, so "1" = first line).
**Behavior:**
- If the line exists, outputs the value at that position.
- If not, outputs: \`Line Does Not Exist\`.
**Input:**
- Connect an upstream node that outputs an array (such as OCR Text Extraction).
**Sidebar Config:**
- Set the desired line number from the configuration sidebar for live updates.
---
`.trim()
};

View File

@@ -0,0 +1,179 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Data Analysis & Manipulation/Node_JSON_Display.jsx
import React, { useEffect, useState, useRef, useCallback } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
// For syntax highlighting, ensure prismjs is installed: npm install prismjs
import Prism from "prismjs";
import "prismjs/components/prism-json";
import "prismjs/themes/prism-okaidia.css";
const JSONPrettyDisplayNode = ({ id, data }) => {
const { setNodes } = useReactFlow();
const edges = useStore((state) => state.edges);
const containerRef = useRef(null);
const resizingRef = useRef(false);
const startPosRef = useRef({ x: 0, y: 0 });
const startDimRef = useRef({ width: 0, height: 0 });
const [jsonData, setJsonData] = useState(data?.jsonData || {});
const initW = parseInt(data?.width || "300", 10);
const initH = parseInt(data?.height || "150", 10);
const [dimensions, setDimensions] = useState({ width: initW, height: initH });
const jsonRef = useRef(jsonData);
const persistDimensions = useCallback(() => {
const w = `${Math.round(dimensions.width)}px`;
const h = `${Math.round(dimensions.height)}px`;
setNodes((nds) =>
nds.map((n) =>
n.id === id
? { ...n, data: { ...n.data, width: w, height: h } }
: n
)
);
}, [dimensions, id, setNodes]);
useEffect(() => {
const onMouseMove = (e) => {
if (!resizingRef.current) return;
const dx = e.clientX - startPosRef.current.x;
const dy = e.clientY - startPosRef.current.y;
setDimensions({
width: Math.max(100, startDimRef.current.width + dx),
height: Math.max(60, startDimRef.current.height + dy)
});
};
const onMouseUp = () => {
if (resizingRef.current) {
resizingRef.current = false;
persistDimensions();
}
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
}, [persistDimensions]);
const onResizeMouseDown = (e) => {
e.stopPropagation();
resizingRef.current = true;
startPosRef.current = { x: e.clientX, y: e.clientY };
startDimRef.current = { ...dimensions };
};
useEffect(() => {
let rate = window.BorealisUpdateRate;
const tick = () => {
const edge = edges.find((e) => e.target === id);
if (edge && edge.source) {
const upstream = window.BorealisValueBus[edge.source];
if (typeof upstream === "object") {
if (JSON.stringify(upstream) !== JSON.stringify(jsonRef.current)) {
jsonRef.current = upstream;
setJsonData(upstream);
window.BorealisValueBus[id] = upstream;
setNodes((nds) =>
nds.map((n) =>
n.id === id ? { ...n, data: { ...n.data, jsonData: upstream } } : n
)
);
}
}
} else {
window.BorealisValueBus[id] = jsonRef.current;
}
};
const iv = setInterval(tick, rate);
const monitor = setInterval(() => {
if (window.BorealisUpdateRate !== rate) {
clearInterval(iv);
clearInterval(monitor);
}
}, 200);
return () => { clearInterval(iv); clearInterval(monitor); };
}, [id, edges, setNodes]);
// Generate highlighted HTML
const pretty = JSON.stringify(jsonData, null, 2);
const highlighted = Prism.highlight(pretty, Prism.languages.json, "json");
return (
<div
ref={containerRef}
className="borealis-node"
style={{
display: "flex",
flexDirection: "column",
width: dimensions.width,
height: dimensions.height,
overflow: "visible",
position: "relative",
boxSizing: "border-box"
}}
>
<Handle type="target" position={Position.Left} className="borealis-handle" />
<Handle type="source" position={Position.Right} className="borealis-handle" />
<div className="borealis-node-header">Display JSON Data</div>
<div
className="borealis-node-content"
style={{
flex: 1,
padding: "4px",
fontSize: "9px",
color: "#ccc",
display: "flex",
flexDirection: "column",
overflow: "hidden"
}}
>
<div style={{ marginBottom: "4px" }}>
Display prettified JSON from upstream.
</div>
<div
style={{
flex: 1,
width: "100%",
background: "#1e1e1e",
border: "1px solid #444",
borderRadius: "2px",
padding: "4px",
overflowY: "auto",
fontFamily: "monospace",
fontSize: "9px"
}}
>
<pre
dangerouslySetInnerHTML={{ __html: highlighted }}
style={{ margin: 0 }}
/>
</div>
</div>
<div
onMouseDown={onResizeMouseDown}
style={{
position: "absolute",
width: "20px",
height: "20px",
right: "-4px",
bottom: "-4px",
cursor: "nwse-resize",
background: "transparent",
zIndex: 10
}}
/>
</div>
);
};
export default {
type: "Node_JSON_Pretty_Display",
label: "Display JSON Data",
description: "Display upstream JSON object as prettified JSON with syntax highlighting.",
content: "Display prettified multi-line JSON from upstream node.",
component: JSONPrettyDisplayNode
};

View File

@@ -0,0 +1,132 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Data Analysis & Manipulation/Node_JSON_Value_Extractor.jsx
import React, { useState, useEffect } from "react";
import { Handle, Position, useReactFlow } from "reactflow";
const JSONValueExtractorNode = ({ id, data }) => {
const { setNodes, getEdges } = useReactFlow();
const [keyName, setKeyName] = useState(data?.keyName || "");
const [value, setValue] = useState(data?.result || "");
const handleKeyChange = (e) => {
const newKey = e.target.value;
setKeyName(newKey);
setNodes((nds) =>
nds.map((n) =>
n.id === id
? { ...n, data: { ...n.data, keyName: newKey } }
: n
)
);
};
useEffect(() => {
let currentRate = window.BorealisUpdateRate;
let intervalId;
const runNodeLogic = () => {
const edges = getEdges();
const incoming = edges.filter((e) => e.target === id);
const sourceId = incoming[0]?.source;
let newValue = "Key Not Found";
if (sourceId && window.BorealisValueBus[sourceId] !== undefined) {
let upstream = window.BorealisValueBus[sourceId];
if (upstream && typeof upstream === "object" && keyName) {
const pathSegments = keyName.split(".");
let nodeVal = upstream;
for (let segment of pathSegments) {
if (
nodeVal != null &&
(typeof nodeVal === "object" || Array.isArray(nodeVal)) &&
segment in nodeVal
) {
nodeVal = nodeVal[segment];
} else {
nodeVal = undefined;
break;
}
}
if (nodeVal !== undefined) {
newValue = String(nodeVal);
}
}
}
if (newValue !== value) {
setValue(newValue);
window.BorealisValueBus[id] = newValue;
setNodes((nds) =>
nds.map((n) =>
n.id === id
? { ...n, data: { ...n.data, result: newValue } }
: n
)
);
}
};
runNodeLogic();
intervalId = setInterval(runNodeLogic, currentRate);
const monitor = setInterval(() => {
const newRate = window.BorealisUpdateRate;
if (newRate !== currentRate) {
clearInterval(intervalId);
currentRate = newRate;
intervalId = setInterval(runNodeLogic, currentRate);
}
}, 250);
return () => {
clearInterval(intervalId);
clearInterval(monitor);
};
}, [keyName, id, setNodes, getEdges, value]);
return (
<div className="borealis-node">
<div className="borealis-node-header">JSON Value Extractor</div>
<div className="borealis-node-content">
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
Key:
</label>
<input
type="text"
value={keyName}
onChange={handleKeyChange}
placeholder="e.g. name.en"
style={{
fontSize: "9px", padding: "4px", width: "100%",
background: "#1e1e1e", color: "#ccc",
border: "1px solid #444", borderRadius: "2px"
}}
/>
<label style={{ fontSize: "9px", display: "block", margin: "8px 0 4px" }}>
Value:
</label>
<textarea
readOnly
value={value}
rows={2}
style={{
fontSize: "9px", padding: "4px", width: "100%",
background: "#1e1e1e", color: "#ccc",
border: "1px solid #444", borderRadius: "2px",
resize: "none"
}}
/>
</div>
<Handle type="target" position={Position.Left} className="borealis-handle" />
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
export default {
type: "JSON_Value_Extractor",
label: "JSON Value Extractor",
description: "Extract a nested value by dot-delimited path from upstream JSON data.",
content: "Provide a dot-separated key path (e.g. 'name.en'); outputs the extracted string or 'Key Not Found'.",
component: JSONValueExtractorNode
};

View File

@@ -0,0 +1,238 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_OCR_Text_Extraction.jsx
import React, { useEffect, useState, useRef } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
// Lightweight hash for image change detection
const getHashScore = (str = "") => {
let hash = 0;
for (let i = 0; i < str.length; i += 101) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash |= 0;
}
return Math.abs(hash);
};
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
const OCRNode = ({ id, data }) => {
const edges = useStore((state) => state.edges);
const { setNodes } = useReactFlow();
const [ocrOutput, setOcrOutput] = useState("");
const valueRef = useRef("");
const lastUsed = useRef({ engine: "", backend: "", dataType: "" });
const lastProcessedAt = useRef(0);
const lastImageHash = useRef(0);
// Always get config from props (sidebar sets these in node.data)
const engine = data?.engine || "None";
const backend = data?.backend || "CPU";
const dataType = data?.dataType || "Mixed";
const customRateEnabled = data?.customRateEnabled ?? true;
const customRateMs = data?.customRateMs || 1000;
const changeThreshold = data?.changeThreshold || 0;
// OCR API Call
const sendToOCRAPI = async (base64) => {
const cleanBase64 = base64.replace(/^data:image\/[a-zA-Z]+;base64,/, "");
try {
const response = await fetch("/api/ocr", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_base64: cleanBase64, engine, backend })
});
const result = await response.json();
return response.ok && Array.isArray(result.lines)
? result.lines
: [`[ERROR] ${result.error || "Invalid OCR response."}`];
} catch (err) {
return [`[ERROR] OCR API request failed: ${err.message}`];
}
};
// Filter lines based on user type
const filterLines = (lines) => {
if (dataType === "Numerical") {
return lines.map(line => line.replace(/[^\d.%\s]/g, '').replace(/\s+/g, ' ').trim()).filter(Boolean);
}
if (dataType === "String") {
return lines.map(line => line.replace(/[^a-zA-Z\s]/g, '').replace(/\s+/g, ' ').trim()).filter(Boolean);
}
return lines;
};
useEffect(() => {
let intervalId = null;
let currentRate = window.BorealisUpdateRate || 100;
const runNodeLogic = async () => {
const inputEdge = edges.find((e) => e.target === id);
if (!inputEdge) {
window.BorealisValueBus[id] = [];
setOcrOutput("");
return;
}
const upstreamValue = window.BorealisValueBus[inputEdge.source] || "";
const now = Date.now();
const effectiveRate = customRateEnabled ? customRateMs : window.BorealisUpdateRate || 100;
const configChanged =
lastUsed.current.engine !== engine ||
lastUsed.current.backend !== backend ||
lastUsed.current.dataType !== dataType;
const upstreamHash = getHashScore(upstreamValue);
const hashDelta = Math.abs(upstreamHash - lastImageHash.current);
const hashThreshold = (changeThreshold / 100) * 1000000000;
const imageChanged = hashDelta > hashThreshold;
if (!configChanged && (!imageChanged || (now - lastProcessedAt.current < effectiveRate))) return;
lastUsed.current = { engine, backend, dataType };
lastProcessedAt.current = now;
lastImageHash.current = upstreamHash;
valueRef.current = upstreamValue;
const lines = await sendToOCRAPI(upstreamValue);
const filtered = filterLines(lines);
setOcrOutput(filtered.join("\n"));
window.BorealisValueBus[id] = filtered;
};
intervalId = setInterval(runNodeLogic, currentRate);
const monitor = setInterval(() => {
const newRate = window.BorealisUpdateRate || 100;
if (newRate !== currentRate) {
clearInterval(intervalId);
intervalId = setInterval(runNodeLogic, newRate);
currentRate = newRate;
}
}, 300);
return () => {
clearInterval(intervalId);
clearInterval(monitor);
};
}, [id, engine, backend, dataType, customRateEnabled, customRateMs, changeThreshold, edges]);
return (
<div className="borealis-node" style={{ minWidth: "200px" }}>
<Handle type="target" position={Position.Left} className="borealis-handle" />
<div className="borealis-node-header">OCR-Based Text Extraction</div>
<div className="borealis-node-content">
<div style={{ fontSize: "9px", marginBottom: "8px", color: "#ccc" }}>
Extract Multi-Line Text from Upstream Image Node
</div>
<label style={labelStyle}>OCR Output:</label>
<textarea
readOnly
value={ocrOutput}
rows={6}
style={{
width: "100%",
fontSize: "9px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
padding: "4px",
resize: "vertical"
}}
/>
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
const labelStyle = {
fontSize: "9px",
display: "block",
marginTop: "6px",
marginBottom: "2px"
};
// Node registration for Borealis (modern, sidebar-enabled)
export default {
type: "OCR_Text_Extraction",
label: "OCR Text Extraction",
description: `Extract text from upstream image using backend OCR engine via API. Includes rate limiting and sensitivity detection for smart processing.`,
content: "Extract Multi-Line Text from Upstream Image Node",
component: OCRNode,
config: [
{
key: "engine",
label: "OCR Engine",
type: "select",
options: ["None", "TesseractOCR", "EasyOCR"],
defaultValue: "None"
},
{
key: "backend",
label: "Compute Backend",
type: "select",
options: ["CPU", "GPU"],
defaultValue: "CPU"
},
{
key: "dataType",
label: "Data Type Filter",
type: "select",
options: ["Mixed", "Numerical", "String"],
defaultValue: "Mixed"
},
{
key: "customRateEnabled",
label: "Custom API Rate Limit Enabled",
type: "select",
options: ["true", "false"],
defaultValue: "true"
},
{
key: "customRateMs",
label: "Custom API Rate Limit (ms)",
type: "text",
defaultValue: "1000"
},
{
key: "changeThreshold",
label: "Change Detection Sensitivity (0-100)",
type: "text",
defaultValue: "0"
}
],
usage_documentation: `
### OCR Text Extraction Node
Extracts text (lines) from an **upstream image node** using a selectable backend OCR engine (Tesseract or EasyOCR). Designed for screenshots, scanned forms, and live image data pipelines.
**Features:**
- **Engine:** Select between None, TesseractOCR, or EasyOCR
- **Backend:** Choose CPU or GPU (if supported)
- **Data Type Filter:** Post-processes recognized lines for numerical-only or string-only content
- **Custom API Rate Limit:** When enabled, you can set a custom polling rate for OCR requests (in ms)
- **Change Detection Sensitivity:** Node will only re-OCR if the input image changes significantly (hash-based, 0 disables)
**Outputs:**
- Array of recognized lines, pushed to downstream nodes
- Output is displayed in the node (read-only)
**Usage:**
- Connect an image node (base64 output) to this node's input
- Configure OCR engine and options in the sidebar
- Useful for extracting values from screen regions, live screenshots, PDF scans, etc.
**Notes:**
- Setting Engine to 'None' disables OCR
- Use numerical/string filter for precise downstream parsing
- Polling rate too fast may cause backend overload
- Change threshold is a 0-100 scale (0 = always run, 100 = image must change completely)
`.trim()
};

View File

@@ -0,0 +1,211 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Data Manipulation/Node_Regex_Replace.jsx
import React, { useEffect, useRef, useState } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
// Shared memory bus setup
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
// -- Modern Regex Replace Node -- //
const RegexReplaceNode = ({ id, data }) => {
const edges = useStore((state) => state.edges);
const { setNodes } = useReactFlow();
// Maintain output live value
const [result, setResult] = useState("");
const [original, setOriginal] = useState("");
const valueRef = useRef("");
useEffect(() => {
let intervalId = null;
let currentRate = window.BorealisUpdateRate;
const runNodeLogic = () => {
const inputEdge = edges.find((e) => e.target === id);
const inputValue = inputEdge
? window.BorealisValueBus[inputEdge.source] || ""
: "";
setOriginal(inputValue);
let newVal = inputValue;
try {
if ((data?.enabled ?? true) && data?.pattern) {
const regex = new RegExp(data.pattern, data.flags || "g");
let safeReplacement = (data.replacement ?? "").trim();
// Remove quotes if user adds them
if (
safeReplacement.startsWith('"') &&
safeReplacement.endsWith('"')
) {
safeReplacement = safeReplacement.slice(1, -1);
}
newVal = inputValue.replace(regex, safeReplacement);
}
} catch (err) {
newVal = `[Error] ${err.message}`;
}
if (newVal !== valueRef.current) {
valueRef.current = newVal;
setResult(newVal);
window.BorealisValueBus[id] = newVal;
}
};
intervalId = setInterval(runNodeLogic, currentRate);
// Monitor update rate changes
const monitor = setInterval(() => {
const newRate = window.BorealisUpdateRate;
if (newRate !== currentRate) {
clearInterval(intervalId);
intervalId = setInterval(runNodeLogic, newRate);
currentRate = newRate;
}
}, 300);
return () => {
clearInterval(intervalId);
clearInterval(monitor);
};
}, [id, edges, data?.pattern, data?.replacement, data?.flags, data?.enabled]);
return (
<div className="borealis-node">
<Handle type="target" position={Position.Left} className="borealis-handle" />
<div className="borealis-node-header">
{data?.label || "Regex Replace"}
</div>
<div className="borealis-node-content">
<div style={{ marginBottom: "6px", fontSize: "9px", color: "#ccc" }}>
Performs live regex-based find/replace on incoming string value.
</div>
<div style={{ fontSize: "9px", color: "#ccc", marginBottom: 2 }}>
<b>Pattern:</b> {data?.pattern || <i>(not set)</i>}<br />
<b>Flags:</b> {data?.flags || "g"}<br />
<b>Enabled:</b> {(data?.enabled ?? true) ? "Yes" : "No"}
</div>
<label style={{ fontSize: "8px", color: "#888" }}>Original:</label>
<textarea
readOnly
value={original}
rows={2}
style={{
width: "100%",
fontSize: "9px",
background: "#222",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
padding: "3px",
resize: "vertical",
marginBottom: "6px"
}}
/>
<label style={{ fontSize: "8px", color: "#888" }}>Output:</label>
<textarea
readOnly
value={result}
rows={2}
style={{
width: "100%",
fontSize: "9px",
background: "#2a2a2a",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
padding: "3px",
resize: "vertical"
}}
/>
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
// Modern Node Export: Sidebar config, usage docs, sensible defaults
export default {
type: "RegexReplace",
label: "Regex Replace",
description: `
Live regex-based string find/replace node.
- Runs a JavaScript regular expression on every input update.
- Useful for cleanup, format fixes, redacting, token extraction.
- Configurable flags, replacement text, and enable toggle.
- Handles errors gracefully, shows live preview in the sidebar.
`.trim(),
content: "Perform regex replacement on incoming string",
component: RegexReplaceNode,
config: [
{
key: "pattern",
label: "Regex Pattern",
type: "text",
defaultValue: "\\d+"
},
{
key: "replacement",
label: "Replacement",
type: "text",
defaultValue: ""
},
{
key: "flags",
label: "Regex Flags",
type: "text",
defaultValue: "g"
},
{
key: "enabled",
label: "Enable Replacement",
type: "select",
options: ["true", "false"],
defaultValue: "true"
}
],
usage_documentation: `
### Regex Replace Node
**Purpose:**
Perform flexible find-and-replace on strings using JavaScript-style regular expressions.
#### Typical Use Cases
- Clean up text, numbers, or IDs in a data stream
- Mask or redact sensitive info (emails, credit cards, etc)
- Extract tokens, words, or reformat content
#### Configuration (see "Config" tab):
- **Regex Pattern**: The search pattern (supports capture groups)
- **Replacement**: The replacement string. You can use \`$1, $2\` for capture groups.
- **Regex Flags**: Default \`g\` (global). Add \`i\` (case-insensitive), \`m\` (multiline), etc.
- **Enable Replacement**: On/Off toggle (for easy debugging)
#### Behavior
- Upstream value is live-updated.
- When enabled, node applies the regex and emits the result downstream.
- Shows both input and output in the sidebar for debugging.
- If the regex is invalid, error is displayed as output.
#### Output
- Emits the transformed string to all downstream nodes.
- Updates in real time at the global Borealis update rate.
#### Example
Pattern: \`(\\d+)\`
Replacement: \`[number:$1]\`
Input: \`abc 123 def 456\`
Output: \`abc [number:123] def [number:456]\`
---
**Tips:**
- Use double backslashes (\\) in patterns when needed (e.g. \`\\\\d+\`).
- Flags can be any combination (e.g. \`gi\`).
`.trim()
};

View File

@@ -0,0 +1,140 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Data Analysis/Node_Regex_Search.jsx
import React, { useEffect, useRef, useState } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
// Modern Regex Search Node: Config via Sidebar
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
const RegexSearchNode = ({ id, data }) => {
const { setNodes } = useReactFlow();
const edges = useStore((state) => state.edges);
// Pattern/flags always come from sidebar config (with defaults)
const pattern = data?.pattern ?? "";
const flags = data?.flags ?? "i";
const valueRef = useRef("0");
const [matched, setMatched] = useState("0");
useEffect(() => {
let intervalId = null;
let currentRate = window.BorealisUpdateRate;
const runNodeLogic = () => {
const inputEdge = edges.find((e) => e.target === id);
const inputVal = inputEdge ? window.BorealisValueBus[inputEdge.source] || "" : "";
let matchResult = false;
try {
if (pattern) {
const regex = new RegExp(pattern, flags);
matchResult = regex.test(inputVal);
}
} catch {
matchResult = false;
}
const result = matchResult ? "1" : "0";
if (result !== valueRef.current) {
valueRef.current = result;
setMatched(result);
window.BorealisValueBus[id] = result;
setNodes((nds) =>
nds.map((n) =>
n.id === id ? { ...n, data: { ...n.data, match: result } } : n
)
);
}
};
intervalId = setInterval(runNodeLogic, currentRate);
const monitor = setInterval(() => {
const newRate = window.BorealisUpdateRate;
if (newRate !== currentRate) {
clearInterval(intervalId);
intervalId = setInterval(runNodeLogic, newRate);
currentRate = newRate;
}
}, 300);
return () => {
clearInterval(intervalId);
clearInterval(monitor);
};
}, [id, edges, pattern, flags, setNodes]);
return (
<div className="borealis-node">
<Handle type="target" position={Position.Left} className="borealis-handle" />
<div className="borealis-node-header">
{data?.label || "Regex Search"}
</div>
<div className="borealis-node-content" style={{ fontSize: "9px", color: "#ccc" }}>
Match: {matched}
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
export default {
type: "RegexSearch",
label: "Regex Search",
description: `
Test for text matches with a regular expression pattern.
- Accepts a regex pattern and flags (e.g. "i", "g", "m")
- Connect any node to the input to test its value.
- Outputs "1" if the regex matches, otherwise "0".
- Useful for input validation, filtering, or text triggers.
`.trim(),
content: "Outputs '1' if regex matches input, otherwise '0'",
component: RegexSearchNode,
config: [
{
key: "pattern",
label: "Regex Pattern",
type: "text",
defaultValue: "",
placeholder: "e.g. World"
},
{
key: "flags",
label: "Regex Flags",
type: "text",
defaultValue: "i",
placeholder: "e.g. i"
}
],
usage_documentation: `
### Regex Search Node
This node tests its input value against a user-supplied regular expression pattern.
**Configuration (Sidebar):**
- **Regex Pattern**: Standard JavaScript regex pattern.
- **Regex Flags**: Any combination of \`i\` (ignore case), \`g\` (global), \`m\` (multiline), etc.
**Input:**
- Accepts a string from any upstream node.
**Output:**
- Emits "1" if the pattern matches the input string.
- Emits "0" if there is no match or the pattern/flags are invalid.
**Common Uses:**
- Search for words/phrases in extracted text.
- Filter values using custom patterns.
- Create triggers based on input structure (e.g. validate an email, detect phone numbers, etc).
#### Example:
- **Pattern:** \`World\`
- **Flags:** \`i\`
- **Input:** \`Hello world!\`
- **Output:** \`1\` (matched, case-insensitive)
`.trim()
};

View File

@@ -0,0 +1,190 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/nodes/Data Analysis/Node_TextArray_Display.jsx
/**
* Display Multi-Line Array Node
* --------------------------------------------------
* A node to display upstream multi-line text arrays.
* Has one input edge on left and passthrough output on right.
* Custom drag-resize handle for width & height adjustments.
* Inner textarea scrolls vertically; container overflow visible.
*/
import React, { useEffect, useState, useRef, useCallback } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
const TextArrayDisplayNode = ({ id, data }) => {
const { setNodes } = useReactFlow();
const edges = useStore((state) => state.edges);
const containerRef = useRef(null);
const resizingRef = useRef(false);
const startPosRef = useRef({ x: 0, y: 0 });
const startDimRef = useRef({ width: 0, height: 0 });
// Initialize lines and dimensions
const [lines, setLines] = useState(data?.lines || []);
const linesRef = useRef(lines);
const initW = parseInt(data?.width || "300", 10);
const initH = parseInt(data?.height || "150", 10);
const [dimensions, setDimensions] = useState({ width: initW, height: initH });
// Persist dimensions to node data
const persistDimensions = useCallback(() => {
const w = `${Math.round(dimensions.width)}px`;
const h = `${Math.round(dimensions.height)}px`;
setNodes((nds) =>
nds.map((n) =>
n.id === id
? { ...n, data: { ...n.data, width: w, height: h } }
: n
)
);
}, [dimensions, id, setNodes]);
// Mouse handlers for custom resize
useEffect(() => {
const onMouseMove = (e) => {
if (!resizingRef.current) return;
const dx = e.clientX - startPosRef.current.x;
const dy = e.clientY - startPosRef.current.y;
setDimensions({
width: Math.max(100, startDimRef.current.width + dx),
height: Math.max(60, startDimRef.current.height + dy)
});
};
const onMouseUp = () => {
if (resizingRef.current) {
resizingRef.current = false;
persistDimensions();
}
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
}, [persistDimensions]);
// Start drag
const onResizeMouseDown = (e) => {
e.stopPropagation();
resizingRef.current = true;
startPosRef.current = { x: e.clientX, y: e.clientY };
startDimRef.current = { ...dimensions };
};
// Polling for upstream data
useEffect(() => {
let rate = window.BorealisUpdateRate;
const tick = () => {
const edge = edges.find((e) => e.target === id);
if (edge && edge.source) {
const arr = window.BorealisValueBus[edge.source] || [];
if (JSON.stringify(arr) !== JSON.stringify(linesRef.current)) {
linesRef.current = arr;
setLines(arr);
window.BorealisValueBus[id] = arr;
setNodes((nds) =>
nds.map((n) =>
n.id === id ? { ...n, data: { ...n.data, lines: arr } } : n
)
);
}
} else {
window.BorealisValueBus[id] = linesRef.current;
}
};
const iv = setInterval(tick, rate);
const monitor = setInterval(() => {
if (window.BorealisUpdateRate !== rate) {
clearInterval(iv);
clearInterval(monitor);
}
}, 200);
return () => { clearInterval(iv); clearInterval(monitor); };
}, [id, edges, setNodes]);
return (
<div
ref={containerRef}
className="borealis-node"
style={{
display: "flex",
flexDirection: "column",
width: dimensions.width,
height: dimensions.height,
overflow: "visible",
position: "relative",
boxSizing: "border-box"
}}
>
{/* Connectors */}
<Handle type="target" position={Position.Left} className="borealis-handle" />
<Handle type="source" position={Position.Right} className="borealis-handle" />
{/* Header */}
<div className="borealis-node-header">
{data?.label || "Display Multi-Line Array"}
</div>
{/* Content */}
<div
className="borealis-node-content"
style={{
flex: 1,
padding: "4px",
fontSize: "9px",
color: "#ccc",
display: "flex",
flexDirection: "column",
overflow: "hidden"
}}
>
<div style={{ marginBottom: "4px" }}>
{data?.content || "Display upstream multi-line text arrays."}
</div>
<label style={{ marginBottom: "4px" }}>Upstream Text Data:</label>
<textarea
value={lines.join("\n")}
readOnly
style={{
flex: 1,
width: "100%",
fontSize: "9px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
padding: "4px",
resize: "none",
overflowY: "auto",
boxSizing: "border-box"
}}
/>
</div>
{/* Invisible drag-resize handle */}
<div
onMouseDown={onResizeMouseDown}
style={{
position: "absolute",
width: "20px",
height: "20px",
right: "-4px",
bottom: "-4px",
cursor: "nwse-resize",
background: "transparent",
zIndex: 10
}}
/>
</div>
);
};
// Export node metadata
export default {
type: "Node_TextArray_Display",
label: "Display Multi-Line Array",
description: "Display upstream multi-line text arrays.",
content: "Display upstream multi-line text arrays.",
component: TextArrayDisplayNode
};

View File

@@ -0,0 +1,193 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Data Collection/Node_API_Request.jsx
import React, { useState, useEffect, useRef } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
// API Request Node (Modern, Sidebar Config Enabled)
const APIRequestNode = ({ id, data }) => {
const { setNodes } = useReactFlow();
const edges = useStore((state) => state.edges);
if (!window.BorealisValueBus) window.BorealisValueBus = {};
// Use config values, but coerce types
const url = data?.url || "http://localhost:5000/health";
// Note: Store useProxy as a string ("true"/"false"), convert to boolean for logic
const useProxy = (data?.useProxy ?? "true") === "true";
const body = data?.body || "";
const intervalSec = parseInt(data?.intervalSec || "10", 10) || 10;
// Status State
const [error, setError] = useState(null);
const [statusCode, setStatusCode] = useState(null);
const [statusText, setStatusText] = useState("");
const resultRef = useRef(null);
useEffect(() => {
let cancelled = false;
const runNodeLogic = async () => {
try {
setError(null);
// Allow dynamic URL override from upstream node (if present)
const inputEdge = edges.find((e) => e.target === id);
const upstreamUrl = inputEdge ? window.BorealisValueBus[inputEdge.source] : null;
const resolvedUrl = upstreamUrl || url;
let target = useProxy ? `/api/proxy?url=${encodeURIComponent(resolvedUrl)}` : resolvedUrl;
const options = {};
if (body.trim()) {
options.method = "POST";
options.headers = { "Content-Type": "application/json" };
options.body = body;
}
const res = await fetch(target, options);
setStatusCode(res.status);
setStatusText(res.statusText);
if (!res.ok) {
resultRef.current = null;
window.BorealisValueBus[id] = undefined;
setNodes((nds) =>
nds.map((n) =>
n.id === id ? { ...n, data: { ...n.data, result: undefined } } : n
)
);
throw new Error(`HTTP ${res.status}`);
}
const json = await res.json();
const pretty = JSON.stringify(json, null, 2);
if (!cancelled && resultRef.current !== pretty) {
resultRef.current = pretty;
window.BorealisValueBus[id] = json;
setNodes((nds) =>
nds.map((n) =>
n.id === id ? { ...n, data: { ...n.data, result: pretty } } : n
)
);
}
} catch (err) {
console.error("API Request node fetch error:", err);
setError(err.message);
}
};
runNodeLogic();
const ms = Math.max(intervalSec, 1) * 1000;
const iv = setInterval(runNodeLogic, ms);
return () => {
cancelled = true;
clearInterval(iv);
};
}, [url, body, intervalSec, useProxy, id, setNodes, edges]);
// Upstream disables direct editing of URL in the UI
const inputEdge = edges.find((e) => e.target === id);
const hasUpstream = Boolean(inputEdge && inputEdge.source);
// -- Node Card Render (minimal: sidebar handles config) --
return (
<div className="borealis-node">
<Handle type="target" position={Position.Left} className="borealis-handle" />
<div className="borealis-node-header">
{data?.label || "API Request"}
</div>
<div className="borealis-node-content" style={{ fontSize: "9px", color: "#ccc" }}>
<div>
<b>Status:</b>{" "}
{error ? (
<span style={{ color: "#f66" }}>{error}</span>
) : statusCode !== null ? (
<span style={{ color: "#6f6" }}>{statusCode} {statusText}</span>
) : (
"N/A"
)}
</div>
<div style={{ marginTop: "4px" }}>
<b>Result:</b>
<pre style={{
background: "#181818",
color: "#b6ffb4",
fontSize: "8px",
maxHeight: 62,
overflow: "auto",
margin: 0,
padding: "4px",
borderRadius: "2px"
}}>{data?.result ? String(data.result).slice(0, 350) : "No data"}</pre>
</div>
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
// Node Registration Object with sidebar config + docs
export default {
type: "API_Request",
label: "API Request",
description: "Fetch JSON from an API endpoint with optional POST body, polling, and proxy toggle. Accepts URL from upstream.",
content: "Fetch JSON from HTTP or remote API endpoint, with CORS proxy option.",
component: APIRequestNode,
config: [
{
key: "url",
label: "Request URL",
type: "text",
defaultValue: "http://localhost:5000/health"
},
{
key: "useProxy",
label: "Use Proxy (bypass CORS)",
type: "select",
options: ["true", "false"],
defaultValue: "true"
},
{
key: "body",
label: "Request Body (JSON)",
type: "textarea",
defaultValue: ""
},
{
key: "intervalSec",
label: "Polling Interval (sec)",
type: "text",
defaultValue: "10"
}
],
usage_documentation: `
### API Request Node
Fetches JSON from an HTTP or HTTPS API endpoint, with an option to POST a JSON body and control polling interval.
**Features:**
- **URL**: You can set a static URL, or connect an upstream node to dynamically control the API endpoint.
- **Use Proxy**: When enabled, requests route through the Borealis backend proxy to bypass CORS/browser restrictions.
- **Request Body**: POST JSON data (leave blank for GET).
- **Polling Interval**: Set how often (in seconds) to re-fetch the API.
**Outputs:**
- The downstream value is the parsed JSON object from the API response.
**Typical Use Cases:**
- Poll external APIs (weather, status, data, etc)
- Connect to local/internal REST endpoints
- Build data pipelines with API triggers
**Input & UI Behavior:**
- If an upstream node is connected, its output value will override the Request URL.
- All config is handled in the right sidebar (Node Properties).
**Error Handling:**
- If the fetch fails, the node displays the error in the UI.
- Only 2xx status codes are considered successful.
**Security Note:**
- Use Proxy mode for APIs requiring CORS bypass or additional privacy.
`.trim()
};

View File

@@ -0,0 +1,123 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Data/Node_Upload_Text.jsx
/**
* Upload Text File Node
* --------------------------------------------------
* A node to upload a text file (TXT/LOG/INI/ETC) and store it as a multi-line text array.
* No input edges. Outputs an array of text lines via the shared value bus.
*/
import React, { useEffect, useState, useRef } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
const UploadTextFileNode = ({ id, data }) => {
const { setNodes } = useReactFlow();
const edges = useStore((state) => state.edges);
// Initialize lines from persisted data or empty
const initialLines = data?.lines || [];
const [lines, setLines] = useState(initialLines);
const linesRef = useRef(initialLines);
const fileInputRef = useRef(null);
// Handle file selection and reading
const handleFileChange = (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
file.text().then((text) => {
const arr = text.split(/\r\n|\n/);
linesRef.current = arr;
setLines(arr);
// Broadcast to shared bus
window.BorealisValueBus[id] = arr;
// Persist data for workflow serialization
setNodes((nds) =>
nds.map((n) =>
n.id === id
? { ...n, data: { ...n.data, lines: arr } }
: n
)
);
});
};
// Trigger file input click
const handleUploadClick = () => {
fileInputRef.current?.click();
};
// Periodically broadcast current lines to bus
useEffect(() => {
let currentRate = window.BorealisUpdateRate;
const intervalId = setInterval(() => {
window.BorealisValueBus[id] = linesRef.current;
}, currentRate);
// Monitor for rate changes
const monitorId = setInterval(() => {
const newRate = window.BorealisUpdateRate;
if (newRate !== currentRate) {
clearInterval(intervalId);
clearInterval(monitorId);
}
}, 250);
return () => {
clearInterval(intervalId);
clearInterval(monitorId);
};
}, [id]);
return (
<div className="borealis-node">
{/* No input handle for this node */}
<div className="borealis-node-header">
{data?.label || "Upload Text File"}
</div>
<div className="borealis-node-content">
<div style={{ marginBottom: "8px", fontSize: "9px", color: "#ccc" }}>
{data?.content ||
"Upload a text-based file, output a multi-line string array."}
</div>
<button
onClick={handleUploadClick}
style={{
width: "100%",
padding: "6px",
fontSize: "9px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
cursor: "pointer"
}}
>
Select File...
</button>
<input
type="file"
accept=".txt,.log,.ini,text/*"
style={{ display: "none" }}
ref={fileInputRef}
onChange={handleFileChange}
/>
</div>
{/* Output connector on right */}
<Handle
type="source"
position={Position.Right}
className="borealis-handle"
/>
</div>
);
};
// Export node metadata for Borealis
export default {
type: "Upload_Text_File",
label: "Upload Text File",
description: "A node to upload a text file (TXT/LOG/INI/ETC) and store it as a multi-line text array.",
content: "Upload a text-based file, output a multi-line string array.",
component: UploadTextFileNode
};

View File

@@ -0,0 +1,218 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/General Purpose/Node_Edge_Toggle.jsx
import React, { useEffect, useState, useRef } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
import Switch from "@mui/material/Switch";
import Tooltip from "@mui/material/Tooltip";
/*
Borealis - Edge Toggle Node
===========================
Allows users to toggle data flow between upstream and downstream nodes.
- When enabled: propagates upstream value.
- When disabled: outputs "0" (or null/blank) so downstream sees a cleared value.
Fully captures and restores toggle state ("enabled"/"disabled") from imported workflow JSON,
so state is always restored as last persisted.
*/
// Init shared value bus if needed
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
const EdgeToggleNode = ({ id, data }) => {
const { setNodes } = useReactFlow();
const edges = useStore((state) => state.edges);
// === CAPTURE persisted toggle state on load/rehydrate ===
// Restore "enabled" from node data if present, otherwise true
const [enabled, setEnabled] = useState(
typeof data?.enabled === "boolean"
? data.enabled
: data?.enabled === "false"
? false
: data?.enabled === "true"
? true
: data?.enabled !== undefined
? !!data.enabled
: true
);
// Store last output value
const [outputValue, setOutputValue] = useState(
typeof data?.value !== "undefined" ? data.value : undefined
);
const outputRef = useRef(outputValue);
// === Persist toggle state back to node data when toggled ===
useEffect(() => {
setNodes((nds) =>
nds.map((n) =>
n.id === id ? { ...n, data: { ...n.data, enabled } } : n
)
);
}, [enabled, id, setNodes]);
// === On mount: restore BorealisValueBus from loaded node data if present ===
useEffect(() => {
// Only run on first mount
if (typeof data?.value !== "undefined") {
window.BorealisValueBus[id] = data.value;
setOutputValue(data.value);
outputRef.current = data.value;
}
}, [id, data?.value]);
// === Main interval logic: live propagate upstream/clear if off ===
useEffect(() => {
let interval = null;
let currentRate = window.BorealisUpdateRate || 100;
const runNodeLogic = () => {
const inputEdge = edges.find((e) => e.target === id);
const hasInput = Boolean(inputEdge && inputEdge.source);
if (enabled && hasInput) {
const upstreamValue = window.BorealisValueBus[inputEdge.source];
if (upstreamValue !== outputRef.current) {
outputRef.current = upstreamValue;
setOutputValue(upstreamValue);
window.BorealisValueBus[id] = upstreamValue;
setNodes((nds) =>
nds.map((n) =>
n.id === id
? { ...n, data: { ...n.data, value: upstreamValue } }
: n
)
);
}
} else if (!enabled) {
// Always push zero (or blank/null) when disabled
if (outputRef.current !== 0) {
outputRef.current = 0;
setOutputValue(0);
window.BorealisValueBus[id] = 0;
setNodes((nds) =>
nds.map((n) =>
n.id === id ? { ...n, data: { ...n.data, value: 0 } } : n
)
);
}
}
};
interval = setInterval(runNodeLogic, currentRate);
const monitor = setInterval(() => {
const newRate = window.BorealisUpdateRate;
if (newRate !== currentRate) {
clearInterval(interval);
currentRate = newRate;
interval = setInterval(runNodeLogic, currentRate);
}
}, 250);
return () => {
clearInterval(interval);
clearInterval(monitor);
};
}, [id, edges, enabled, setNodes]);
// Edge input detection
const inputEdge = edges.find((e) => e.target === id);
const hasInput = Boolean(inputEdge && inputEdge.source);
return (
<div className="borealis-node">
{/* Input handle */}
<Handle
type="target"
position={Position.Left}
className="borealis-handle"
/>
{/* Header */}
<div className="borealis-node-header">
{data?.label || "Edge Toggle"}
</div>
{/* Content */}
<div className="borealis-node-content">
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Tooltip
title={enabled ? "Turn Off / Send Zero" : "Turn On / Allow Data"}
arrow
>
<Switch
checked={enabled}
size="small"
onChange={() => setEnabled((e) => !e)}
sx={{
"& .MuiSwitch-switchBase.Mui-checked": {
color: "#58a6ff",
},
"& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track": {
backgroundColor: "#58a6ff",
},
}}
/>
</Tooltip>
<span
style={{
fontSize: 9,
color: enabled ? "#00d18c" : "#ff4f4f",
fontWeight: "bold",
marginLeft: 4,
userSelect: "none",
}}
>
{enabled ? "Flow Enabled" : "Flow Disabled"}
</span>
</div>
</div>
{/* Output handle */}
<Handle
type="source"
position={Position.Right}
className="borealis-handle"
/>
</div>
);
};
// Node Export for Borealis
export default {
type: "Edge_Toggle",
label: "Edge Toggle",
description: `
Toggles edge data flow ON/OFF using a switch.
- When enabled, passes upstream value to downstream.
- When disabled, sends zero (0) so downstream always sees a cleared value.
- Use to quickly enable/disable parts of your workflow without unlinking edges.
`.trim(),
content: "Toggle ON/OFF to allow or send zero downstream",
component: EdgeToggleNode,
config: [
{
key: "enabled",
label: "Toggle Enabled",
type: "select",
options: ["true", "false"],
defaultValue: "true"
}
],
usage_documentation: `
### Edge Toggle Node
**Purpose:**
Allows you to control data flow along a workflow edge without disconnecting the wire.
**Behavior:**
- When **Enabled**: passes upstream value downstream as usual.
- When **Disabled**: pushes \`0\` (zero) so that downstream logic always sees a cleared value (acts as an instant "mute" switch).
**Persistence:**
- Toggle state is saved in the workflow and restored on load/import.
**Tips:**
- Use for debug toggling, feature gating, or for rapid workflow prototyping.
---
`.trim()
};

View File

@@ -0,0 +1,100 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/General Purpose/Node_Data.jsx
import React, { useEffect, useRef, useState } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
import { IconButton } from "@mui/material";
import { Settings as SettingsIcon } from "@mui/icons-material";
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
const DataNode = ({ id, data }) => {
const { setNodes } = useReactFlow();
const edges = useStore((state) => state.edges);
const [renderValue, setRenderValue] = useState(data?.value || "");
const valueRef = useRef(renderValue);
useEffect(() => {
valueRef.current = data?.value || "";
setRenderValue(valueRef.current);
window.BorealisValueBus[id] = valueRef.current;
}, [data?.value, id]);
useEffect(() => {
let currentRate = window.BorealisUpdateRate || 100;
let intervalId = null;
const runNodeLogic = () => {
const inputEdge = edges.find((e) => e?.target === id);
const hasInput = Boolean(inputEdge?.source);
if (hasInput) {
const upstreamValue = window.BorealisValueBus[inputEdge.source] ?? "";
if (upstreamValue !== valueRef.current) {
valueRef.current = upstreamValue;
setRenderValue(upstreamValue);
window.BorealisValueBus[id] = upstreamValue;
setNodes((nds) =>
nds.map((n) =>
n.id === id ? { ...n, data: { ...n.data, value: upstreamValue } } : n
)
);
}
} else {
window.BorealisValueBus[id] = valueRef.current;
}
};
intervalId = setInterval(runNodeLogic, currentRate);
const monitor = setInterval(() => {
const newRate = window.BorealisUpdateRate || 100;
if (newRate !== currentRate) {
clearInterval(intervalId);
currentRate = newRate;
intervalId = setInterval(runNodeLogic, currentRate);
}
}, 250);
return () => {
clearInterval(intervalId);
clearInterval(monitor);
};
}, [id, setNodes, edges]);
return (
<div className="borealis-node">
<Handle type="target" position={Position.Left} className="borealis-handle" />
<div className="borealis-node-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span>{data?.label || "Data Node"}</span>
</div>
<div className="borealis-node-content" style={{ fontSize: "9px", color: "#ccc", marginTop: 4 }}>
Value: {renderValue}
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
export default {
type: "DataNode",
label: "String / Number",
description: "Foundational node for live value propagation.\n\n- Accepts input or manual value\n- Pushes downstream\n- Uses shared memory",
content: "Store a String or Number",
component: DataNode,
config: [
{ key: "value", label: "Value", type: "text" }
],
usage_documentation: `
### Description:
This node acts as a basic live data emitter. When connected to an upstream node, it inherits its value, otherwise it accepts user-defined input of either a number or a string.
**Acceptable Inputs**:
- **Static Value** (*Number or String*)
**Behavior**:
- **Pass-through Conduit** (*If Upstream Node is Connected*) > Value cannot be manually changed while connected to an upstream node.
- Uses global Borealis "**Update Rate**" for updating value if connected to an upstream node.
`.trim()
};

View File

@@ -0,0 +1,200 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/General Purpose/Node_Logical_Operators.jsx
import React, { useEffect, useRef, useState } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
import { IconButton } from "@mui/material";
import SettingsIcon from "@mui/icons-material/Settings";
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
const ComparisonNode = ({ id, data }) => {
const { setNodes } = useReactFlow();
const edges = useStore(state => state.edges);
const [renderValue, setRenderValue] = useState("0");
const valueRef = useRef("0");
useEffect(() => {
let currentRate = window.BorealisUpdateRate;
let intervalId = null;
const runNodeLogic = () => {
let inputType = data?.inputType || "Number";
let operator = data?.operator || "Equal (==)";
let rangeStart = data?.rangeStart;
let rangeEnd = data?.rangeEnd;
// String mode disables all but equality ops
if (inputType === "String" && !["Equal (==)", "Not Equal (!=)"].includes(operator)) {
operator = "Equal (==)";
setNodes(nds =>
nds.map(n =>
n.id === id ? { ...n, data: { ...n.data, operator } } : n
)
);
}
const edgeInputsA = edges.filter(e => e?.target === id && e.targetHandle === "a");
const edgeInputsB = edges.filter(e => e?.target === id && e.targetHandle === "b");
const extractValues = (edgeList) => {
const values = edgeList.map(e => window.BorealisValueBus[e.source]).filter(v => v !== undefined);
if (inputType === "Number") {
return values.reduce((sum, v) => sum + (parseFloat(v) || 0), 0);
}
return values.join("");
};
const a = extractValues(edgeInputsA);
const b = extractValues(edgeInputsB);
let result = "0";
if (operator === "Within Range") {
// Only valid for Number mode
const aNum = parseFloat(a);
const startNum = parseFloat(rangeStart);
const endNum = parseFloat(rangeEnd);
if (
!isNaN(aNum) &&
!isNaN(startNum) &&
!isNaN(endNum) &&
startNum <= endNum
) {
result = (aNum >= startNum && aNum <= endNum) ? "1" : "0";
} else {
result = "0";
}
} else {
const resultMap = {
"Equal (==)": a === b,
"Not Equal (!=)": a !== b,
"Greater Than (>)": a > b,
"Less Than (<)": a < b,
"Greater Than or Equal (>=)": a >= b,
"Less Than or Equal (<=)": a <= b
};
result = resultMap[operator] ? "1" : "0";
}
valueRef.current = result;
setRenderValue(result);
window.BorealisValueBus[id] = result;
setNodes(nds =>
nds.map(n =>
n.id === id ? { ...n, data: { ...n.data, value: result } } : n
)
);
};
intervalId = setInterval(runNodeLogic, currentRate);
const monitor = setInterval(() => {
const newRate = window.BorealisUpdateRate;
if (newRate !== currentRate) {
clearInterval(intervalId);
currentRate = newRate;
intervalId = setInterval(runNodeLogic, currentRate);
}
}, 250);
return () => {
clearInterval(intervalId);
clearInterval(monitor);
};
}, [id, edges, data?.inputType, data?.operator, data?.rangeStart, data?.rangeEnd, setNodes]);
return (
<div className="borealis-node">
<div style={{ position: "absolute", left: -16, top: 12, fontSize: "8px", color: "#ccc" }}>A</div>
<div style={{ position: "absolute", left: -16, top: 50, fontSize: "8px", color: "#ccc" }}>B</div>
<Handle type="target" position={Position.Left} id="a" style={{ top: 12 }} className="borealis-handle" />
<Handle type="target" position={Position.Left} id="b" style={{ top: 50 }} className="borealis-handle" />
<div className="borealis-node-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span>{data?.label || "Logic Comparison"}</span>
</div>
<div className="borealis-node-content" style={{ fontSize: "9px", color: "#ccc", marginTop: 4 }}>
Result: {renderValue}
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
export default {
type: "ComparisonNode",
label: "Logic Comparison",
description: "Compare A vs B using logic operators, with range support.",
content: "Compare A and B using Logic, with new range operator.",
component: ComparisonNode,
config: [
{
key: "inputType",
label: "Input Type",
type: "select",
options: ["Number", "String"]
},
{
key: "operator",
label: "Operator",
type: "select",
options: [
"Equal (==)",
"Not Equal (!=)",
"Greater Than (>)",
"Less Than (<)",
"Greater Than or Equal (>=)",
"Less Than or Equal (<=)",
"Within Range"
]
},
// These two fields will show up in the sidebar config for ALL operator choices
// Sidebar UI will ignore/hide if operator != Within Range, but the config is always present
{
key: "rangeStart",
label: "Range Start",
type: "text"
},
{
key: "rangeEnd",
label: "Range End",
type: "text"
}
],
usage_documentation: `
### Logic Comparison Node
This node compares two inputs (A and B) using the selected operator, including a numeric range.
**Modes:**
- **Number**: Sums all connected inputs and compares.
- **String**: Concatenates all inputs for comparison.
- Only **Equal (==)** and **Not Equal (!=)** are valid for strings.
- **Within Range**: If operator is "Within Range", compares if input A is within [Range Start, Range End] (inclusive).
**Output:**
- Returns \`1\` if comparison is true.
- Returns \`0\` if comparison is false.
**Input Notes:**
- A and B can each have multiple inputs.
- Input order matters for strings (concatenation).
- Input handles:
- **A** = Top left
- **B** = Middle left
**"Within Range" Operator:**
- Only works for **Number** input type.
- Enter "Range Start" and "Range End" in the right sidebar.
- The result is \`1\` if A >= Range Start AND A <= Range End (inclusive).
- Result is \`0\` if out of range or values are invalid.
**Example:**
- Range Start: 33
- Range End: 77
- A: 44 -> 1 (true, in range)
- A: 88 -> 0 (false, out of range)
`.trim()
};

View File

@@ -0,0 +1,172 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/General Purpose/Node_Math_Operations.jsx
import React, { useEffect, useRef, useState } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
import { IconButton } from "@mui/material";
import SettingsIcon from "@mui/icons-material/Settings";
// Init shared memory bus if not already set
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
const MathNode = ({ id, data }) => {
const { setNodes } = useReactFlow();
const edges = useStore(state => state.edges);
const [renderResult, setRenderResult] = useState(data?.value || "0");
const resultRef = useRef(renderResult);
useEffect(() => {
let intervalId = null;
let currentRate = window.BorealisUpdateRate;
const runLogic = () => {
const operator = data?.operator || "Add";
const inputsA = edges.filter(e => e.target === id && e.targetHandle === "a");
const inputsB = edges.filter(e => e.target === id && e.targetHandle === "b");
const sum = (list) =>
list
.map(e => parseFloat(window.BorealisValueBus[e.source]) || 0)
.reduce((a, b) => a + b, 0);
const valA = sum(inputsA);
const valB = sum(inputsB);
let value = 0;
switch (operator) {
case "Add":
value = valA + valB;
break;
case "Subtract":
value = valA - valB;
break;
case "Multiply":
value = valA * valB;
break;
case "Divide":
value = valB !== 0 ? valA / valB : 0;
break;
case "Average":
const totalInputs = inputsA.length + inputsB.length;
const totalSum = valA + valB;
value = totalInputs > 0 ? totalSum / totalInputs : 0;
break;
}
resultRef.current = value;
setRenderResult(value.toString());
window.BorealisValueBus[id] = value.toString();
setNodes(nds =>
nds.map(n =>
n.id === id
? { ...n, data: { ...n.data, value: value.toString() } }
: n
)
);
};
intervalId = setInterval(runLogic, currentRate);
// Watch for update rate changes
const monitor = setInterval(() => {
const newRate = window.BorealisUpdateRate;
if (newRate !== currentRate) {
clearInterval(intervalId);
currentRate = newRate;
intervalId = setInterval(runLogic, currentRate);
}
}, 250);
return () => {
clearInterval(intervalId);
clearInterval(monitor);
};
}, [id, edges, setNodes, data?.operator]);
return (
<div className="borealis-node">
<div style={{ position: "absolute", left: -16, top: 12, fontSize: "8px", color: "#ccc" }}>A</div>
<div style={{ position: "absolute", left: -16, top: 50, fontSize: "8px", color: "#ccc" }}>B</div>
<Handle type="target" position={Position.Left} id="a" style={{ top: 12 }} className="borealis-handle" />
<Handle type="target" position={Position.Left} id="b" style={{ top: 50 }} className="borealis-handle" />
<div className="borealis-node-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span>{data?.label || "Math Operation"}</span>
</div>
<div className="borealis-node-content" style={{ fontSize: "9px", color: "#ccc", marginTop: 4 }}>
Result: {renderResult}
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
export default {
type: "MathNode",
label: "Math Operation",
description: `
Live math node for computing on two grouped inputs.
- Sums all A and B handle inputs separately
- Performs selected math operation: Add, Subtract, Multiply, Divide, Average
- Emits result as string via BorealisValueBus
- Updates at the global update rate
**Common Uses:**
Live dashboard math, sensor fusion, calculation chains, dynamic thresholds
`.trim(),
content: "Perform Math Operations",
component: MathNode,
config: [
{
key: "operator",
label: "Operator",
type: "select",
options: [
"Add",
"Subtract",
"Multiply",
"Divide",
"Average"
]
}
],
usage_documentation: `
### Math Operation Node
Performs live math between two groups of inputs (**A** and **B**).
#### Usage
- Connect any number of nodes to the **A** and **B** input handles.
- The node **sums all values** from A and from B before applying the operator.
- Select the math operator in the sidebar config:
- **Add**: A + B
- **Subtract**: A - B
- **Multiply**: A * B
- **Divide**: A / B (0 if B=0)
- **Average**: (A + B) / total number of inputs
#### Output
- The computed result is pushed as a string to downstream nodes every update tick.
#### Input Handles
- **A** (Top Left)
- **B** (Middle Left)
#### Example
If three nodes outputting 5, 10, 15 are connected to A,
and one node outputs 2 is connected to B,
and operator is Multiply:
- **A** = 5 + 10 + 15 = 30
- **B** = 2
- **Result** = 30 * 2 = 60
`.trim()
};

View File

@@ -0,0 +1,113 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_Adjust_Contrast.jsx
import React, { useEffect, useState, useRef } from "react";
import { Handle, Position, useStore } from "reactflow";
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
const ContrastNode = ({ id }) => {
const edges = useStore((state) => state.edges);
const [contrast, setContrast] = useState(100);
const valueRef = useRef("");
const [renderValue, setRenderValue] = useState("");
const applyContrast = (base64Data, contrastVal) => {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const factor = (259 * (contrastVal + 255)) / (255 * (259 - contrastVal));
for (let i = 0; i < data.length; i += 4) {
data[i] = factor * (data[i] - 128) + 128;
data[i + 1] = factor * (data[i + 1] - 128) + 128;
data[i + 2] = factor * (data[i + 2] - 128) + 128;
}
ctx.putImageData(imageData, 0, 0);
resolve(canvas.toDataURL("image/png").replace(/^data:image\/png;base64,/, ""));
};
img.onerror = () => resolve(base64Data);
img.src = `data:image/png;base64,${base64Data}`;
});
};
useEffect(() => {
const inputEdge = edges.find(e => e.target === id);
if (!inputEdge?.source) return;
const input = window.BorealisValueBus[inputEdge.source] ?? "";
if (!input) return;
applyContrast(input, contrast).then((output) => {
setRenderValue(output);
window.BorealisValueBus[id] = output;
});
}, [contrast, edges, id]);
useEffect(() => {
let interval = null;
const tick = async () => {
const edge = edges.find(e => e.target === id);
const input = edge ? window.BorealisValueBus[edge.source] : "";
if (input && input !== valueRef.current) {
const result = await applyContrast(input, contrast);
valueRef.current = input;
setRenderValue(result);
window.BorealisValueBus[id] = result;
}
};
interval = setInterval(tick, window.BorealisUpdateRate);
return () => clearInterval(interval);
}, [id, contrast, edges]);
return (
<div className="borealis-node">
<Handle type="target" position={Position.Left} className="borealis-handle" />
<div className="borealis-node-header">Adjust Contrast</div>
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
<label>Contrast (1255):</label>
<input
type="number"
min="1"
max="255"
value={contrast}
onChange={(e) => setContrast(parseInt(e.target.value) || 100)}
style={{
width: "100%",
fontSize: "9px",
padding: "4px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
marginTop: "4px"
}}
/>
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
export default {
type: "ContrastNode",
label: "Adjust Contrast",
description: "Modify contrast of base64 image using a contrast multiplier.",
content: "Adjusts contrast of image using canvas pixel transform.",
component: ContrastNode
};

View File

@@ -0,0 +1,195 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_BW_Threshold.jsx
import React, { useEffect, useRef, useState } from "react";
import { Handle, Position, useStore } from "reactflow";
// Ensure BorealisValueBus exists
if (!window.BorealisValueBus) {
window.BorealisValueBus = {};
}
if (!window.BorealisUpdateRate) {
window.BorealisUpdateRate = 100;
}
const BWThresholdNode = ({ id, data }) => {
const edges = useStore((state) => state.edges);
// Attempt to parse threshold from data.value (if present),
// otherwise default to 128.
const initial = parseInt(data?.value, 10);
const [threshold, setThreshold] = useState(
isNaN(initial) ? 128 : initial
);
const [renderValue, setRenderValue] = useState("");
const valueRef = useRef("");
const lastUpstreamRef = useRef("");
// If the node is reimported and data.value changes externally,
// update the threshold accordingly.
useEffect(() => {
const newVal = parseInt(data?.value, 10);
if (!isNaN(newVal)) {
setThreshold(newVal);
}
}, [data?.value]);
const handleThresholdInput = (e) => {
let val = parseInt(e.target.value, 10);
if (isNaN(val)) {
val = 128;
}
val = Math.max(0, Math.min(255, val));
// Keep the Node's data.value updated
data.value = val;
setThreshold(val);
window.BorealisValueBus[id] = val;
};
const applyThreshold = async (base64Data, cutoff) => {
if (!base64Data || typeof base64Data !== "string") {
return "";
}
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const dataArr = imageData.data;
for (let i = 0; i < dataArr.length; i += 4) {
const avg = (dataArr[i] + dataArr[i + 1] + dataArr[i + 2]) / 3;
const color = avg < cutoff ? 0 : 255;
dataArr[i] = color;
dataArr[i + 1] = color;
dataArr[i + 2] = color;
}
ctx.putImageData(imageData, 0, 0);
resolve(canvas.toDataURL("image/png").replace(/^data:image\/png;base64,/, ""));
};
img.onerror = () => resolve(base64Data);
img.src = "data:image/png;base64," + base64Data;
});
};
// Main polling logic
useEffect(() => {
let currentRate = window.BorealisUpdateRate;
let intervalId = null;
const runNodeLogic = async () => {
const inputEdge = edges.find(e => e.target === id);
if (inputEdge?.source) {
const upstreamValue = window.BorealisValueBus[inputEdge.source] ?? "";
if (upstreamValue !== lastUpstreamRef.current) {
const transformed = await applyThreshold(upstreamValue, threshold);
lastUpstreamRef.current = upstreamValue;
valueRef.current = transformed;
setRenderValue(transformed);
window.BorealisValueBus[id] = transformed;
}
} else {
lastUpstreamRef.current = "";
valueRef.current = "";
setRenderValue("");
window.BorealisValueBus[id] = "";
}
};
intervalId = setInterval(runNodeLogic, currentRate);
const monitor = setInterval(() => {
const newRate = window.BorealisUpdateRate;
if (newRate !== currentRate) {
clearInterval(intervalId);
currentRate = newRate;
intervalId = setInterval(runNodeLogic, currentRate);
}
}, 250);
return () => {
clearInterval(intervalId);
clearInterval(monitor);
};
}, [id, edges, threshold]);
// Reapply when threshold changes (even if image didn't)
useEffect(() => {
const inputEdge = edges.find(e => e.target === id);
if (!inputEdge?.source) {
return;
}
const upstreamValue = window.BorealisValueBus[inputEdge.source] ?? "";
if (!upstreamValue) {
return;
}
applyThreshold(upstreamValue, threshold).then((result) => {
valueRef.current = result;
setRenderValue(result);
window.BorealisValueBus[id] = result;
});
}, [threshold, edges, id]);
return (
<div className="borealis-node">
<Handle type="target" position={Position.Left} className="borealis-handle" />
<div className="borealis-node-header">BW Threshold</div>
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
<div style={{ marginBottom: "6px", color: "#ccc" }}>
Threshold Strength (0255):
</div>
<input
type="number"
min="0"
max="255"
value={threshold}
onChange={handleThresholdInput}
style={{
width: "100%",
fontSize: "9px",
padding: "4px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
marginBottom: "6px"
}}
/>
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
export default {
type: "BWThresholdNode",
label: "BW Threshold",
description: `
Black & White Threshold (Stateless)
- Converts a base64 image to black & white using a user-defined threshold value
- Reapplies threshold when the number changes, even if image stays the same
- Outputs a new base64 PNG with BW transformation
`.trim(),
content: "Applies black & white threshold to base64 image input.",
component: BWThresholdNode
};

View File

@@ -0,0 +1,135 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_Convert_to_Grayscale.jsx
import React, { useEffect, useState, useRef } from "react";
import { Handle, Position, useStore } from "reactflow";
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
const GrayscaleNode = ({ id }) => {
const edges = useStore((state) => state.edges);
const [grayscaleLevel, setGrayscaleLevel] = useState(100); // percentage (0100)
const [renderValue, setRenderValue] = useState("");
const valueRef = useRef("");
const applyGrayscale = (base64Data, level) => {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const alpha = level / 100;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const avg = (r + g + b) / 3;
data[i] = r * (1 - alpha) + avg * alpha;
data[i + 1] = g * (1 - alpha) + avg * alpha;
data[i + 2] = b * (1 - alpha) + avg * alpha;
}
ctx.putImageData(imageData, 0, 0);
resolve(canvas.toDataURL("image/png").replace(/^data:image\/png;base64,/, ""));
};
img.onerror = () => resolve(base64Data);
img.src = `data:image/png;base64,${base64Data}`;
});
};
useEffect(() => {
const inputEdge = edges.find(e => e.target === id);
if (!inputEdge?.source) return;
const input = window.BorealisValueBus[inputEdge.source] ?? "";
if (!input) return;
applyGrayscale(input, grayscaleLevel).then((output) => {
valueRef.current = input;
setRenderValue(output);
window.BorealisValueBus[id] = output;
});
}, [grayscaleLevel, edges, id]);
useEffect(() => {
let interval = null;
const run = async () => {
const edge = edges.find(e => e.target === id);
const input = edge ? window.BorealisValueBus[edge.source] : "";
if (input && input !== valueRef.current) {
const result = await applyGrayscale(input, grayscaleLevel);
valueRef.current = input;
setRenderValue(result);
window.BorealisValueBus[id] = result;
}
};
interval = setInterval(run, window.BorealisUpdateRate);
return () => clearInterval(interval);
}, [id, edges, grayscaleLevel]);
const handleLevelChange = (e) => {
let val = parseInt(e.target.value, 10);
if (isNaN(val)) val = 100;
val = Math.min(100, Math.max(0, val));
setGrayscaleLevel(val);
};
return (
<div className="borealis-node">
<Handle type="target" position={Position.Left} className="borealis-handle" />
<div className="borealis-node-header">Convert to Grayscale</div>
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
<label style={{ display: "block", marginBottom: "4px" }}>
Grayscale Intensity (0100):
</label>
<input
type="number"
min="0"
max="100"
step="1"
value={grayscaleLevel}
onChange={handleLevelChange}
style={{
width: "100%",
fontSize: "9px",
padding: "4px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px"
}}
/>
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
export default {
type: "GrayscaleNode",
label: "Convert to Grayscale",
description: `
Adjustable Grayscale Conversion
- Accepts base64 image input
- Applies grayscale effect using a % level
- 0% = no change, 100% = full grayscale
- Outputs result downstream as base64
`.trim(),
content: "Convert image to grayscale with adjustable intensity.",
component: GrayscaleNode
};

View File

@@ -0,0 +1,90 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_Export_Image.jsx
import React, { useEffect, useState } from "react";
import { Handle, Position, useReactFlow } from "reactflow";
const ExportImageNode = ({ id }) => {
const { getEdges } = useReactFlow();
const [imageData, setImageData] = useState("");
useEffect(() => {
const interval = setInterval(() => {
const edges = getEdges();
const inputEdge = edges.find(e => e.target === id);
if (inputEdge) {
const base64 = window.BorealisValueBus?.[inputEdge.source];
if (typeof base64 === "string") {
setImageData(base64);
}
}
}, 1000);
return () => clearInterval(interval);
}, [id, getEdges]);
const handleDownload = async () => {
const blob = await (async () => {
const res = await fetch(`data:image/png;base64,${imageData}`);
return await res.blob();
})();
if (window.showSaveFilePicker) {
try {
const fileHandle = await window.showSaveFilePicker({
suggestedName: "image.png",
types: [{
description: "PNG Image",
accept: { "image/png": [".png"] }
}]
});
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
} catch (e) {
console.warn("Save cancelled:", e);
}
} else {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "image.png";
a.style.display = "none";
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(a.href);
document.body.removeChild(a);
}
};
return (
<div className="borealis-node">
<Handle type="target" position={Position.Left} className="borealis-handle" />
<div className="borealis-node-header">Export Image</div>
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
Export upstream base64-encoded image data as a PNG on-disk.
<button
style={{
marginTop: "6px",
width: "100%",
fontSize: "9px",
padding: "4px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px"
}}
onClick={handleDownload}
disabled={!imageData}
>
Download PNG
</button>
</div>
</div>
);
};
export default {
type: "ExportImageNode",
label: "Export Image",
description: "Lets the user download the base64 PNG image to disk.",
content: "Save base64 PNG to disk as a file.",
component: ExportImageNode
};

View File

@@ -0,0 +1,146 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_Image_Viewer.jsx
import React, { useEffect, useState, useRef } from "react";
import { Handle, Position, useReactFlow } from "reactflow";
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
const ImageViewerNode = ({ id }) => {
const { getEdges } = useReactFlow();
const [imageBase64, setImageBase64] = useState("");
const canvasRef = useRef(null);
const overlayDivRef = useRef(null);
const [isZoomed, setIsZoomed] = useState(false);
// Poll upstream for base64 image and propagate
useEffect(() => {
const interval = setInterval(() => {
const edges = getEdges();
const inp = edges.find(e => e.target === id);
if (inp) {
const val = window.BorealisValueBus[inp.source] || "";
setImageBase64(val);
window.BorealisValueBus[id] = val;
} else {
setImageBase64("");
window.BorealisValueBus[id] = "";
}
}, window.BorealisUpdateRate);
return () => clearInterval(interval);
}, [getEdges, id]);
// Draw the image into canvas for high performance
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (imageBase64) {
const img = new Image();
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
};
img.src = "data:image/png;base64," + imageBase64;
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}, [imageBase64]);
// Manage zoom overlay on image click
useEffect(() => {
if (!isZoomed || !imageBase64) return;
const div = document.createElement("div");
overlayDivRef.current = div;
Object.assign(div.style, {
position: "fixed",
top: "0",
left: "0",
width: "100%",
height: "100%",
backgroundColor: "rgba(0,0,0,0.8)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: "1000",
cursor: "zoom-out",
transition: "opacity 0.3s ease"
});
const handleOverlayClick = () => setIsZoomed(false);
div.addEventListener("click", handleOverlayClick);
const ovCanvas = document.createElement("canvas");
const ctx = ovCanvas.getContext("2d");
const img = new Image();
img.onload = () => {
let w = img.width;
let h = img.height;
const maxW = window.innerWidth * 0.8;
const maxH = window.innerHeight * 0.8;
const scale = Math.min(1, maxW / w, maxH / h);
ovCanvas.width = w;
ovCanvas.height = h;
ctx.clearRect(0, 0, w, h);
ctx.drawImage(img, 0, 0);
ovCanvas.style.width = `${w * scale}px`;
ovCanvas.style.height = `${h * scale}px`;
ovCanvas.style.transition = "transform 0.3s ease";
};
img.src = "data:image/png;base64," + imageBase64;
div.appendChild(ovCanvas);
document.body.appendChild(div);
// Cleanup when unzooming
return () => {
div.removeEventListener("click", handleOverlayClick);
if (overlayDivRef.current) {
document.body.removeChild(overlayDivRef.current);
overlayDivRef.current = null;
}
};
}, [isZoomed, imageBase64]);
const handleClick = () => {
if (imageBase64) setIsZoomed(z => !z);
};
return (
<div className="borealis-node">
<Handle type="target" position={Position.Left} className="borealis-handle" />
<div className="borealis-node-header">Image Viewer</div>
<div
className="borealis-node-content"
style={{ cursor: imageBase64 ? "zoom-in" : "default" }}
>
{imageBase64 ? (
<canvas
ref={canvasRef}
onClick={handleClick}
style={{ width: "100%", border: "1px solid #333", marginTop: "6px", marginBottom: "6px" }}
/>
) : (
<div style={{ fontSize: "9px", color: "#888", marginTop: "6px" }}>
Waiting for image...
</div>
)}
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
export default {
type: "Image_Viewer",
label: "Image Viewer",
description: `
Displays base64 image via canvas for high performance
- Accepts upstream base64 image
- Renders with canvas for speed
- Click to zoom/unzoom overlay with smooth transition
`.trim(),
content: "Visual preview of base64 image with zoom overlay.",
component: ImageViewerNode
};

View File

@@ -0,0 +1,175 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_Upload_Image.jsx
/**
* ==================================================
* Borealis - Image Upload Node (Raw Base64 Output)
* ==================================================
*
* COMPONENT ROLE:
* This node lets the user upload an image file (JPG/JPEG/PNG),
* reads it as a data URL, then strips off the "data:image/*;base64,"
* prefix, storing only the raw base64 data in BorealisValueBus.
*
* IMPORTANT:
* - No upstream connector (target handle) is provided.
* - The raw base64 is pushed out to downstream nodes via source handle.
* - Your viewer (or other downstream node) must prepend "data:image/png;base64,"
* or the appropriate MIME string for display.
*/
import React, { useEffect, useRef, useState } from "react";
import { Handle, Position, useReactFlow } from "reactflow";
// Global Shared Bus for Node Data Propagation
if (!window.BorealisValueBus) {
window.BorealisValueBus = {};
}
// Global Update Rate (ms) for All Data Nodes
if (!window.BorealisUpdateRate) {
window.BorealisUpdateRate = 100;
}
const ImageUploadNode = ({ id, data }) => {
const { setNodes } = useReactFlow();
const [renderValue, setRenderValue] = useState(data?.value || "");
const valueRef = useRef(renderValue);
// Handler for file uploads
const handleFileUpload = (event) => {
console.log("handleFileUpload triggered for node:", id);
// Get the file list
const files = event.target.files || event.currentTarget.files;
if (!files || files.length === 0) {
console.log("No files selected or files array is empty");
return;
}
const file = files[0];
if (!file) {
console.log("File object not found");
return;
}
// Debugging info
console.log("Selected file:", file.name, file.type, file.size);
// Validate file type
const validTypes = ["image/jpeg", "image/png"];
if (!validTypes.includes(file.type)) {
console.warn("Unsupported file type in node:", id, file.type);
return;
}
// Setup FileReader
const reader = new FileReader();
reader.onload = (loadEvent) => {
console.log("FileReader onload in node:", id);
const base64DataUrl = loadEvent?.target?.result || "";
// Strip off the data:image/...;base64, prefix to store raw base64
const rawBase64 = base64DataUrl.replace(/^data:image\/[a-zA-Z]+;base64,/, "");
console.log("Raw Base64 (truncated):", rawBase64.substring(0, 50));
valueRef.current = rawBase64;
setRenderValue(rawBase64);
window.BorealisValueBus[id] = rawBase64;
// Update node data
setNodes((nds) =>
nds.map((n) =>
n.id === id
? { ...n, data: { ...n.data, value: rawBase64 } }
: n
)
);
};
reader.onerror = (errorEvent) => {
console.error("FileReader error in node:", id, errorEvent);
};
// Read the file as a data URL
reader.readAsDataURL(file);
};
// Poll-based output (no upstream)
useEffect(() => {
let currentRate = window.BorealisUpdateRate || 100;
let intervalId = null;
const runNodeLogic = () => {
// Simply emit current value (raw base64) to the bus
window.BorealisValueBus[id] = valueRef.current;
};
const startInterval = () => {
intervalId = setInterval(runNodeLogic, currentRate);
};
startInterval();
// Monitor for global update rate changes
const monitor = setInterval(() => {
const newRate = window.BorealisUpdateRate || 100;
if (newRate !== currentRate) {
currentRate = newRate;
clearInterval(intervalId);
startInterval();
}
}, 250);
return () => {
clearInterval(intervalId);
clearInterval(monitor);
};
}, [id, setNodes]);
return (
<div className="borealis-node" style={{ minWidth: "160px" }}>
{/* No target handle because we don't accept upstream data */}
<div className="borealis-node-header">
{data?.label || "Raw Base64 Image Upload"}
</div>
<div className="borealis-node-content">
<div style={{ marginBottom: "8px", fontSize: "9px", color: "#ccc" }}>
{data?.content || "Upload a JPG/PNG, store only the raw base64 in ValueBus."}
</div>
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
Upload Image File
</label>
<input
type="file"
accept=".jpg,.jpeg,.png"
onChange={handleFileUpload}
style={{
fontSize: "9px",
padding: "4px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
width: "100%",
marginBottom: "8px"
}}
/>
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
export default {
type: "ImageUploadNode_RawBase64", // Unique ID for the node type
label: "Upload Image",
description: `
A node to upload an image (JPG/PNG) and store it in base64 format for later use downstream.
`.trim(),
content: "Upload an image, output only the raw base64 string.",
component: ImageUploadNode
};

View File

@@ -0,0 +1,134 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Organization/Node_Backdrop_Group_Box.jsx
/**
* ===========================================
* Borealis - Backdrop Group Box Node
* ===========================================
*
* COMPONENT ROLE:
* This node functions as a backdrop or grouping box.
* It's resizable and can be renamed by clicking its title.
* It doesn't connect to other nodes or pass data<74>it's purely visual.
*
* BEHAVIOR:
* - Allows renaming via single-click on the header text.
* - Can be resized by dragging from the bottom-right corner.
*
* NOTE:
* - No inputs/outputs: purely cosmetic for grouping and labeling.
*/
import React, { useState, useEffect, useRef } from "react";
import { Handle, Position } from "reactflow";
import { ResizableBox } from "react-resizable";
import "react-resizable/css/styles.css";
const BackdropGroupBoxNode = ({ id, data }) => {
const [title, setTitle] = useState(data?.label || "Backdrop Group Box");
const [isEditing, setIsEditing] = useState(false);
const inputRef = useRef(null);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
}
}, [isEditing]);
const handleTitleClick = (e) => {
e.stopPropagation();
setIsEditing(true);
};
const handleTitleChange = (e) => {
const newTitle = e.target.value;
setTitle(newTitle);
window.BorealisValueBus[id] = newTitle;
};
const handleBlur = () => {
setIsEditing(false);
};
return (
<div style={{ pointerEvents: "auto" }}>
<ResizableBox
width={200}
height={120}
minConstraints={[120, 80]}
maxConstraints={[600, 600]}
resizeHandles={["se"]}
className="borealis-node"
handle={(h) => (
<span
className={`react-resizable-handle react-resizable-handle-${h}`}
style={{ pointerEvents: "auto" }}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
/>
)}
onClick={(e) => e.stopPropagation()}
style={{
backgroundColor: "rgba(44, 44, 44, 0.5)",
border: "1px solid #3a3a3a",
borderRadius: "4px",
boxShadow: "0 0 5px rgba(88, 166, 255, 0.15)",
overflow: "hidden",
position: "relative",
zIndex: 0
}}
>
<div
onClick={handleTitleClick}
style={{
backgroundColor: "rgba(35, 35, 35, 0.5)",
padding: "6px 10px",
fontWeight: "bold",
fontSize: "10px",
cursor: "pointer",
userSelect: "none"
}}
>
{isEditing ? (
<input
ref={inputRef}
type="text"
value={title}
onChange={handleTitleChange}
onBlur={handleBlur}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
style={{
fontSize: "10px",
padding: "2px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
width: "100%"
}}
/>
) : (
<span>{title}</span>
)}
</div>
<div style={{ padding: "10px", fontSize: "9px", height: "100%" }}>
{/* Empty space for grouping */}
</div>
</ResizableBox>
</div>
);
};
export default {
type: "BackdropGroupBoxNode",
label: "Backdrop Group Box",
description: `
Resizable Grouping Node
- Purely cosmetic, for grouping related nodes
- Resizable by dragging bottom-right corner
- Rename by clicking on title bar
`.trim(),
content: "Use as a visual group label",
component: BackdropGroupBoxNode
};

View File

@@ -0,0 +1,145 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Reporting/Node_Export_to_CSV.jsx
import React, { useRef, useState } from "react";
import { Handle, Position } from "reactflow";
import { Button, Snackbar } from "@mui/material";
/**
* ExportToCSVNode
* ----------------
* Simplified version:
* - No output connector
* - Removed "Export to Disk" checkbox
* - Only function is export to disk (manual trigger)
*/
const ExportToCSVNode = ({ data }) => {
const [exportPath, setExportPath] = useState("");
const [appendMode, setAppendMode] = useState(false);
const [snackbarOpen, setSnackbarOpen] = useState(false);
const fileInputRef = useRef(null);
const handleExportClick = () => setSnackbarOpen(true);
const handleSnackbarClose = () => setSnackbarOpen(false);
const handlePathClick = async () => {
if (window.showDirectoryPicker) {
try {
const dirHandle = await window.showDirectoryPicker();
setExportPath(dirHandle.name || "Selected Directory");
} catch (err) {
console.warn("Directory Selection Cancelled:", err);
}
} else {
fileInputRef.current?.click();
}
};
const handleFakePicker = (event) => {
const files = event.target.files;
if (files.length > 0) {
const fakePath = files[0].webkitRelativePath?.split("/")[0];
setExportPath(fakePath || "Selected Folder");
}
};
return (
<div className="borealis-node">
<Handle type="target" position={Position.Left} className="borealis-handle" />
<div className="borealis-node-header">
{data.label}
</div>
<div className="borealis-node-content">
<div style={{ marginBottom: "8px" }}>
{data.content}
</div>
<label style={{ fontSize: "9px", display: "block", marginTop: "6px" }}>
Export Path:
</label>
<div style={{ display: "flex", gap: "4px", alignItems: "center", marginBottom: "6px" }}>
<input
type="text"
readOnly
value={exportPath}
placeholder="Click to Select Folder"
onClick={handlePathClick}
style={{
flex: 1,
fontSize: "9px",
padding: "3px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
cursor: "pointer"
}}
/>
<Button
variant="outlined"
size="small"
onClick={handleExportClick}
sx={{
fontSize: "9px",
padding: "2px 8px",
minWidth: "unset",
borderColor: "#58a6ff",
color: "#58a6ff"
}}
>
Export
</Button>
</div>
<label style={{ fontSize: "9px", display: "block", marginTop: "4px" }}>
<input
type="checkbox"
checked={appendMode}
onChange={(e) => setAppendMode(e.target.checked)}
style={{ marginRight: "4px" }}
/>
Append CSV Data if Headers Match
</label>
</div>
<input
ref={fileInputRef}
type="file"
webkitdirectory="true"
directory=""
multiple
style={{ display: "none" }}
onChange={handleFakePicker}
/>
<Snackbar
open={snackbarOpen}
autoHideDuration={1000}
onClose={handleSnackbarClose}
message="Feature Coming Soon..."
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
/>
</div>
);
};
export default {
type: "ExportToCSVNode",
label: "Export to CSV",
description: `
Reporting Node
This node lets the user choose a folder to export CSV data to disk.
When the "Export" button is clicked, CSV content (from upstream logic) is intended to be saved
to the selected directory. This is a placeholder for future file system interaction.
Inputs:
- Structured Table Data (via upstream node)
Outputs:
- None (writes directly to disk in future)
`.trim(),
content: "Export Input Data to CSV File",
component: ExportToCSVNode
};

View File

@@ -0,0 +1,193 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Templates/Node_Template.jsx
/**
* ==================================================
* Borealis - Node Template (Golden Template)
* ==================================================
*
* COMPONENT ROLE:
* Serves as a comprehensive template for creating new
* Borealis nodes. This file includes detailed commentary
* for human developers and AI systems, explaining every
* aspect of a node's structure, state management, data flow,
* and integration with the shared memory bus.
*
* METADATA:
* - type: unique identifier for the node type (Data entry)
* - label: display name in Borealis UI
* - description: explanatory tooltip shown to users
* - content: short summary of node functionality
*
* USAGE:
* Copy and rename this file when building a new node.
* Update the metadata and customize logic inside runNodeLogic().
*/
import React, { useEffect, useState, useRef } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
/**
* TemplateNode Component
* ----------------------
* A single-input, single-output node that propagates a string value.
*
* @param {Object} props
* @param {string} props.id - Unique node identifier in the flow
* @param {Object} props.data - Node-specific data and settings
*/
const TemplateNode = ({ id, data }) => {
const { setNodes } = useReactFlow();
const edges = useStore((state) => state.edges);
// Local state holds the current string value shown in the textbox
// AI Note: valueRef.current tracks the last emitted value to prevent redundant bus writes
const [renderValue, setRenderValue] = useState(data?.value || "/Data/Server/WebUI/src/Nodes/Templates/Node_Template.jsx");
const valueRef = useRef(renderValue);
/**
* handleManualInput
* -----------------
* Called when the user types into the textbox (only when no input edge).
* Writes the new value to the shared bus and updates node state.
*/
const handleManualInput = (e) => {
const newValue = e.target.value;
// Update local ref and component state
valueRef.current = newValue;
setRenderValue(newValue);
// Broadcast value on the shared bus
window.BorealisValueBus[id] = newValue;
// Persist the new value in node.data for workflow serialization
setNodes((nds) =>
nds.map((n) =>
n.id === id
? { ...n, data: { ...n.data, value: newValue } }
: n
)
);
};
/**
* Polling effect: runNodeLogic
* ----------------------------
* On mount, start an interval that:
* - Checks for an upstream connection
* - If connected, reads from bus and updates state/bus
* - If not, broadcasts manual input value
* - Monitors for global rate changes and reconfigures
*/
useEffect(() => {
let currentRate = window.BorealisUpdateRate;
let intervalId;
const runNodeLogic = () => {
// Detect if a source edge is connected to this node's input
const inputEdge = edges.find((e) => e.target === id);
const hasInput = Boolean(inputEdge && inputEdge.source);
if (hasInput) {
// Read upstream value
const upstreamValue = window.BorealisValueBus[inputEdge.source] || "";
// Only update if value changed
if (upstreamValue !== valueRef.current) {
valueRef.current = upstreamValue;
setRenderValue(upstreamValue);
window.BorealisValueBus[id] = upstreamValue;
// Persist to node.data for serialization
setNodes((nds) =>
nds.map((n) =>
n.id === id
? { ...n, data: { ...n.data, value: upstreamValue } }
: n
)
);
}
} else {
// No upstream: broadcast manual input value
window.BorealisValueBus[id] = valueRef.current;
}
};
// Start polling
intervalId = setInterval(runNodeLogic, currentRate);
// Watch for global rate changes
const monitor = setInterval(() => {
const newRate = window.BorealisUpdateRate;
if (newRate !== currentRate) {
clearInterval(intervalId);
currentRate = newRate;
intervalId = setInterval(runNodeLogic, currentRate);
}
}, 250);
// Cleanup on unmount
return () => {
clearInterval(intervalId);
clearInterval(monitor);
};
}, [id, edges, setNodes]);
// Determine connection status for UI control disabling
const inputEdge = edges.find((e) => e.target === id);
const hasInput = Boolean(inputEdge && inputEdge.source);
return (
<div className="borealis-node">
{/* Input connector on left */}
<Handle type="target" position={Position.Left} className="borealis-handle" />
{/* Header: displays node title */}
<div className="borealis-node-header">
{data?.label || "Node Template"}
</div>
{/* Content area: description and input control */}
<div className="borealis-node-content">
{/* Description: guideline for human users */}
<div style={{ marginBottom: "8px", fontSize: "9px", color: "#ccc" }}>
{data?.content || "Template acting as a design scaffold for designing nodes for Borealis."}
</div>
{/* Label for the textbox */}
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
Template Location:
</label>
{/* Textbox: disabled if upstream data present */}
<input
type="text"
value={renderValue}
onChange={handleManualInput}
disabled={hasInput}
style={{
fontSize: "9px",
padding: "4px",
background: hasInput ? "#2a2a2a" : "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
width: "100%"
}}
/>
</div>
{/* Output connector on right */}
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
// Export node metadata for Borealis
export default {
type: "Node_Template", // Unique node type identifier
label: "Node Template", // Display name in UI
description: `Node structure template to be used as a scaffold when building new nodes for Borealis.`, // Node Sidebar Tooltip Description
content: "Template acting as a design scaffold for designing nodes for Borealis.", // Short summary
component: TemplateNode // React component
};

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "build", "dist"]
}

View File

@@ -0,0 +1,89 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/Server/WebUI/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import fs from 'fs';
const runtimeCertDir = process.env.BOREALIS_CERT_DIR;
const certCandidates = [
process.env.BOREALIS_TLS_CERT,
runtimeCertDir && path.resolve(runtimeCertDir, 'borealis-server-cert.pem'),
path.resolve(__dirname, '../certs/borealis-server-cert.pem'),
path.resolve(__dirname, '../../../Server/Borealis/certs/borealis-server-cert.pem'),
] as const;
const keyCandidates = [
process.env.BOREALIS_TLS_KEY,
runtimeCertDir && path.resolve(runtimeCertDir, 'borealis-server-key.pem'),
path.resolve(__dirname, '../certs/borealis-server-key.pem'),
path.resolve(__dirname, '../../../Server/Borealis/certs/borealis-server-key.pem'),
] as const;
const pickFirst = (candidates: readonly (string | undefined)[]) => {
for (const candidate of candidates) {
if (!candidate) continue;
if (fs.existsSync(candidate)) {
return candidate;
}
}
return undefined;
};
const certPath = pickFirst(certCandidates);
const keyPath = pickFirst(keyCandidates);
const httpsOptions = certPath && keyPath
? {
cert: fs.readFileSync(certPath),
key: fs.readFileSync(keyPath),
}
: undefined;
export default defineConfig({
plugins: [react()],
server: {
open: true,
host: true,
strictPort: true,
// Allow LAN/IP access during dev (so other devices can reach Vite)
// If you want to restrict, replace `true` with an explicit allowlist.
allowedHosts: true,
https: httpsOptions,
proxy: {
// Ensure cookies/headers are forwarded correctly to Flask over TLS
'/api': {
target: 'https://127.0.0.1:5000',
changeOrigin: true,
secure: false,
},
'/socket.io': {
target: 'wss://127.0.0.1:5000',
ws: true,
changeOrigin: true,
secure: false,
}
}
},
build: {
outDir: 'build',
emptyOutDir: true,
chunkSizeWarningLimit: 1000,
rollupOptions: {
output: {
// split each npm package into its own chunk
manualChunks(id) {
if (id.includes('node_modules')) {
return id.toString()
.split('node_modules/')[1]
.split('/')[0];
}
}
}
}
},
resolve: {
alias: { '@': path.resolve(__dirname, 'src') },
extensions: ['.js','.jsx','.ts','.tsx']
}
});