diff --git a/Data/Server/WebUI/src/App.jsx b/Data/Server/WebUI/src/App.jsx
index 5564ca7..3cd3200 100644
--- a/Data/Server/WebUI/src/App.jsx
+++ b/Data/Server/WebUI/src/App.jsx
@@ -5,7 +5,8 @@ import React, {
useState,
useEffect,
useCallback,
- useRef
+ useRef,
+ useMemo
} from "react";
// Material UI - Components
@@ -19,7 +20,21 @@ import {
Button,
CssBaseline,
ThemeProvider,
- createTheme
+ createTheme,
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ List,
+ ListItemButton,
+ ListItemText,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableRow,
+ TableSortLabel,
+ Paper,
+ Tooltip
} from "@mui/material";
// Material UI - Icons
@@ -27,7 +42,15 @@ import {
KeyboardArrowDown as KeyboardArrowDownIcon,
InfoOutlined as InfoOutlinedIcon,
MergeType as MergeTypeIcon,
- People as PeopleIcon
+ 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
} from "@mui/icons-material";
// React Flow
@@ -61,7 +84,7 @@ if (!window.BorealisUpdateRate) {
window.BorealisUpdateRate = 200;
}
-const modules = import.meta.glob('./nodes/**/*.jsx', { eager: true });
+const modules = import.meta.glob("./nodes/**/*.jsx", { eager: true });
const nodeTypes = {};
const categorizedNodes = {};
@@ -70,7 +93,7 @@ Object.entries(modules).forEach(([path, mod]) => {
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] = [];
@@ -109,17 +132,176 @@ 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 (
+
+
+
+ Devices
+
+
+ Connected agents and their recent heartbeat.
+
+
+
+
+
+
+ handleSort("status")}
+ >
+ Status
+
+
+
+ handleSort("hostname")}
+ >
+ Hostname
+
+
+
+ handleSort("lastSeen")}
+ >
+ Last Heartbeat
+
+
+
+ handleSort("os")}
+ >
+ OS
+
+
+
+
+
+ {sorted.map((r, i) => (
+
+
+
+ {r.status}
+
+ {r.hostname}
+ {timeSince(r.lastSeen)}
+ {r.os}
+
+ ))}
+ {sorted.length === 0 && (
+
+
+ No agents connected.
+
+
+ )}
+
+
+
+ );
+}
+
+// ---------- Main App ----------
export default function App() {
const [tabs, setTabs] = useState([
- {
- id: "flow_1",
- tab_name: "Flow 1",
- nodes: [],
- edges: []
- }
+ { id: "flow_1", tab_name: "Flow 1", nodes: [], edges: [] }
]);
const [activeTabId, setActiveTabId] = useState("flow_1");
+ // 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);
@@ -130,6 +312,7 @@ 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) {
@@ -187,6 +370,7 @@ export default function App() {
[activeTabId]
);
+ // app bar menu
const handleAboutMenuOpen = (event) => setAboutAnchorEl(event.currentTarget);
const handleAboutMenuClose = () => setAboutAnchorEl(null);
const openCreditsDialog = () => {
@@ -194,17 +378,11 @@ export default function App() {
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: []
- }
- ]);
+ setTabs([{ id: "flow_1", tab_name: "Flow 1", nodes: [], edges: [] }]);
setActiveTabId("flow_1");
setConfirmCloseOpen(false);
};
@@ -214,14 +392,10 @@ export default function App() {
const newId = "flow_" + nextIndex;
setTabs((old) => [
...old,
- {
- id: newId,
- tab_name: "Flow " + nextIndex,
- nodes: [],
- edges: []
- }
+ { id: newId, tab_name: "Flow " + nextIndex, nodes: [], edges: [] }
]);
setActiveTabId(newId);
+ setCurrentPage("workflow-editor");
};
const handleTabChange = (newActiveTabId) => {
@@ -276,9 +450,7 @@ export default function App() {
}
setTabs((old) =>
old.map((tab) =>
- tab.id === renameTabId
- ? { ...tab, tab_name: renameValue }
- : tab
+ tab.id === renameTabId ? { ...tab, tab_name: renameValue } : tab
)
);
setRenameDialogOpen(false);
@@ -303,12 +475,7 @@ export default function App() {
try {
const fileHandle = await window.showSaveFilePicker({
suggestedName: suggestedFilename,
- types: [
- {
- description: "Workflow JSON File",
- accept: { "application/json": [".json"] }
- }
- ]
+ types: [{ description: "Workflow JSON File", accept: { "application/json": [".json"] } }]
});
const writable = await fileHandle.createWritable();
await writable.write(blob);
@@ -332,12 +499,7 @@ export default function App() {
if (window.showOpenFilePicker) {
try {
const [fileHandle] = await window.showOpenFilePicker({
- types: [
- {
- description: "Workflow JSON File",
- accept: { "application/json": [".json"] }
- }
- ]
+ types: [{ description: "Workflow JSON File", accept: { "application/json": [".json"] } }]
});
const file = await fileHandle.getFile();
const text = await file.text();
@@ -353,6 +515,7 @@ export default function App() {
}
]);
setActiveTabId(newId);
+ setCurrentPage("workflow-editor");
} catch (err) {
console.error("Import cancelled or failed:", err);
}
@@ -378,38 +541,16 @@ export default function App() {
}
]);
setActiveTabId(newId);
+ setCurrentPage("workflow-editor");
} catch (err) {
console.error("Failed to read file:", err);
}
};
- return (
-
-
-
-
-
-
-
- }
- startIcon={}
- sx={{ height: "36px" }}
- >
- About
-
-
-
-
+ // ---------- Main Content ----------
+ const renderMainContent = () => {
+ if (currentPage === "workflow-editor") {
+ return (
+ );
+ }
+ if (currentPage === "devices") {
+ return ;
+ }
+ return (
+
+ Select a section from navigation.
+
+ );
+ };
+
+ // ---------- Nav helpers ----------
+ const NavItem = ({ icon, label, pageKey, indent = 0, onClick }) => {
+ const active = currentPage === pageKey;
+ return (
+ setCurrentPage(pageKey))}
+ sx={{
+ pl: indent ? 4 : 2,
+ py: 1,
+ color: "#ccc",
+ position: "relative",
+ bgcolor: active ? "#2a2a2a" : "transparent",
+ "&:hover": { bgcolor: "#2c2c2c" }
+ }}
+ >
+ {/* left accent when active */}
+
+ {icon && {icon}}
+
+
+ );
+ };
+
+ return (
+
+
+
+
+
+
+
+ }
+ startIcon={}
+ sx={{ height: "36px" }}
+ >
+ About
+
+
+
+
+
+ {/* Main area with new wider nav styled like Node Sidebar */}
+
+ {/* Navigation Sidebar */}
+
+
+ {!navCollapsed && (
+ <>
+ {/* Devices */}
+ setExpandedNav((s) => ({ ...s, devices: e }))}
+ square
+ disableGutters
+ sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
+ >
+ }
+ sx={{ backgroundColor: "#2c2c2c", minHeight: "36px", "& .MuiAccordionSummary-content": { margin: 0 } }}
+ >
+
+ Devices
+
+
+
+ } label="Devices" pageKey="devices" />
+
+
+
+ {/* Filters & Groups */}
+ setExpandedNav((s) => ({ ...s, filters: e }))}
+ square
+ disableGutters
+ sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
+ >
+ }
+ sx={{ backgroundColor: "#2c2c2c", minHeight: "36px", "& .MuiAccordionSummary-content": { margin: 0 } }}
+ >
+
+ Filters & Groups
+
+
+
+ } label="Filters" pageKey="filters" />
+ } label="Groups" pageKey="groups" />
+
+
+
+ {/* Automation */}
+ setExpandedNav((s) => ({ ...s, automation: e }))}
+ square
+ disableGutters
+ sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
+ >
+ }
+ sx={{ backgroundColor: "#2c2c2c", minHeight: "36px", "& .MuiAccordionSummary-content": { margin: 0 } }}
+ >
+
+ Automation
+
+
+
+ } label="Jobs" pageKey="jobs" />
+ }
+ label="Workflows"
+ pageKey="workflow-editor"
+ onClick={() => setCurrentPage("workflow-editor")}
+ />
+
+
+ }
+ sx={{
+ mt: 1,
+ color: "#58a6ff",
+ borderColor: "#58a6ff",
+ textTransform: "none",
+ border: "1px solid #58a6ff",
+ backgroundColor: "#1e1e1e",
+ "&:hover": { backgroundColor: "#1b1b1b" }
+ }}
+ >
+ New Workflow
+
+
+
+ } label="Community Nodes" pageKey="community" />
+
+
+
+ {/* Hidden file input for import */}
+
+ >
+ )}
+
+
+ {/* Collapse/Expand bar */}
+ 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 ? ">>" : "<<"}
+
+
+
+ {/* Content */}
+
+ {renderMainContent()}
+
+
+
-
+
+ {/* Dialogs / Menus */}
+
setCreditsDialogOpen(false)} />
setRenameDialogOpen(false)}
onSave={handleRenameDialogSave}
/>
-
+
);
}