Files
Borealis-Github-Replica/Data/Server/WebUI/src/App.jsx

1393 lines
56 KiB
JavaScript

////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/App.jsx
//Shared Imports
import React, { useState, useEffect, useCallback, useRef } from "react";
import { ReactFlowProvider } from "reactflow";
import "reactflow/dist/style.css";
import {
CloseAllDialog, RenameTabDialog, TabContextMenu, NotAuthorizedDialog
} from "./Dialogs";
import NavigationSidebar from "./Navigation_Sidebar";
// Styling Imports
import {
AppBar, Toolbar, Typography, Box, Menu, MenuItem, Button,
CssBaseline, ThemeProvider, createTheme, Breadcrumbs
} from "@mui/material";
import {
KeyboardArrowDown as KeyboardArrowDownIcon,
Logout as LogoutIcon,
NavigateNext as NavigateNextIcon
} from "@mui/icons-material";
import ClickAwayListener from "@mui/material/ClickAwayListener";
import SearchIcon from "@mui/icons-material/Search";
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
import ArrowDropUpIcon from "@mui/icons-material/ArrowDropUp";
// Workflow Editor Imports
import FlowTabs from "./Flow_Editor/Flow_Tabs";
import FlowEditor from "./Flow_Editor/Flow_Editor";
import NodeSidebar from "./Flow_Editor/Node_Sidebar";
import StatusBar from "./Status_Bar";
// Borealis Page Imports
import Login from "./Login.jsx";
import SiteList from "./Sites/Site_List";
import DeviceList from "./Devices/Device_List";
import DeviceDetails from "./Devices/Device_Details";
import AgentDevices from "./Devices/Agent_Devices.jsx";
import SSHDevices from "./Devices/SSH_Devices.jsx";
import WinRMDevices from "./Devices/WinRM_Devices.jsx";
import AssemblyList from "./Assemblies/Assembly_List";
import AssemblyEditor from "./Assemblies/Assembly_Editor";
import ScheduledJobsList from "./Scheduling/Scheduled_Jobs_List";
import CreateJob from "./Scheduling/Create_Job.jsx";
import CredentialList from "./Access_Management/Credential_List.jsx";
import UserManagement from "./Access_Management/Users.jsx";
import GithubAPIToken from "./Access_Management/Github_API_Token.jsx";
import ServerInfo from "./Admin/Server_Info.jsx";
import EnrollmentCodes from "./Devices/Enrollment_Codes.jsx";
import DeviceApprovals from "./Devices/Device_Approvals.jsx";
// Networking Imports
import { io } from "socket.io-client";
if (!window.BorealisSocket) {
window.BorealisSocket = io(window.location.origin, { transports: ["websocket"] });
}
if (!window.BorealisUpdateRate) {
window.BorealisUpdateRate = 200;
}
///////////////////////////////////////////////////////////////////////////////////////////////////
// Load node modules dynamically
const modules = import.meta.glob('./Nodes/**/*.jsx', { eager: true });
const nodeTypes = {};
const categorizedNodes = {};
Object.entries(modules).forEach(([path, mod]) => {
const comp = mod.default;
if (!comp) return;
const { type, component } = comp;
if (!type || !component) return;
const parts = path.replace('./Nodes/', '').split('/');
const category = parts[0];
if (!categorizedNodes[category]) categorizedNodes[category] = [];
categorizedNodes[category].push(comp);
nodeTypes[type] = component;
});
const darkTheme = createTheme({
palette: {
mode: "dark",
background: { default: "#121212", paper: "#1e1e1e" },
text: { primary: "#ffffff" }
},
components: {
MuiTooltip: {
styleOverrides: {
tooltip: { backgroundColor: "#2a2a2a", color: "#ccc", fontSize: "0.75rem", border: "1px solid #444" },
arrow: { color: "#2a2a2a" }
}
}
}
});
const LOCAL_STORAGE_KEY = "borealis_persistent_state";
export default function App() {
const [tabs, setTabs] = useState([{ id: "flow_1", tab_name: "Flow 1", nodes: [], edges: [] }]);
const [activeTabId, setActiveTabId] = useState("flow_1");
const [currentPage, setCurrentPageState] = useState("devices");
const [selectedDevice, setSelectedDevice] = useState(null);
const [userMenuAnchorEl, setUserMenuAnchorEl] = useState(null);
const [confirmCloseOpen, setConfirmCloseOpen] = useState(false);
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const [renameTabId, setRenameTabId] = useState(null);
const [renameValue, setRenameValue] = useState("");
const [tabMenuAnchor, setTabMenuAnchor] = useState(null);
const [tabMenuTabId, setTabMenuTabId] = useState(null);
const fileInputRef = useRef(null);
const [user, setUser] = useState(null);
const [userRole, setUserRole] = useState(null);
const [userDisplayName, setUserDisplayName] = useState(null);
const [editingJob, setEditingJob] = useState(null);
const [jobsRefreshToken, setJobsRefreshToken] = useState(0);
const [assemblyEditorState, setAssemblyEditorState] = useState(null); // { path, mode, context, nonce }
const [sessionResolved, setSessionResolved] = useState(false);
const initialPathRef = useRef(window.location.pathname + window.location.search);
const pendingPathRef = useRef(null);
const [notAuthorizedOpen, setNotAuthorizedOpen] = useState(false);
// Top-bar search state
const SEARCH_CATEGORIES = [
{ key: "hostname", label: "Hostname", scope: "device", placeholder: "Search Hostname" },
{ key: "internal_ip", label: "Internal IP", scope: "device", placeholder: "Search Internal IP" },
{ key: "external_ip", label: "External IP", scope: "device", placeholder: "Search External IP" },
{ key: "description", label: "Description", scope: "device", placeholder: "Search Description" },
{ key: "last_user", label: "Last User", scope: "device", placeholder: "Search Last User" },
{ key: "serial_number", label: "Serial Number (Soon)", scope: "device", placeholder: "Search Serial Number" },
{ key: "site_name", label: "Site Name", scope: "site", placeholder: "Search Site Name" },
{ key: "site_description", label: "Site Description", scope: "site", placeholder: "Search Site Description" },
];
const [searchCategory, setSearchCategory] = useState("hostname");
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchMenuEl, setSearchMenuEl] = useState(null);
const [suggestions, setSuggestions] = useState({ devices: [], sites: [], q: "", field: "" });
const searchAnchorRef = useRef(null);
const searchDebounceRef = useRef(null);
// Gentle highlight helper for matched substrings
const highlightText = useCallback((text, query) => {
const t = String(text ?? "");
const q = String(query ?? "").trim();
if (!q) return t;
try {
const esc = q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`(${esc})`, "ig");
const parts = t.split(re);
return parts.map((part, i) =>
part.toLowerCase() === q.toLowerCase()
? (
<span key={i} style={{ backgroundColor: '#243a52', color: '#a7d0ff', borderRadius: 2, padding: '0 1px' }}>{part}</span>
)
: <span key={i}>{part}</span>
);
} catch {
return t;
}
}, []);
const pageToPath = useCallback(
(page, options = {}) => {
switch (page) {
case "login":
return "/login";
case "sites":
return "/sites";
case "devices":
return "/devices";
case "agent_devices":
return "/devices/agent";
case "ssh_devices":
return "/devices/ssh";
case "winrm_devices":
return "/devices/winrm";
case "device_details": {
const device =
options.device ||
selectedDevice ||
(options.deviceId
? { agent_guid: options.deviceId, hostname: options.deviceName || options.deviceId }
: null);
const deviceId =
device?.agent_guid ||
device?.guid ||
device?.summary?.agent_guid ||
device?.hostname ||
device?.id;
if (deviceId) {
return `/device/${encodeURIComponent(deviceId)}`;
}
return "/devices";
}
case "jobs":
return "/scheduling";
case "create_job":
return "/scheduling/create_job";
case "workflows":
return "/workflows";
case "workflow-editor":
return "/workflows/editor";
case "assemblies":
return "/assemblies";
case "scripts":
case "ansible_editor": {
const mode = page === "ansible_editor" ? "ansible" : "scripts";
const params = new URLSearchParams();
if (mode === "ansible") {
params.set("mode", "ansible");
}
const state = options.assemblyState || assemblyEditorState;
if (state?.path) {
params.set("path", state.path);
}
const query = params.toString();
return query ? `/assemblies/editor?${query}` : "/assemblies/editor";
}
case "access_credentials":
return "/access_management/credentials";
case "access_github_token":
return "/access_management/github_token";
case "access_users":
return "/access_management/users";
case "server_info":
return "/admin/server_info";
case "admin_enrollment_codes":
return "/admin/enrollment-codes";
case "admin_device_approvals":
return "/admin/device-approvals";
default:
return "/devices";
}
},
[assemblyEditorState, selectedDevice]
);
const interpretPath = useCallback((rawPath) => {
try {
const url = new URL(rawPath || "/", window.location.origin);
let path = url.pathname || "/";
if (path.length > 1 && path.endsWith("/")) {
path = path.slice(0, -1);
}
const segments = path.split("/").filter(Boolean);
const params = url.searchParams;
if (path === "/login") return { page: "login", options: {} };
if (path === "/" || path === "") return { page: "devices", options: {} };
if (path === "/devices") return { page: "devices", options: {} };
if (path === "/devices/agent") return { page: "agent_devices", options: {} };
if (path === "/devices/ssh") return { page: "ssh_devices", options: {} };
if (path === "/devices/winrm") return { page: "winrm_devices", options: {} };
if (segments[0] === "device" && segments[1]) {
const id = decodeURIComponent(segments[1]);
return {
page: "device_details",
options: { device: { agent_guid: id, hostname: id } }
};
}
if (path === "/sites") return { page: "sites", options: {} };
if (path === "/scheduling") return { page: "jobs", options: {} };
if (path === "/scheduling/create_job") return { page: "create_job", options: {} };
if (path === "/workflows") return { page: "workflows", options: {} };
if (path === "/workflows/editor") return { page: "workflow-editor", options: {} };
if (path === "/assemblies") return { page: "assemblies", options: {} };
if (path === "/assemblies/editor") {
const mode = params.get("mode");
const relPath = params.get("path") || "";
const state = relPath
? { path: relPath, mode: mode === "ansible" ? "ansible" : "scripts", nonce: Date.now() }
: null;
return {
page: mode === "ansible" ? "ansible_editor" : "scripts",
options: state ? { assemblyState: state } : {}
};
}
if (path === "/access_management/users") return { page: "access_users", options: {} };
if (path === "/access_management/github_token") return { page: "access_github_token", options: {} };
if (path === "/access_management/credentials") return { page: "access_credentials", options: {} };
if (path === "/admin/server_info") return { page: "server_info", options: {} };
if (path === "/admin/enrollment-codes") return { page: "admin_enrollment_codes", options: {} };
if (path === "/admin/device-approvals") return { page: "admin_device_approvals", options: {} };
return { page: "devices", options: {} };
} catch {
return { page: "devices", options: {} };
}
}, []);
const updateStateForPage = useCallback(
(page, options = {}) => {
setCurrentPageState(page);
if (page === "device_details") {
if (options.device) {
setSelectedDevice(options.device);
} else if (options.deviceId) {
const fallbackId = options.deviceId;
const fallbackName = options.deviceName || options.deviceId;
setSelectedDevice((prev) => {
const prevId = prev?.agent_guid || prev?.guid || prev?.hostname || "";
if (prevId === fallbackId || prevId === fallbackName) {
return prev;
}
return { agent_guid: fallbackId, hostname: fallbackName };
});
}
} else if (!options.preserveDevice) {
setSelectedDevice(null);
}
if ((page === "scripts" || page === "ansible_editor") && options.assemblyState) {
setAssemblyEditorState(options.assemblyState);
}
},
[setAssemblyEditorState, setCurrentPageState, setSelectedDevice]
);
const navigateTo = useCallback(
(page, options = {}) => {
const { replace = false, allowUnauthenticated = false, suppressPending = false } = options;
const targetPath = pageToPath(page, options);
if (!allowUnauthenticated && !user && page !== "login") {
if (!suppressPending && targetPath) {
pendingPathRef.current = targetPath;
}
updateStateForPage("login", {});
const loginPath = "/login";
const method = replace ? "replaceState" : "pushState";
const current = window.location.pathname + window.location.search;
if (replace || current !== loginPath) {
window.history[method]({}, "", loginPath);
}
return;
}
if (page === "login") {
updateStateForPage("login", {});
const loginPath = "/login";
const method = replace ? "replaceState" : "pushState";
const current = window.location.pathname + window.location.search;
if (replace || current !== loginPath) {
window.history[method]({}, "", loginPath);
}
return;
}
pendingPathRef.current = null;
updateStateForPage(page, options);
if (targetPath) {
const method = replace ? "replaceState" : "pushState";
const current = window.location.pathname + window.location.search;
if (replace || current !== targetPath) {
window.history[method]({}, "", targetPath);
}
}
},
[pageToPath, updateStateForPage, user]
);
const navigateByPath = useCallback(
(path, { replace = false, allowUnauthenticated = false } = {}) => {
const { page, options } = interpretPath(path);
navigateTo(page, { ...(options || {}), replace, allowUnauthenticated });
},
[interpretPath, navigateTo]
);
const navigateToRef = useRef(navigateTo);
const navigateByPathRef = useRef(navigateByPath);
useEffect(() => {
navigateToRef.current = navigateTo;
navigateByPathRef.current = navigateByPath;
}, [navigateTo, navigateByPath]);
// Build breadcrumb items for current view
const breadcrumbs = React.useMemo(() => {
const items = [];
switch (currentPage) {
case "sites":
items.push({ label: "Sites", page: "sites" });
items.push({ label: "Site List", page: "sites" });
break;
case "devices":
items.push({ label: "Inventory", page: "devices" });
items.push({ label: "Devices", page: "devices" });
break;
case "device_details":
items.push({ label: "Devices", page: "devices" });
items.push({ label: "Device List", page: "devices" });
items.push({ label: "Device Details" });
break;
case "jobs":
items.push({ label: "Automation", page: "jobs" });
items.push({ label: "Scheduled Jobs", page: "jobs" });
break;
case "create_job":
items.push({ label: "Automation", page: "jobs" });
items.push({ label: "Scheduled Jobs", page: "jobs" });
items.push({ label: editingJob ? "Edit Job" : "Create Job", page: "create_job" });
break;
case "workflows":
items.push({ label: "Automation", page: "jobs" });
items.push({ label: "Workflows", page: "workflows" });
break;
case "workflow-editor":
items.push({ label: "Automation", page: "jobs" });
items.push({ label: "Workflows", page: "workflows" });
items.push({ label: "Flow Editor" });
break;
case "scripts":
items.push({ label: "Automation", page: "jobs" });
items.push({ label: "Scripts", page: "scripts" });
break;
case "ansible_editor":
items.push({ label: "Automation", page: "jobs" });
items.push({ label: "Ansible Playbooks", page: "assemblies" });
items.push({ label: "Playbook Editor" });
break;
case "assemblies":
items.push({ label: "Automation", page: "jobs" });
items.push({ label: "Assemblies", page: "assemblies" });
break;
case "community":
items.push({ label: "Automation", page: "jobs" });
items.push({ label: "Community Content", page: "community" });
break;
case "agent_devices":
items.push({ label: "Inventory", page: "devices" });
items.push({ label: "Devices", page: "devices" });
items.push({ label: "Agent Devices", page: "agent_devices" });
break;
case "ssh_devices":
items.push({ label: "Inventory", page: "devices" });
items.push({ label: "Devices", page: "devices" });
items.push({ label: "SSH Devices", page: "ssh_devices" });
break;
case "winrm_devices":
items.push({ label: "Inventory", page: "devices" });
items.push({ label: "Devices", page: "devices" });
items.push({ label: "WinRM Devices", page: "winrm_devices" });
break;
case "access_credentials":
items.push({ label: "Access Management", page: "access_credentials" });
items.push({ label: "Credentials", page: "access_credentials" });
break;
case "access_github_token":
items.push({ label: "Access Management", page: "access_credentials" });
items.push({ label: "GitHub API Token", page: "access_github_token" });
break;
case "access_users":
items.push({ label: "Access Management", page: "access_credentials" });
items.push({ label: "Users", page: "access_users" });
break;
case "server_info":
items.push({ label: "Admin Settings" });
items.push({ label: "Server Info", page: "server_info" });
break;
case "admin_enrollment_codes":
items.push({ label: "Admin Settings", page: "server_info" });
items.push({ label: "Installer Codes", page: "admin_enrollment_codes" });
break;
case "admin_device_approvals":
items.push({ label: "Admin Settings", page: "server_info" });
items.push({ label: "Device Approvals", page: "admin_device_approvals" });
break;
case "filters":
items.push({ label: "Filters & Groups", page: "filters" });
items.push({ label: "Filters", page: "filters" });
break;
case "groups":
items.push({ label: "Filters & Groups", page: "filters" });
items.push({ label: "Groups", page: "groups" });
break;
default:
// Fallback to a neutral crumb if unknown
if (currentPage) items.push({ label: String(currentPage) });
}
return items;
}, [currentPage, selectedDevice, editingJob]);
useEffect(() => {
let canceled = false;
const hydrateSession = async () => {
const session = localStorage.getItem("borealis_session");
if (session) {
try {
const data = JSON.parse(session);
if (Date.now() - data.timestamp < 3600 * 1000) {
if (!canceled) {
setUser(data.username);
setUserRole(data.role || null);
setUserDisplayName(data.display_name || data.username);
}
} else {
localStorage.removeItem("borealis_session");
}
} catch {
localStorage.removeItem("borealis_session");
}
}
try {
const resp = await fetch('/api/auth/me', { credentials: 'include' });
if (resp.ok) {
const me = await resp.json();
if (!canceled) {
setUser(me.username);
setUserRole(me.role || null);
setUserDisplayName(me.display_name || me.username);
}
localStorage.setItem(
"borealis_session",
JSON.stringify({ username: me.username, display_name: me.display_name || me.username, role: me.role, timestamp: Date.now() })
);
}
} catch {}
if (!canceled) {
setSessionResolved(true);
}
};
hydrateSession();
return () => {
canceled = true;
};
}, []);
useEffect(() => {
if (!sessionResolved) return;
const navTo = navigateToRef.current;
const navByPath = navigateByPathRef.current;
if (user) {
const stored = initialPathRef.current;
const currentLocation = window.location.pathname + window.location.search;
const targetPath =
stored && stored !== "/login"
? stored
: currentLocation === "/login" || currentLocation === ""
? "/devices"
: currentLocation;
navByPath(targetPath, { replace: true, allowUnauthenticated: true });
initialPathRef.current = null;
pendingPathRef.current = null;
} else {
const stored = initialPathRef.current;
const currentLocation = window.location.pathname + window.location.search;
const rememberPath =
stored && !stored.startsWith("/login")
? stored
: !currentLocation.startsWith("/login")
? currentLocation
: null;
if (rememberPath) {
pendingPathRef.current = rememberPath;
}
navTo("login", { replace: true, allowUnauthenticated: true, suppressPending: true });
}
}, [sessionResolved, user]);
useEffect(() => {
if (!sessionResolved) return;
const handlePopState = () => {
const path = window.location.pathname + window.location.search;
if (!user) {
if (!path.startsWith("/login")) {
pendingPathRef.current = path;
}
navigateToRef.current("login", { replace: true, allowUnauthenticated: true, suppressPending: true });
return;
}
navigateByPathRef.current(path, { replace: true, allowUnauthenticated: true });
};
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, [sessionResolved, user]);
// Suggest fetcher with debounce
const fetchSuggestions = useCallback((field, q) => {
const query = String(q || "").trim();
if (query.length < 3) {
setSuggestions({ devices: [], sites: [], q: query, field });
return;
}
const params = new URLSearchParams({ field, q: query, limit: "5" });
fetch(`/api/search/suggest?${params.toString()}`)
.then((r) => (r.ok ? r.json() : { devices: [], sites: [], q: query, field }))
.then((data) => setSuggestions(data))
.catch(() => setSuggestions({ devices: [], sites: [], q: query, field }));
}, []);
useEffect(() => {
if (!searchOpen) return;
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
searchDebounceRef.current = setTimeout(() => {
fetchSuggestions(searchCategory, searchQuery);
}, 220);
return () => { if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); };
}, [searchOpen, searchCategory, searchQuery, fetchSuggestions]);
const execSearch = useCallback(async (field, q, navigateImmediate = true) => {
const cat = SEARCH_CATEGORIES.find((c) => c.key === field) || SEARCH_CATEGORIES[0];
if (cat.scope === "site") {
try {
localStorage.setItem('site_list_initial_filters', JSON.stringify(
field === 'site_name' ? { name: q } : { description: q }
));
} catch {}
if (navigateImmediate) navigateTo("sites");
} else {
// device field
// Map API field -> Device_List filter key
const fieldMap = {
hostname: 'hostname',
description: 'description',
last_user: 'lastUser',
internal_ip: 'internalIp',
external_ip: 'externalIp',
serial_number: 'serialNumber', // placeholder (ignored by Device_List for now)
};
const k = fieldMap[field] || 'hostname';
const qLc = String(q || '').toLowerCase();
const exact = (suggestions.devices || []).find((d) => String(d.hostname || d.value || '').toLowerCase() === qLc);
if (exact && (exact.hostname || '').trim()) {
const device = { hostname: exact.hostname.trim() };
if (navigateImmediate) {
navigateTo('device_details', { device });
} else {
setSelectedDevice(device);
}
} else if (field === 'hostname') {
// Probe device existence and open directly if found
try {
const resp = await fetch(`/api/device/details/${encodeURIComponent(q)}`);
if (resp.ok) {
const data = await resp.json();
if (data && (data.summary?.hostname || Object.keys(data).length > 0)) {
const device = { hostname: q };
if (navigateImmediate) {
navigateTo('device_details', { device });
} else {
setSelectedDevice(device);
}
} else {
try { localStorage.setItem('device_list_initial_filters', JSON.stringify({ [k]: q })); } catch {}
if (navigateImmediate) navigateTo('devices');
}
} else {
try { localStorage.setItem('device_list_initial_filters', JSON.stringify({ [k]: q })); } catch {}
if (navigateImmediate) navigateTo('devices');
}
} catch {
try { localStorage.setItem('device_list_initial_filters', JSON.stringify({ [k]: q })); } catch {}
if (navigateImmediate) navigateTo('devices');
}
} else {
try {
const payload = (k === 'serialNumber') ? {} : { [k]: q };
localStorage.setItem('device_list_initial_filters', JSON.stringify(payload));
} catch {}
if (navigateImmediate) navigateTo("devices");
}
}
setSearchOpen(false);
}, [SEARCH_CATEGORIES, navigateTo, suggestions.devices]);
const handleLoginSuccess = ({ username, role }) => {
setUser(username);
setUserRole(role || null);
setUserDisplayName(username);
localStorage.setItem(
"borealis_session",
JSON.stringify({ username, display_name: username, role: role || null, timestamp: Date.now() })
);
// Refresh full profile (to get display_name) in background
(async () => {
try {
const resp = await fetch('/api/auth/me', { credentials: 'include' });
if (resp.ok) {
const me = await resp.json();
setUserDisplayName(me.display_name || me.username);
localStorage.setItem(
"borealis_session",
JSON.stringify({ username: me.username, display_name: me.display_name || me.username, role: me.role, timestamp: Date.now() })
);
}
} catch {}
})();
if (pendingPathRef.current) {
navigateByPath(pendingPathRef.current, { replace: true, allowUnauthenticated: true });
pendingPathRef.current = null;
} else {
navigateTo('devices', { replace: true, allowUnauthenticated: true });
}
};
useEffect(() => {
const saved = localStorage.getItem(LOCAL_STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed.tabs) && parsed.activeTabId) {
setTabs(parsed.tabs);
setActiveTabId(parsed.activeTabId);
}
} catch (err) {
console.warn("Failed to parse saved state:", err);
}
}
}, []);
useEffect(() => {
const timeout = setTimeout(() => {
const data = JSON.stringify({ tabs, activeTabId });
localStorage.setItem(LOCAL_STORAGE_KEY, data);
}, 1000);
return () => clearTimeout(timeout);
}, [tabs, activeTabId]);
const handleSetNodes = useCallback((callbackOrArray, tId) => {
const targetId = tId || activeTabId;
setTabs((old) =>
old.map((tab) =>
tab.id === targetId
? { ...tab, nodes: typeof callbackOrArray === "function" ? callbackOrArray(tab.nodes) : callbackOrArray }
: tab
)
);
}, [activeTabId]);
const handleSetEdges = useCallback((callbackOrArray, tId) => {
const targetId = tId || activeTabId;
setTabs((old) =>
old.map((tab) =>
tab.id === targetId
? { ...tab, edges: typeof callbackOrArray === "function" ? callbackOrArray(tab.edges) : callbackOrArray }
: tab
)
);
}, [activeTabId]);
const handleUserMenuOpen = (event) => setUserMenuAnchorEl(event.currentTarget);
const handleUserMenuClose = () => setUserMenuAnchorEl(null);
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
} catch {}
try { localStorage.removeItem('borealis_session'); } catch {}
setUser(null);
setUserRole(null);
setUserDisplayName(null);
navigateTo('login', { replace: true, allowUnauthenticated: true, suppressPending: true });
};
const handleTabRightClick = (evt, tabId) => {
evt.preventDefault();
setTabMenuAnchor({ x: evt.clientX, y: evt.clientY });
setTabMenuTabId(tabId);
};
const handleCloseTab = () => {
setTabs((prev) => {
const filtered = prev.filter((t) => t.id !== tabMenuTabId);
if (filtered.length === 0) {
const newTab = { id: "flow_1", tab_name: "Flow 1", nodes: [], edges: [] };
setActiveTabId(newTab.id);
return [newTab];
}
if (activeTabId === tabMenuTabId) {
setActiveTabId(filtered[0].id);
}
return filtered;
});
setTabMenuAnchor(null);
};
const handleRenameTab = () => {
const tab = tabs.find((t) => t.id === tabMenuTabId);
if (tab) {
setRenameTabId(tabMenuTabId);
setRenameValue(tab.tab_name);
setRenameDialogOpen(true);
}
setTabMenuAnchor(null);
};
const handleSaveRename = () => {
setTabs((prev) =>
prev.map((t) => (t.id === renameTabId ? { ...t, tab_name: renameValue } : t))
);
setRenameDialogOpen(false);
};
const handleExportFlow = useCallback(() => {
const tab = tabs.find((t) => t.id === activeTabId);
if (!tab) return;
const payload = {
tab_name: tab.tab_name,
nodes: tab.nodes,
edges: tab.edges
};
const fileName = `${tab.tab_name || "workflow"}.json`;
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}, [tabs, activeTabId]);
const handleImportFlow = useCallback(() => {
if (fileInputRef.current) {
fileInputRef.current.value = null;
fileInputRef.current.click();
}
}, []);
const onFileInputChange = useCallback(
(e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const data = JSON.parse(reader.result);
const newId = "flow_" + Date.now();
setTabs((prev) => [
...prev,
{
id: newId,
tab_name:
data.tab_name || data.name || file.name.replace(/\.json$/i, ""),
nodes: data.nodes || [],
edges: data.edges || []
}
]);
setActiveTabId(newId);
navigateTo("workflow-editor");
} catch (err) {
console.error("Failed to import workflow:", err);
}
};
reader.readAsText(file);
e.target.value = "";
},
[navigateTo, setTabs]
);
const handleSaveFlow = useCallback(
async (name) => {
const tab = tabs.find((t) => t.id === activeTabId);
if (!tab || !name) return;
const payload = {
path: tab.folderPath ? `${tab.folderPath}/${name}` : name,
workflow: {
tab_name: tab.tab_name,
nodes: tab.nodes,
edges: tab.edges
}
};
try {
const body = {
island: 'workflows',
kind: 'file',
path: payload.path,
content: payload.workflow
};
await fetch("/api/assembly/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
setTabs((prev) =>
prev.map((t) => (t.id === activeTabId ? { ...t, tab_name: name } : t))
);
} catch (err) {
console.error("Failed to save workflow:", err);
}
},
[tabs, activeTabId]
);
const isAdmin = (String(userRole || '').toLowerCase() === 'admin');
useEffect(() => {
const requiresAdmin = currentPage === 'server_info'
|| currentPage === 'admin_enrollment_codes'
|| currentPage === 'admin_device_approvals'
|| currentPage === 'access_credentials'
|| currentPage === 'access_github_token'
|| currentPage === 'access_users'
|| currentPage === 'ssh_devices'
|| currentPage === 'winrm_devices'
|| currentPage === 'agent_devices';
if (!isAdmin && requiresAdmin) {
setNotAuthorizedOpen(true);
navigateTo('devices', { replace: true, suppressPending: true });
}
}, [currentPage, isAdmin, navigateTo]);
const renderMainContent = () => {
switch (currentPage) {
case "sites":
return (
<SiteList
onOpenDevicesForSite={(siteName) => {
try {
localStorage.setItem('device_list_initial_site_filter', String(siteName || ''));
} catch {}
navigateTo("devices");
}}
/>
);
case "devices":
return (
<DeviceList
onSelectDevice={(d) => {
navigateTo("device_details", { device: d });
}}
/>
);
case "agent_devices":
return (
<AgentDevices
onSelectDevice={(d) => {
navigateTo("device_details", { device: d });
}}
/>
);
case "ssh_devices":
return <SSHDevices />;
case "winrm_devices":
return <WinRMDevices />;
case "device_details":
return (
<DeviceDetails
device={selectedDevice}
onBack={() => {
navigateTo("devices");
setSelectedDevice(null);
}}
/>
);
case "jobs":
return (
<ScheduledJobsList
onCreateJob={() => { setEditingJob(null); navigateTo("create_job"); }}
onEditJob={(job) => { setEditingJob(job); navigateTo("create_job"); }}
refreshToken={jobsRefreshToken}
/>
);
case "create_job":
return (
<CreateJob
initialJob={editingJob}
onCancel={() => { navigateTo("jobs"); setEditingJob(null); }}
onCreated={() => { navigateTo("jobs"); setEditingJob(null); setJobsRefreshToken(Date.now()); }}
/>
);
case "workflows":
return (
<AssemblyList
onOpenWorkflow={async (workflow, folderPath, name) => {
const newId = "flow_" + Date.now();
if (workflow && workflow.rel_path) {
const folder = workflow.rel_path.split("/").slice(0, -1).join("/");
try {
const resp = await fetch(`/api/assembly/load?island=workflows&path=${encodeURIComponent(workflow.rel_path)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
setTabs([{ id: newId, tab_name: data.tab_name || workflow.name || workflow.file_name || "Workflow", nodes: data.nodes || [], edges: data.edges || [], folderPath: folder }]);
} catch (err) {
console.error("Failed to load workflow:", err);
setTabs([{ id: newId, tab_name: workflow?.name || "Workflow", nodes: [], edges: [], folderPath: folder }]);
}
} else {
setTabs([{ id: newId, tab_name: name || "Flow", nodes: [], edges: [], folderPath: folderPath || "" }]);
}
setActiveTabId(newId);
navigateTo("workflow-editor");
}}
onOpenScript={(rel, mode, context) => {
const nonce = Date.now();
setAssemblyEditorState({
path: rel || '',
mode,
context: context ? { ...context, nonce } : null,
nonce
});
navigateTo(mode === 'ansible' ? 'ansible_editor' : 'scripts', {
assemblyState: {
path: rel || '',
mode,
context: context ? { ...context, nonce } : null,
nonce
}
});
}}
/>
);
case "assemblies":
return (
<AssemblyList
onOpenWorkflow={async (workflow, folderPath, name) => {
const newId = "flow_" + Date.now();
if (workflow && workflow.rel_path) {
const folder = workflow.rel_path.split("/").slice(0, -1).join("/");
try {
const resp = await fetch(`/api/assembly/load?island=workflows&path=${encodeURIComponent(workflow.rel_path)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
setTabs([{ id: newId, tab_name: data.tab_name || workflow.name || workflow.file_name || "Workflow", nodes: data.nodes || [], edges: data.edges || [], folderPath: folder }]);
} catch (err) {
console.error("Failed to load workflow:", err);
setTabs([{ id: newId, tab_name: workflow?.name || "Workflow", nodes: [], edges: [], folderPath: folder }]);
}
} else {
setTabs([{ id: newId, tab_name: name || "Flow", nodes: [], edges: [], folderPath: folderPath || "" }]);
}
setActiveTabId(newId);
navigateTo("workflow-editor");
}}
onOpenScript={(rel, mode, context) => {
const nonce = Date.now();
setAssemblyEditorState({
path: rel || '',
mode,
context: context ? { ...context, nonce } : null,
nonce
});
navigateTo(mode === 'ansible' ? 'ansible_editor' : 'scripts', {
assemblyState: {
path: rel || '',
mode,
context: context ? { ...context, nonce } : null,
nonce
}
});
}}
/>
);
case "scripts":
return (
<AssemblyEditor
mode="scripts"
initialPath={assemblyEditorState?.mode === 'scripts' ? (assemblyEditorState?.path || '') : ''}
initialContext={assemblyEditorState?.mode === 'scripts' ? assemblyEditorState?.context : null}
onConsumeInitialData={() =>
setAssemblyEditorState((prev) => (prev && prev.mode === 'scripts' ? null : prev))
}
onSaved={() => navigateTo('assemblies')}
/>
);
case "ansible_editor":
return (
<AssemblyEditor
mode="ansible"
initialPath={assemblyEditorState?.mode === 'ansible' ? (assemblyEditorState?.path || '') : ''}
initialContext={assemblyEditorState?.mode === 'ansible' ? assemblyEditorState?.context : null}
onConsumeInitialData={() =>
setAssemblyEditorState((prev) => (prev && prev.mode === 'ansible' ? null : prev))
}
onSaved={() => navigateTo('assemblies')}
/>
);
case "access_credentials":
return <CredentialList isAdmin={isAdmin} />;
case "access_github_token":
return <GithubAPIToken isAdmin={isAdmin} />;
case "access_users":
return <UserManagement isAdmin={isAdmin} />;
case "server_info":
return <ServerInfo isAdmin={isAdmin} />;
case "admin_enrollment_codes":
return <EnrollmentCodes />;
case "admin_device_approvals":
return <DeviceApprovals />;
case "workflow-editor":
return (
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 1, overflow: "hidden" }}>
<Box sx={{ display: "flex", flexGrow: 1, overflow: "hidden" }}>
<NodeSidebar
categorizedNodes={categorizedNodes}
handleExportFlow={handleExportFlow}
handleImportFlow={handleImportFlow}
handleSaveFlow={handleSaveFlow}
handleOpenCloseAllDialog={() => setConfirmCloseOpen(true)}
fileInputRef={fileInputRef}
onFileInputChange={onFileInputChange}
currentTabName={tabs.find((t) => t.id === activeTabId)?.tab_name}
/>
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 1, overflow: "hidden" }}>
<FlowTabs
tabs={tabs}
activeTabId={activeTabId}
onTabChange={setActiveTabId}
onAddTab={() => {}}
onTabRightClick={handleTabRightClick}
/>
<Box sx={{ flexGrow: 1, position: "relative" }}>
{tabs.map((tab) => (
<Box
key={tab.id}
sx={{
position: "absolute", top: 0, bottom: 0, left: 0, right: 0,
display: tab.id === activeTabId ? "block" : "none"
}}
>
<ReactFlowProvider id={tab.id}>
<FlowEditor
flowId={tab.id}
nodes={tab.nodes}
edges={tab.edges}
setNodes={(val) => handleSetNodes(val, tab.id)}
setEdges={(val) => handleSetEdges(val, tab.id)}
nodeTypes={nodeTypes}
categorizedNodes={categorizedNodes}
/>
</ReactFlowProvider>
</Box>
))}
</Box>
</Box>
</Box>
<StatusBar />
</Box>
);
default:
return (
<Box sx={{ p: 2 }}>
<Typography>Select a section from navigation.</Typography>
</Box>
);
}
};
if (!user) {
return (
<ThemeProvider theme={darkTheme}>
<CssBaseline />
<Login onLogin={handleLoginSuccess} />
</ThemeProvider>
);
}
return (
<ThemeProvider theme={darkTheme}>
<CssBaseline />
<Box sx={{ width: "100vw", height: "100vh", display: "flex", flexDirection: "column", overflow: "hidden" }}>
<AppBar position="static" sx={{ bgcolor: "#16191d" }}>
<Toolbar sx={{ minHeight: "36px", position: 'relative' }}>
<Box component="img" src="/Borealis_Logo_Full.png" alt="Borealis Logo" sx={{ height: "52px", marginRight: "8px" }} />
{/* Breadcrumbs inline in top bar (transparent), aligned to content area */}
<Box
sx={{
position: 'absolute',
left: 'calc(260px + 550px)', // fine-tuned to align with black content edge
bottom: 6,
display: 'flex',
alignItems: 'flex-end',
pointerEvents: 'none' // avoid interfering with About menu positioning
}}
>
<Breadcrumbs
separator={<NavigateNextIcon fontSize="inherit" sx={{ color: "#6b6b6b" }} />}
aria-label="breadcrumb"
sx={{
color: "#9aa0a6",
fontSize: "0.825rem", // 50% larger than previous
'& .MuiBreadcrumbs-separator': { mx: 0.6 },
pointerEvents: 'auto'
}}
>
{breadcrumbs.map((c, idx) => {
if (c.page) {
return (
<Button
key={idx}
onClick={() => navigateTo(c.page)}
size="small"
sx={{
color: "#7db7ff",
textTransform: "none",
minWidth: 0,
p: 0,
fontSize: "0.825rem"
}}
>
{c.label}
</Button>
);
}
return (
<Typography key={idx} component="span" sx={{ color: "#e0e0e0", fontSize: "0.825rem" }}>
{c.label}
</Typography>
);
})}
</Breadcrumbs>
</Box>
{/* Top search: category + input */}
<ClickAwayListener onClickAway={() => setSearchOpen(false)}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, ml: 2 }}>
<Button
variant="outlined"
size="small"
onClick={(e) => setSearchMenuEl(e.currentTarget)}
endIcon={searchMenuEl ? <ArrowDropUpIcon /> : <ArrowDropDownIcon />}
sx={{
height: 32,
color: '#ddd',
left: -11,
bottom: -6,
borderColor: '#3a3f44',
textTransform: 'none',
bgcolor: '#1e2328',
'&:hover': { borderColor: '#4b5158', bgcolor: '#22272e' },
minWidth: 160,
justifyContent: 'space-between'
}}
>
{(SEARCH_CATEGORIES.find(c => c.key === searchCategory) || {}).label || 'Hostname'}
</Button>
<Menu
anchorEl={searchMenuEl}
open={Boolean(searchMenuEl)}
onClose={() => setSearchMenuEl(null)}
PaperProps={{ sx: { bgcolor: '#1e1e1e', color: '#fff', minWidth: 240 } }}
>
{SEARCH_CATEGORIES.map((c) => (
<MenuItem key={c.key} onClick={() => { setSearchCategory(c.key); setSearchMenuEl(null); setSearchQuery(''); setSuggestions({ devices: [], sites: [], q: '', field: '' }); }}>
{c.label}
</MenuItem>
))}
</Menu>
<Box
ref={searchAnchorRef}
sx={{ position: 'relative', left: -2, bottom: -6, display: 'flex', alignItems: 'center', border: '1px solid #3a3f44', borderRadius: 1, height: 32, minWidth: 320, bgcolor: '#1e2328' }}
>
<input
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setSearchOpen(true); }}
onFocus={() => setSearchOpen(true)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
execSearch(searchCategory, searchQuery);
} else if (e.key === 'Escape') {
setSearchOpen(false);
}
}}
placeholder={(SEARCH_CATEGORIES.find(c => c.key === searchCategory) || {}).placeholder || 'Search'}
style={{
outline: 'none', border: 'none', background: 'transparent', color: '#e8eaed', paddingLeft: 10, paddingRight: 28, width: 360, height: '100%'
}}
/>
<SearchIcon sx={{ position: 'absolute', right: 6, color: '#8aa0b4', fontSize: 18 }} />
{searchOpen && (((SEARCH_CATEGORIES.find(c=>c.key===searchCategory)?.scope==='device') && (suggestions.devices||[]).length>0) || ((SEARCH_CATEGORIES.find(c=>c.key===searchCategory)?.scope==='site') && (suggestions.sites||[]).length>0)) && (
<Box
sx={{ position: 'absolute', top: '100%', left: 0, right: 0, bgcolor: '#1e2328', border: '1px solid #3a3f44', borderTop: 'none', zIndex: 1400, borderRadius: '0 0 6px 6px', maxHeight: 320, overflowY: 'auto' }}
>
{/* Devices group */}
{((suggestions.devices || []).length > 0 && (SEARCH_CATEGORIES.find(c=>c.key===searchCategory)?.scope==='device')) && (
<Box sx={{ borderBottom: '1px solid #2b2f34' }}>
<Box sx={{ display: 'flex', alignItems: 'center', px: 1.2, py: 0.8, color: '#9aa0a6', fontSize: 12 }}>Devices</Box>
{suggestions.devices && suggestions.devices.length > 0 ? (
suggestions.devices.map((d, idx) => {
const primary = (searchCategory === 'hostname')
? highlightText(d.hostname || d.value, searchQuery)
: (d.hostname || d.value);
// Choose a secondary value based on category; fallback to best-available info
let secVal = '';
if (searchCategory === 'internal_ip') secVal = d.internal_ip || '';
else if (searchCategory === 'external_ip') secVal = d.external_ip || '';
else if (searchCategory === 'description') secVal = d.description || '';
else if (searchCategory === 'last_user') secVal = d.last_user || '';
const secHighlighted = (searchCategory !== 'hostname' && secVal)
? highlightText(secVal, searchQuery)
: (d.internal_ip || d.external_ip || d.description || d.last_user || '');
return (
<Box key={idx} onClick={() => { navigateTo('device_details', { device: { hostname: d.hostname || d.value } }); setSearchOpen(false); }} sx={{ px: 1.2, py: 0.6, '&:hover': { bgcolor: '#22272e' }, cursor: 'pointer' }}>
<Typography variant="body2" sx={{ color: '#e8eaed' }}>{primary}</Typography>
<Typography variant="caption" sx={{ color: '#9aa0a6' }}>
{d.site_name || ''}{(d.site_name && (secVal || (d.internal_ip || d.external_ip || d.description || d.last_user))) ? ' • ' : ''}{secHighlighted}
</Typography>
</Box>
);
})
) : (
<Box sx={{ px: 1.2, py: 1, color: '#6b737c', fontSize: 12 }}>
{searchCategory === 'serial_number' ? 'Serial numbers are not tracked yet.' : 'No matches'}
</Box>
)}
</Box>
)}
{/* Sites group */}
{((suggestions.sites || []).length > 0 && (SEARCH_CATEGORIES.find(c=>c.key===searchCategory)?.scope==='site')) && (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', px: 1.2, py: 0.8, color: '#9aa0a6', fontSize: 12 }}>Sites</Box>
{suggestions.sites && suggestions.sites.length > 0 ? (
suggestions.sites.map((s, idx) => (
<Box key={idx} onClick={() => execSearch(searchCategory, s.value)} sx={{ px: 1.2, py: 0.6, '&:hover': { bgcolor: '#22272e' }, cursor: 'pointer' }}>
<Typography variant="body2" sx={{ color: '#e8eaed' }}>{searchCategory === 'site_name' ? highlightText(s.site_name, searchQuery) : s.site_name}</Typography>
<Typography variant="caption" sx={{ color: '#9aa0a6' }}>{searchCategory === 'site_description' ? highlightText(s.site_description || '', searchQuery) : (s.site_description || '')}</Typography>
</Box>
))
) : (
<Box sx={{ px: 1.2, py: 1, color: '#6b737c', fontSize: 12 }}>No matches</Box>
)}
</Box>
)}
</Box>
)}
</Box>
</Box>
</ClickAwayListener>
{/* Spacer to keep user menu aligned right */}
<Box sx={{ flexGrow: 1 }} />
<Button
color="inherit"
onClick={handleUserMenuOpen}
endIcon={<KeyboardArrowDownIcon />}
sx={{ height: "36px" }}
>
{userDisplayName || user || 'User'}
</Button>
<Menu anchorEl={userMenuAnchorEl} open={Boolean(userMenuAnchorEl)} onClose={handleUserMenuClose}>
<MenuItem onClick={() => { handleUserMenuClose(); handleLogout(); }}>
<LogoutIcon sx={{ fontSize: 18, color: "#ff6b6b", mr: 1 }} /> Logout
</MenuItem>
</Menu>
</Toolbar>
</AppBar>
<Box sx={{ display: "flex", flexGrow: 1, overflow: "auto", minHeight: 0 }}>
<NavigationSidebar currentPage={currentPage} onNavigate={navigateTo} isAdmin={isAdmin} />
<Box
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
minHeight: 0,
// Ensure primary page container (usually a Paper with m:2) fills to the bottom
'& > *': {
alignSelf: 'stretch',
minHeight: 'calc(100% - 32px)' // account for typical m:2 top+bottom margins
}
}}
>
{renderMainContent()}
</Box>
</Box>
</Box>
<CloseAllDialog open={confirmCloseOpen} onClose={() => setConfirmCloseOpen(false)} onConfirm={() => {}} />
<RenameTabDialog
open={renameDialogOpen}
value={renameValue}
onChange={setRenameValue}
onCancel={() => setRenameDialogOpen(false)}
onSave={handleSaveRename}
/>
<TabContextMenu
anchor={tabMenuAnchor}
onClose={() => setTabMenuAnchor(null)}
onRename={handleRenameTab}
onCloseTab={handleCloseTab}
/>
<NotAuthorizedDialog open={notAuthorizedOpen} onClose={() => setNotAuthorizedOpen(false)} />
</ThemeProvider>
);
}