From 3c886652af4f6b3a932a977b710cd9b031b956e9 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Thu, 7 Aug 2025 20:47:15 -0600 Subject: [PATCH] Modifications to Navigation Sidebar --- Data/Server/WebUI/src/App.jsx | 507 ++++++++++++++++++++++++++++------ 1 file changed, 429 insertions(+), 78 deletions(-) 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 ( - - - - - - - - - - { handleAboutMenuClose(); window.open("https://git.bunny-lab.io/bunny-lab/Borealis", "_blank"); }}> - Gitea Project - - - Credits - - - - + // ---------- 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 ( + + + + + + + + + + { handleAboutMenuClose(); window.open("https://git.bunny-lab.io/bunny-lab/Borealis", "_blank"); }}> + Gitea Project + + + Credits + + + + + + {/* 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")} + /> + + + + + + } 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} /> - + ); }