Modifications to Navigation Sidebar

This commit is contained in:
2025-08-07 20:47:15 -06:00
parent 10c1b5bfe4
commit 3c886652af

View File

@@ -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 (
<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: []
}
{ 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 (
<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" }}>
<Box component="img" src="/Borealis_Logo_Full.png" alt="Borealis Logo" sx={{ height: "52px", marginRight: "8px" }} />
<Typography variant="h6" sx={{ flexGrow: 1, fontSize: "1rem" }}></Typography>
<Button
color="inherit"
onClick={handleAboutMenuOpen}
endIcon={<KeyboardArrowDownIcon />}
startIcon={<InfoOutlinedIcon />}
sx={{ height: "36px" }}
>
About
</Button>
<Menu anchorEl={aboutAnchorEl} open={Boolean(aboutAnchorEl)} onClose={handleAboutMenuClose}>
<MenuItem onClick={() => { handleAboutMenuClose(); window.open("https://git.bunny-lab.io/bunny-lab/Borealis", "_blank"); }}>
<MergeTypeIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} /> Gitea Project
</MenuItem>
<MenuItem onClick={openCreditsDialog}>
<PeopleIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} /> Credits
</MenuItem>
</Menu>
</Toolbar>
</AppBar>
// ---------- Main Content ----------
const renderMainContent = () => {
if (currentPage === "workflow-editor") {
return (
<Box sx={{ display: "flex", flexGrow: 1, overflow: "hidden" }}>
<NodeSidebar
categorizedNodes={categorizedNodes}
@@ -456,13 +597,228 @@ export default function App() {
</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>
);
};
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" }}>
<Box component="img" src="/Borealis_Logo_Full.png" alt="Borealis Logo" sx={{ height: "52px", marginRight: "8px" }} />
<Typography variant="h6" sx={{ flexGrow: 1, fontSize: "1rem" }}></Typography>
<Button
color="inherit"
onClick={handleAboutMenuOpen}
endIcon={<KeyboardArrowDownIcon />}
startIcon={<InfoOutlinedIcon />}
sx={{ height: "36px" }}
>
About
</Button>
<Menu anchorEl={aboutAnchorEl} open={Boolean(aboutAnchorEl)} onClose={handleAboutMenuClose}>
<MenuItem onClick={() => { handleAboutMenuClose(); window.open("https://git.bunny-lab.io/bunny-lab/Borealis", "_blank"); }}>
<MergeTypeIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} /> Gitea Project
</MenuItem>
<MenuItem onClick={openCreditsDialog}>
<PeopleIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} /> Credits
</MenuItem>
</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 */}
<Box sx={{ flexGrow: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{renderMainContent()}
</Box>
</Box>
<StatusBar />
</Box>
<CloseAllDialog
open={confirmCloseOpen}
onClose={handleCloseDialog}
onConfirm={handleConfirmCloseAll}
/>
{/* Dialogs / Menus */}
<CloseAllDialog open={confirmCloseOpen} onClose={handleCloseDialog} onConfirm={handleConfirmCloseAll} />
<CreditsDialog open={creditsDialogOpen} onClose={() => setCreditsDialogOpen(false)} />
<RenameTabDialog
open={renameDialogOpen}
@@ -471,12 +827,7 @@ export default function App() {
onCancel={() => setRenameDialogOpen(false)}
onSave={handleRenameDialogSave}
/>
<TabContextMenu
anchor={tabMenuAnchor}
onClose={handleCloseTabMenu}
onRename={handleRenameTab}
onCloseTab={handleCloseTab}
/>
<TabContextMenu anchor={tabMenuAnchor} onClose={handleCloseTabMenu} onRename={handleRenameTab} onCloseTab={handleCloseTab} />
</ThemeProvider>
);
}