Added Device, Script,and Workflow Tabs in new Navigation Sidebar

This commit is contained in:
2025-08-07 21:11:55 -06:00
parent 720b09fa34
commit a5e2b87fc3
5 changed files with 714 additions and 683 deletions

View File

@@ -1,103 +1,54 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/App.jsx
// Core React Imports
import React, {
useState,
useEffect,
useCallback,
useRef,
useMemo
} from "react";
// Material UI - Components
import React, { useState, useEffect, useCallback, useRef } from "react";
import {
AppBar,
Toolbar,
Typography,
Box,
Menu,
MenuItem,
Button,
CssBaseline,
ThemeProvider,
createTheme,
Accordion,
AccordionSummary,
AccordionDetails,
List,
ListItemButton,
ListItemText,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TableSortLabel,
Paper,
Tooltip
AppBar, Toolbar, Typography, Box, Menu, MenuItem, Button,
CssBaseline, ThemeProvider, createTheme
} from "@mui/material";
// Material UI - Icons
import {
KeyboardArrowDown as KeyboardArrowDownIcon,
InfoOutlined as InfoOutlinedIcon,
MergeType as MergeTypeIcon,
People as PeopleIcon,
ExpandMore as ExpandMoreIcon,
PlayCircle as PlayCircleIcon,
Devices as DevicesIcon,
AutoAwesomeMosaic as WorkflowsIcon,
Construction as JobsIcon,
PeopleOutline as CommunityIcon,
FilterAlt as FilterIcon,
Groups as GroupsIcon
People as PeopleIcon
} from "@mui/icons-material";
// React Flow
import { ReactFlowProvider } from "reactflow";
// Styles
import "reactflow/dist/style.css";
// Import Borealis Modules
import FlowTabs from "./Flow_Tabs";
import FlowEditor from "./Flow_Editor";
import NodeSidebar from "./Node_Sidebar";
import {
CloseAllDialog,
CreditsDialog,
RenameTabDialog,
TabContextMenu
CloseAllDialog, CreditsDialog, RenameTabDialog, TabContextMenu
} from "./Dialogs";
import StatusBar from "./Status_Bar";
// Websocket Functionality
// New imports for split pages
import NavigationSidebar from "./Navigation_Sidebar";
import WorkflowList from "./Workflow_List";
import DeviceList from "./Device_List";
import ScriptList from "./Script_List";
import { io } from "socket.io-client";
if (!window.BorealisSocket) {
window.BorealisSocket = io(window.location.origin, {
transports: ["websocket"]
});
window.BorealisSocket = io(window.location.origin, { transports: ["websocket"] });
}
if (!window.BorealisUpdateRate) {
window.BorealisUpdateRate = 200;
}
const modules = import.meta.glob("./nodes/**/*.jsx", { eager: true });
// 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 parts = path.replace('./nodes/', '').split('/');
const category = parts[0];
if (!categorizedNodes[category]) {
categorizedNodes[category] = [];
}
if (!categorizedNodes[category]) categorizedNodes[category] = [];
categorizedNodes[category].push(comp);
nodeTypes[type] = component;
});
@@ -105,26 +56,14 @@ Object.entries(modules).forEach(([path, mod]) => {
const darkTheme = createTheme({
palette: {
mode: "dark",
background: {
default: "#121212",
paper: "#1e1e1e"
},
text: {
primary: "#ffffff"
}
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"
}
tooltip: { backgroundColor: "#2a2a2a", color: "#ccc", fontSize: "0.75rem", border: "1px solid #444" },
arrow: { color: "#2a2a2a" }
}
}
}
@@ -132,177 +71,11 @@ const darkTheme = createTheme({
const LOCAL_STORAGE_KEY = "borealis_persistent_state";
// ---------- Utilities ----------
function timeSince(tsSec) {
if (!tsSec) return "unknown";
const now = Date.now() / 1000;
const s = Math.max(0, Math.floor(now - tsSec));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${s % 60}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
function statusFromHeartbeat(tsSec, offlineAfter = 15) {
if (!tsSec) return "Offline";
const now = Date.now() / 1000;
return now - tsSec <= offlineAfter ? "Online" : "Offline";
}
// ---------- Devices Table (sortable) ----------
function DevicesTable() {
const [rows, setRows] = useState([]);
const [orderBy, setOrderBy] = useState("status");
const [order, setOrder] = useState("desc");
const fetchAgents = useCallback(async () => {
try {
const res = await fetch("/api/agents");
const data = await res.json();
const arr = Object.values(data || {}).map((a) => ({
hostname: a.hostname || a.agent_id || "unknown",
status: statusFromHeartbeat(a.last_seen),
lastSeen: a.last_seen || 0,
os: a.agent_operating_system || a.os || "-"
}));
setRows(arr);
} catch (e) {
console.warn("Failed to load agents:", e);
setRows([]);
}
}, []);
useEffect(() => {
fetchAgents();
const t = setInterval(fetchAgents, 5000);
return () => clearInterval(t);
}, [fetchAgents]);
const sorted = useMemo(() => {
const dir = order === "asc" ? 1 : -1;
return [...rows].sort((a, b) => {
const A = a[orderBy];
const B = b[orderBy];
if (orderBy === "lastSeen") return (A - B) * dir;
return String(A).localeCompare(String(B)) * dir;
});
}, [rows, orderBy, order]);
const handleSort = (col) => {
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
else {
setOrderBy(col);
setOrder("asc");
}
};
const statusColor = (s) => (s === "Online" ? "#00d18c" : "#ff4f4f");
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 }}>
Devices
</Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}>
Connected agents and their recent heartbeat.
</Typography>
</Box>
<Table size="small" sx={{ minWidth: 680 }}>
<TableHead>
<TableRow>
<TableCell sortDirection={orderBy === "status" ? order : false}>
<TableSortLabel
active={orderBy === "status"}
direction={orderBy === "status" ? order : "asc"}
onClick={() => handleSort("status")}
>
Status
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "hostname" ? order : false}>
<TableSortLabel
active={orderBy === "hostname"}
direction={orderBy === "hostname" ? order : "asc"}
onClick={() => handleSort("hostname")}
>
Hostname
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "lastSeen" ? order : false}>
<TableSortLabel
active={orderBy === "lastSeen"}
direction={orderBy === "lastSeen" ? order : "asc"}
onClick={() => handleSort("lastSeen")}
>
Last Heartbeat
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "os" ? order : false}>
<TableSortLabel
active={orderBy === "os"}
direction={orderBy === "os" ? order : "asc"}
onClick={() => handleSort("os")}
>
OS
</TableSortLabel>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sorted.map((r, i) => (
<TableRow key={i} hover>
<TableCell>
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: 10,
background: statusColor(r.status),
marginRight: 8,
verticalAlign: "middle"
}}
/>
{r.status}
</TableCell>
<TableCell>{r.hostname}</TableCell>
<TableCell>{timeSince(r.lastSeen)}</TableCell>
<TableCell>{r.os}</TableCell>
</TableRow>
))}
{sorted.length === 0 && (
<TableRow>
<TableCell colSpan={4} sx={{ color: "#888" }}>
No agents connected.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Paper>
);
}
// ---------- Main App ----------
export default function App() {
const [tabs, setTabs] = useState([
{ id: "flow_1", tab_name: "Flow 1", nodes: [], edges: [] }
]);
const [tabs, setTabs] = useState([{ id: "flow_1", tab_name: "Flow 1", nodes: [], edges: [] }]);
const [activeTabId, setActiveTabId] = useState("flow_1");
const [currentPage, setCurrentPage] = useState("jobs");
// navigation state
const [currentPage, setCurrentPage] = useState("devices");
const [navCollapsed, setNavCollapsed] = useState(false);
const [expandedNav, setExpandedNav] = useState({
devices: true,
filters: false,
automation: true
});
// dialogs / menus
const [aboutAnchorEl, setAboutAnchorEl] = useState(null);
const [creditsDialogOpen, setCreditsDialogOpen] = useState(false);
const [confirmCloseOpen, setConfirmCloseOpen] = useState(false);
@@ -313,7 +86,6 @@ export default function App() {
const [tabMenuTabId, setTabMenuTabId] = useState(null);
const fileInputRef = useRef(null);
// persist tabs
useEffect(() => {
const saved = localStorage.getItem(LOCAL_STORAGE_KEY);
if (saved) {
@@ -337,310 +109,125 @@ export default function App() {
return () => clearTimeout(timeout);
}, [tabs, activeTabId]);
const handleSetNodes = useCallback(
(callbackOrArray, tId) => {
const targetId = tId || activeTabId;
setTabs((old) =>
old.map((tab) => {
if (tab.id !== targetId) return tab;
const newNodes =
typeof callbackOrArray === "function"
? callbackOrArray(tab.nodes)
: callbackOrArray;
return { ...tab, nodes: newNodes };
})
);
},
[activeTabId]
);
const handleSetEdges = useCallback(
(callbackOrArray, tId) => {
const targetId = tId || activeTabId;
setTabs((old) =>
old.map((tab) => {
if (tab.id !== targetId) return tab;
const newEdges =
typeof callbackOrArray === "function"
? callbackOrArray(tab.edges)
: callbackOrArray;
return { ...tab, edges: newEdges };
})
);
},
[activeTabId]
);
// app bar menu
const handleAboutMenuOpen = (event) => setAboutAnchorEl(event.currentTarget);
const handleAboutMenuClose = () => setAboutAnchorEl(null);
const openCreditsDialog = () => {
handleAboutMenuClose();
setCreditsDialogOpen(true);
};
// flow tab helpers...
const handleOpenCloseAllDialog = () => setConfirmCloseOpen(true);
const handleCloseDialog = () => setConfirmCloseOpen(false);
const handleConfirmCloseAll = () => {
setTabs([{ id: "flow_1", tab_name: "Flow 1", nodes: [], edges: [] }]);
setActiveTabId("flow_1");
setConfirmCloseOpen(false);
};
const createNewTab = () => {
const nextIndex = tabs.length + 1;
const newId = "flow_" + nextIndex;
setTabs((old) => [
...old,
{ id: newId, tab_name: "Flow " + nextIndex, nodes: [], edges: [] }
]);
setActiveTabId(newId);
setCurrentPage("workflow-editor");
};
const handleTabChange = (newActiveTabId) => {
setActiveTabId(newActiveTabId);
};
const handleTabRightClick = (evt, tabId) => {
evt.preventDefault();
setTabMenuAnchor({ x: evt.clientX, y: evt.clientY });
setTabMenuTabId(tabId);
};
const handleCloseTabMenu = () => {
setTabMenuAnchor(null);
setTabMenuTabId(null);
};
const handleRenameTab = () => {
setRenameDialogOpen(true);
setRenameTabId(tabMenuTabId);
const t = tabs.find((x) => x.id === tabMenuTabId);
setRenameValue(t ? t.tab_name : "");
handleCloseTabMenu();
};
const handleCloseTab = () => {
setTabs((old) => {
const idx = old.findIndex((t) => t.id === tabMenuTabId);
if (idx === -1) return old;
const newList = [...old];
newList.splice(idx, 1);
if (tabMenuTabId === activeTabId && newList.length > 0) {
setActiveTabId(newList[0].id);
} else if (newList.length === 0) {
newList.push({
id: "flow_1",
tab_name: "Flow 1",
nodes: [],
edges: []
});
setActiveTabId("flow_1");
}
return newList;
});
handleCloseTabMenu();
};
const handleRenameDialogSave = () => {
if (!renameTabId) {
setRenameDialogOpen(false);
return;
}
const handleSetNodes = useCallback((callbackOrArray, tId) => {
const targetId = tId || activeTabId;
setTabs((old) =>
old.map((tab) =>
tab.id === renameTabId ? { ...tab, tab_name: renameValue } : tab
tab.id === targetId
? { ...tab, nodes: typeof callbackOrArray === "function" ? callbackOrArray(tab.nodes) : callbackOrArray }
: tab
)
);
setRenameDialogOpen(false);
};
}, [activeTabId]);
const handleExportFlow = async () => {
const activeTab = tabs.find((x) => x.id === activeTabId);
if (!activeTab) return;
const data = JSON.stringify(
{
nodes: activeTab.nodes,
edges: activeTab.edges,
tab_name: activeTab.tab_name
},
null,
2
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
)
);
const blob = new Blob([data], { type: "application/json" });
const sanitizedTabName = activeTab.tab_name.replace(/\s+/g, "_").toLowerCase();
const suggestedFilename = sanitizedTabName + "_workflow.json";
if (window.showSaveFilePicker) {
try {
const fileHandle = await window.showSaveFilePicker({
suggestedName: suggestedFilename,
types: [{ description: "Workflow JSON File", accept: { "application/json": [".json"] } }]
});
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
} catch (err) {
console.error("Save cancelled or failed:", err);
}
} else {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = suggestedFilename;
a.style.display = "none";
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(a.href);
document.body.removeChild(a);
}
};
}, [activeTabId]);
const handleImportFlow = async () => {
if (window.showOpenFilePicker) {
try {
const [fileHandle] = await window.showOpenFilePicker({
types: [{ description: "Workflow JSON File", accept: { "application/json": [".json"] } }]
});
const file = await fileHandle.getFile();
const text = await file.text();
const json = JSON.parse(text);
const newId = "flow_" + (tabs.length + 1);
setTabs((prev) => [
...prev,
{
id: newId,
tab_name: json.tab_name || "Imported Flow " + (tabs.length + 1),
nodes: json.nodes || [],
edges: json.edges || []
}
]);
setActiveTabId(newId);
setCurrentPage("workflow-editor");
} catch (err) {
console.error("Import cancelled or failed:", err);
}
} else {
fileInputRef.current?.click();
}
};
const handleAboutMenuOpen = (event) => setAboutAnchorEl(event.currentTarget);
const handleAboutMenuClose = () => setAboutAnchorEl(null);
const openCreditsDialog = () => { handleAboutMenuClose(); setCreditsDialogOpen(true); };
const handleFileInputChange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const json = JSON.parse(text);
const newId = "flow_" + (tabs.length + 1);
setTabs((prev) => [
...prev,
{
id: newId,
tab_name: json.tab_name || "Imported Flow " + (tabs.length + 1),
nodes: json.nodes || [],
edges: json.edges || []
}
]);
setActiveTabId(newId);
setCurrentPage("workflow-editor");
} catch (err) {
console.error("Failed to read file:", err);
}
};
// ---------- Main Content ----------
const renderMainContent = () => {
if (currentPage === "workflow-editor") {
return (
<Box sx={{ display: "flex", flexGrow: 1, overflow: "hidden" }}>
<NodeSidebar
categorizedNodes={categorizedNodes}
handleExportFlow={handleExportFlow}
handleImportFlow={handleImportFlow}
handleOpenCloseAllDialog={handleOpenCloseAllDialog}
fileInputRef={fileInputRef}
onFileInputChange={handleFileInputChange}
switch (currentPage) {
case "devices":
return <DeviceList />;
case "workflows":
return (
<WorkflowList
onOpenWorkflow={(workflow) => {
// If workflow name exists in tabs, just switch to it
if (workflow?.name) {
const existing = tabs.find(
(t) => t.tab_name.toLowerCase() === workflow.name.toLowerCase()
);
if (existing) {
setActiveTabId(existing.id);
setCurrentPage("workflow-editor");
return;
}
}
// Otherwise, create a new workflow tab
const newId = "flow_" + (tabs.length + 1);
setTabs((prev) => [
...prev,
{
id: newId,
tab_name: workflow?.name || `Flow ${tabs.length + 1}`,
nodes: [],
edges: []
}
]);
setActiveTabId(newId);
setCurrentPage("workflow-editor");
}}
/>
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 1, overflow: "hidden" }}>
<FlowTabs
tabs={tabs}
activeTabId={activeTabId}
onTabChange={handleTabChange}
onAddTab={createNewTab}
onTabRightClick={handleTabRightClick}
);
case "scripts":
return <ScriptList />;
case "workflow-editor":
return (
<Box sx={{ display: "flex", flexGrow: 1, overflow: "hidden" }}>
<NodeSidebar
categorizedNodes={categorizedNodes}
handleExportFlow={() => {}}
handleImportFlow={() => {}}
handleOpenCloseAllDialog={() => {}}
fileInputRef={fileInputRef}
onFileInputChange={() => {}}
/>
<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 sx={{ display: "flex", flexDirection: "column", flexGrow: 1, overflow: "hidden" }}>
<FlowTabs
tabs={tabs}
activeTabId={activeTabId}
onTabChange={setActiveTabId}
onAddTab={() => {}}
onTabRightClick={() => {}}
/>
<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>
</Box>
);
}
if (currentPage === "devices") {
return <DevicesTable />;
}
return (
<Box sx={{ p: 2 }}>
<Typography>Select a section from navigation.</Typography>
</Box>
);
};
);
// ---------- Nav helpers ----------
const NavItem = ({ icon, label, pageKey, indent = 0, onClick }) => {
const active = currentPage === pageKey;
return (
<ListItemButton
onClick={onClick || (() => setCurrentPage(pageKey))}
sx={{
pl: indent ? 4 : 2,
py: 1,
color: "#ccc",
position: "relative",
bgcolor: active ? "#2a2a2a" : "transparent",
"&:hover": { bgcolor: "#2c2c2c" }
}}
>
{/* left accent when active */}
<Box
sx={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: active ? 3 : 0,
bgcolor: "#58a6ff",
transition: "width 0.15s ease"
}}
/>
{icon && <Box sx={{ mr: 1, display: "flex", alignItems: "center" }}>{icon}</Box>}
<ListItemText primary={label} primaryTypographyProps={{ fontSize: "0.95rem" }} />
</ListItemButton>
);
default:
return (
<Box sx={{ p: 2 }}>
<Typography>Select a section from navigation.</Typography>
</Box>
);
}
};
return (
@@ -670,164 +257,29 @@ export default function App() {
</Menu>
</Toolbar>
</AppBar>
{/* Main area with new wider nav styled like Node Sidebar */}
<Box sx={{ display: "flex", flexGrow: 1, overflow: "hidden" }}>
{/* Navigation Sidebar */}
<Box
sx={{
width: navCollapsed ? 48 : 300,
bgcolor: "#121212",
borderRight: "1px solid #333",
display: "flex",
flexDirection: "column",
transition: "width 0.2s ease"
}}
>
<Box sx={{ flex: 1, overflowY: "auto" }}>
{!navCollapsed && (
<>
{/* Devices */}
<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={{ backgroundColor: "#2c2c2c", minHeight: "36px", "& .MuiAccordionSummary-content": { margin: 0 } }}
>
<Typography sx={{ fontSize: "0.9rem", color: "#0475c2" }}>
<b>Devices</b>
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
<NavItem icon={<DevicesIcon fontSize="small" />} label="Devices" pageKey="devices" />
</AccordionDetails>
</Accordion>
{/* Filters & Groups */}
<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={{ backgroundColor: "#2c2c2c", minHeight: "36px", "& .MuiAccordionSummary-content": { margin: 0 } }}
>
<Typography sx={{ fontSize: "0.9rem", color: "#0475c2" }}>
<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>
{/* Automation */}
<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={{ backgroundColor: "#2c2c2c", minHeight: "36px", "& .MuiAccordionSummary-content": { margin: 0 } }}
>
<Typography sx={{ fontSize: "0.9rem", color: "#0475c2" }}>
<b>Automation</b>
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
<NavItem icon={<JobsIcon fontSize="small" />} label="Jobs" pageKey="jobs" />
<NavItem
icon={<WorkflowsIcon fontSize="small" />}
label="Workflows"
pageKey="workflow-editor"
onClick={() => setCurrentPage("workflow-editor")}
/>
<Box sx={{ px: 2, pb: 1 }}>
<Tooltip title="Create a new workflow tab and open the editor" arrow>
<Button
fullWidth
onClick={createNewTab}
startIcon={<PlayCircleIcon />}
sx={{
mt: 1,
color: "#58a6ff",
borderColor: "#58a6ff",
textTransform: "none",
border: "1px solid #58a6ff",
backgroundColor: "#1e1e1e",
"&:hover": { backgroundColor: "#1b1b1b" }
}}
>
New Workflow
</Button>
</Tooltip>
</Box>
<NavItem icon={<CommunityIcon fontSize="small" />} label="Community Nodes" pageKey="community" />
</AccordionDetails>
</Accordion>
{/* Hidden file input for import */}
<input
type="file"
accept=".json,application/json"
style={{ display: "none" }}
ref={fileInputRef}
onChange={handleFileInputChange}
/>
</>
)}
</Box>
{/* Collapse/Expand bar */}
<Box
onClick={() => setNavCollapsed((c) => !c)}
sx={{
height: "36px",
borderTop: "1px solid #333",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#888",
backgroundColor: "#121212",
"&:hover": { backgroundColor: "#1e1e1e" }
}}
>
{navCollapsed ? ">>" : "<<"}
</Box>
</Box>
{/* Content */}
<NavigationSidebar currentPage={currentPage} onNavigate={setCurrentPage} />
<Box sx={{ flexGrow: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{renderMainContent()}
</Box>
</Box>
<StatusBar />
</Box>
{/* Dialogs / Menus */}
<CloseAllDialog open={confirmCloseOpen} onClose={handleCloseDialog} onConfirm={handleConfirmCloseAll} />
<CloseAllDialog open={confirmCloseOpen} onClose={() => setConfirmCloseOpen(false)} onConfirm={() => {}} />
<CreditsDialog open={creditsDialogOpen} onClose={() => setCreditsDialogOpen(false)} />
<RenameTabDialog
open={renameDialogOpen}
value={renameValue}
onChange={setRenameValue}
onCancel={() => setRenameDialogOpen(false)}
onSave={handleRenameDialogSave}
onSave={() => {}}
/>
<TabContextMenu
anchor={tabMenuAnchor}
onClose={() => setTabMenuAnchor(null)}
onRename={() => {}}
onCloseTab={() => {}}
/>
<TabContextMenu anchor={tabMenuAnchor} onClose={handleCloseTabMenu} onRename={handleRenameTab} onCloseTab={handleCloseTab} />
</ThemeProvider>
);
}

View File

@@ -0,0 +1,165 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Device_List.jsx
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
Paper,
Box,
Typography,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TableSortLabel
} from "@mui/material";
function timeSince(tsSec) {
if (!tsSec) return "unknown";
const now = Date.now() / 1000;
const s = Math.max(0, Math.floor(now - tsSec));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${s % 60}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
function statusFromHeartbeat(tsSec, offlineAfter = 15) {
if (!tsSec) return "Offline";
const now = Date.now() / 1000;
return now - tsSec <= offlineAfter ? "Online" : "Offline";
}
export default function DeviceList() {
const [rows, setRows] = useState([]);
const [orderBy, setOrderBy] = useState("status");
const [order, setOrder] = useState("desc");
const fetchAgents = useCallback(async () => {
try {
const res = await fetch("/api/agents");
const data = await res.json();
const arr = Object.values(data || {}).map((a) => ({
hostname: a.hostname || a.agent_id || "unknown",
status: statusFromHeartbeat(a.last_seen),
lastSeen: a.last_seen || 0,
os: a.agent_operating_system || a.os || "-"
}));
setRows(arr);
} catch (e) {
console.warn("Failed to load agents:", e);
setRows([]);
}
}, []);
useEffect(() => {
fetchAgents();
const t = setInterval(fetchAgents, 5000);
return () => clearInterval(t);
}, [fetchAgents]);
const sorted = useMemo(() => {
const dir = order === "asc" ? 1 : -1;
return [...rows].sort((a, b) => {
const A = a[orderBy];
const B = b[orderBy];
if (orderBy === "lastSeen") return (A - B) * dir;
return String(A).localeCompare(String(B)) * dir;
});
}, [rows, orderBy, order]);
const handleSort = (col) => {
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
else {
setOrderBy(col);
setOrder("asc");
}
};
const statusColor = (s) => (s === "Online" ? "#00d18c" : "#ff4f4f");
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 }}>
Devices
</Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}>
Devices connected to Borealis via Agent and their recent heartbeats.
</Typography>
</Box>
<Table size="small" sx={{ minWidth: 680 }}>
<TableHead>
<TableRow>
<TableCell sortDirection={orderBy === "status" ? order : false}>
<TableSortLabel
active={orderBy === "status"}
direction={orderBy === "status" ? order : "asc"}
onClick={() => handleSort("status")}
>
Status
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "hostname" ? order : false}>
<TableSortLabel
active={orderBy === "hostname"}
direction={orderBy === "hostname" ? order : "asc"}
onClick={() => handleSort("hostname")}
>
Device Hostname
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "lastSeen" ? order : false}>
<TableSortLabel
active={orderBy === "lastSeen"}
direction={orderBy === "lastSeen" ? order : "asc"}
onClick={() => handleSort("lastSeen")}
>
Last Heartbeat
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "os" ? order : false}>
<TableSortLabel
active={orderBy === "os"}
direction={orderBy === "os" ? order : "asc"}
onClick={() => handleSort("os")}
>
OS
</TableSortLabel>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sorted.map((r, i) => (
<TableRow key={i} hover>
<TableCell>
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: 10,
background: statusColor(r.status),
marginRight: 8,
verticalAlign: "middle"
}}
/>
{r.status}
</TableCell>
<TableCell>{r.hostname}</TableCell>
<TableCell>{timeSince(r.lastSeen)}</TableCell>
<TableCell>{r.os}</TableCell>
</TableRow>
))}
{sorted.length === 0 && (
<TableRow>
<TableCell colSpan={4} sx={{ color: "#888" }}>
No agents connected.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Paper>
);
}

View File

@@ -0,0 +1,143 @@
////////// 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,
AutoAwesomeMosaic as WorkflowsIcon,
Code as ScriptIcon,
PeopleOutline as CommunityIcon
} from "@mui/icons-material";
export default function NavigationSidebar({ currentPage, onNavigate }) {
const [expandedNav, setExpandedNav] = useState({
devices: true,
filters: false,
automation: 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: "#ccc",
position: "relative",
bgcolor: active ? "#2a2a2a" : "transparent",
"&:hover": { bgcolor: "#2c2c2c" }
}}
>
<Box
sx={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: active ? 3 : 0,
bgcolor: "#58a6ff",
transition: "width 0.15s ease"
}}
/>
{icon && <Box sx={{ mr: 1, display: "flex", alignItems: "center" }}>{icon}</Box>}
<ListItemText primary={label} primaryTypographyProps={{ fontSize: "0.95rem" }} />
</ListItemButton>
);
};
return (
<Box
sx={{
width: 260,
bgcolor: "#121212",
borderRight: "1px solid #333",
display: "flex",
flexDirection: "column",
overflow: "hidden"
}}
>
<Box sx={{ flex: 1, overflowY: "auto" }}>
{/* Devices */}
<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={{ backgroundColor: "#2c2c2c", minHeight: "36px", "& .MuiAccordionSummary-content": { margin: 0 } }}
>
<Typography sx={{ fontSize: "0.9rem", color: "#0475c2" }}>
<b>Devices</b>
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
<NavItem icon={<DevicesIcon fontSize="small" />} label="Devices" pageKey="devices" />
</AccordionDetails>
</Accordion>
{/* Filters & Groups */}
<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={{ backgroundColor: "#2c2c2c", minHeight: "36px", "& .MuiAccordionSummary-content": { margin: 0 } }}
>
<Typography sx={{ fontSize: "0.9rem", color: "#0475c2" }}>
<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>
{/* Automation */}
<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={{ backgroundColor: "#2c2c2c", minHeight: "36px", "& .MuiAccordionSummary-content": { margin: 0 } }}
>
<Typography sx={{ fontSize: "0.9rem", color: "#0475c2" }}>
<b>Automation</b>
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
<NavItem icon={<JobsIcon fontSize="small" />} label="Scheduled Jobs" pageKey="jobs" />
<NavItem icon={<WorkflowsIcon fontSize="small" />} label="Workflows" pageKey="workflows" />
<NavItem icon={<ScriptIcon fontSize="small" />} label="Scripts" pageKey="scripts" />
<NavItem icon={<CommunityIcon fontSize="small" />} label="Community Nodes" pageKey="community" />
</AccordionDetails>
</Accordion>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,127 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Script_List.jsx
import React, { useState, useMemo } from "react";
import {
Paper,
Box,
Typography,
Button,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TableSortLabel
} from "@mui/material";
import { Code as ScriptIcon } from "@mui/icons-material";
export default function ScriptList() {
const [rows, setRows] = useState([]);
const [orderBy, setOrderBy] = useState("name");
const [order, setOrder] = useState("asc");
const handleSort = (col) => {
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
else {
setOrderBy(col);
setOrder("asc");
}
};
const sorted = useMemo(() => {
const dir = order === "asc" ? 1 : -1;
return [...rows].sort((a, b) => {
const A = a[orderBy] || "";
const B = b[orderBy] || "";
return String(A).localeCompare(String(B)) * dir;
});
}, [rows, orderBy, order]);
return (
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
<Box sx={{ p: 2, pb: 1, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Box>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
Scripts
</Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}>
List of available automation scripts.
</Typography>
</Box>
<Button
startIcon={<ScriptIcon />}
sx={{
color: "#58a6ff",
borderColor: "#58a6ff",
textTransform: "none",
border: "1px solid #58a6ff",
backgroundColor: "#1e1e1e",
"&:hover": { backgroundColor: "#1b1b1b" }
}}
onClick={() => alert("Create Script action")}
>
Create Script
</Button>
</Box>
<Table size="small" sx={{ minWidth: 680 }}>
<TableHead>
<TableRow>
<TableCell sortDirection={orderBy === "name" ? order : false}>
<TableSortLabel
active={orderBy === "name"}
direction={orderBy === "name" ? order : "asc"}
onClick={() => handleSort("name")}
>
Name
</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 === "category" ? order : false}>
<TableSortLabel
active={orderBy === "category"}
direction={orderBy === "category" ? order : "asc"}
onClick={() => handleSort("category")}
>
Category
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "lastEdited" ? order : false}>
<TableSortLabel
active={orderBy === "lastEdited"}
direction={orderBy === "lastEdited" ? order : "asc"}
onClick={() => handleSort("lastEdited")}
>
Last Edited
</TableSortLabel>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sorted.map((r, i) => (
<TableRow key={i} hover>
<TableCell>{r.name}</TableCell>
<TableCell>{r.description}</TableCell>
<TableCell>{r.category}</TableCell>
<TableCell>{r.lastEdited}</TableCell>
</TableRow>
))}
{sorted.length === 0 && (
<TableRow>
<TableCell colSpan={4} sx={{ color: "#888" }}>
No scripts found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Paper>
);
}

View File

@@ -0,0 +1,144 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Workflow_List.jsx
import React, { useState, useMemo } from "react";
import {
Paper,
Box,
Typography,
Button,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TableSortLabel
} from "@mui/material";
import { PlayCircle as PlayCircleIcon } from "@mui/icons-material";
export default function WorkflowList({ onOpenWorkflow }) {
const [rows, setRows] = useState([]);
const [orderBy, setOrderBy] = useState("name");
const [order, setOrder] = useState("asc");
const handleSort = (col) => {
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
else {
setOrderBy(col);
setOrder("asc");
}
};
const sorted = useMemo(() => {
const dir = order === "asc" ? 1 : -1;
return [...rows].sort((a, b) => {
const A = a[orderBy] || "";
const B = b[orderBy] || "";
return String(A).localeCompare(String(B)) * dir;
});
}, [rows, orderBy, order]);
const handleNewWorkflow = () => {
if (onOpenWorkflow) {
onOpenWorkflow(); // trigger App.jsx to open editor
}
};
const handleRowClick = (workflow) => {
if (onOpenWorkflow) {
onOpenWorkflow(workflow);
}
};
return (
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
<Box sx={{ p: 2, pb: 1, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Box>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
Workflows
</Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}>
List of available workflows.
</Typography>
</Box>
<Button
startIcon={<PlayCircleIcon />}
sx={{
color: "#58a6ff",
borderColor: "#58a6ff",
textTransform: "none",
border: "1px solid #58a6ff",
backgroundColor: "#1e1e1e",
"&:hover": { backgroundColor: "#1b1b1b" }
}}
onClick={handleNewWorkflow}
>
New Workflow
</Button>
</Box>
<Table size="small" sx={{ minWidth: 680 }}>
<TableHead>
<TableRow>
<TableCell sortDirection={orderBy === "name" ? order : false}>
<TableSortLabel
active={orderBy === "name"}
direction={orderBy === "name" ? order : "asc"}
onClick={() => handleSort("name")}
>
Name
</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 === "category" ? order : false}>
<TableSortLabel
active={orderBy === "category"}
direction={orderBy === "category" ? order : "asc"}
onClick={() => handleSort("category")}
>
Category
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "lastEdited" ? order : false}>
<TableSortLabel
active={orderBy === "lastEdited"}
direction={orderBy === "lastEdited" ? order : "asc"}
onClick={() => handleSort("lastEdited")}
>
Last Edited
</TableSortLabel>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sorted.map((r, i) => (
<TableRow
key={i}
hover
sx={{ cursor: "pointer" }}
onClick={() => handleRowClick(r)}
>
<TableCell>{r.name}</TableCell>
<TableCell>{r.description}</TableCell>
<TableCell>{r.category}</TableCell>
<TableCell>{r.lastEdited}</TableCell>
</TableRow>
))}
{sorted.length === 0 && (
<TableRow>
<TableCell colSpan={4} sx={{ color: "#888" }}>
No workflows found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Paper>
);
}