mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-09-11 06:48:43 -06:00
Added Device, Script,and Workflow Tabs in new Navigation Sidebar
This commit is contained in:
@@ -1,103 +1,54 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/App.jsx
|
////////// 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 } from "react";
|
||||||
import React, {
|
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
useCallback,
|
|
||||||
useRef,
|
|
||||||
useMemo
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
// Material UI - Components
|
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar, Toolbar, Typography, Box, Menu, MenuItem, Button,
|
||||||
Toolbar,
|
CssBaseline, ThemeProvider, createTheme
|
||||||
Typography,
|
|
||||||
Box,
|
|
||||||
Menu,
|
|
||||||
MenuItem,
|
|
||||||
Button,
|
|
||||||
CssBaseline,
|
|
||||||
ThemeProvider,
|
|
||||||
createTheme,
|
|
||||||
Accordion,
|
|
||||||
AccordionSummary,
|
|
||||||
AccordionDetails,
|
|
||||||
List,
|
|
||||||
ListItemButton,
|
|
||||||
ListItemText,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TableSortLabel,
|
|
||||||
Paper,
|
|
||||||
Tooltip
|
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
|
||||||
// Material UI - Icons
|
|
||||||
import {
|
import {
|
||||||
KeyboardArrowDown as KeyboardArrowDownIcon,
|
KeyboardArrowDown as KeyboardArrowDownIcon,
|
||||||
InfoOutlined as InfoOutlinedIcon,
|
InfoOutlined as InfoOutlinedIcon,
|
||||||
MergeType as MergeTypeIcon,
|
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";
|
} from "@mui/icons-material";
|
||||||
|
|
||||||
// React Flow
|
|
||||||
import { ReactFlowProvider } from "reactflow";
|
import { ReactFlowProvider } from "reactflow";
|
||||||
|
|
||||||
// Styles
|
|
||||||
import "reactflow/dist/style.css";
|
import "reactflow/dist/style.css";
|
||||||
|
|
||||||
// Import Borealis Modules
|
|
||||||
import FlowTabs from "./Flow_Tabs";
|
import FlowTabs from "./Flow_Tabs";
|
||||||
import FlowEditor from "./Flow_Editor";
|
import FlowEditor from "./Flow_Editor";
|
||||||
import NodeSidebar from "./Node_Sidebar";
|
import NodeSidebar from "./Node_Sidebar";
|
||||||
import {
|
import {
|
||||||
CloseAllDialog,
|
CloseAllDialog, CreditsDialog, RenameTabDialog, TabContextMenu
|
||||||
CreditsDialog,
|
|
||||||
RenameTabDialog,
|
|
||||||
TabContextMenu
|
|
||||||
} from "./Dialogs";
|
} from "./Dialogs";
|
||||||
import StatusBar from "./Status_Bar";
|
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";
|
import { io } from "socket.io-client";
|
||||||
|
|
||||||
if (!window.BorealisSocket) {
|
if (!window.BorealisSocket) {
|
||||||
window.BorealisSocket = io(window.location.origin, {
|
window.BorealisSocket = io(window.location.origin, { transports: ["websocket"] });
|
||||||
transports: ["websocket"]
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!window.BorealisUpdateRate) {
|
if (!window.BorealisUpdateRate) {
|
||||||
window.BorealisUpdateRate = 200;
|
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 nodeTypes = {};
|
||||||
const categorizedNodes = {};
|
const categorizedNodes = {};
|
||||||
|
|
||||||
Object.entries(modules).forEach(([path, mod]) => {
|
Object.entries(modules).forEach(([path, mod]) => {
|
||||||
const comp = mod.default;
|
const comp = mod.default;
|
||||||
if (!comp) return;
|
if (!comp) return;
|
||||||
const { type, component } = comp;
|
const { type, component } = comp;
|
||||||
if (!type || !component) return;
|
if (!type || !component) return;
|
||||||
const parts = path.replace("./nodes/", "").split("/");
|
const parts = path.replace('./nodes/', '').split('/');
|
||||||
const category = parts[0];
|
const category = parts[0];
|
||||||
if (!categorizedNodes[category]) {
|
if (!categorizedNodes[category]) categorizedNodes[category] = [];
|
||||||
categorizedNodes[category] = [];
|
|
||||||
}
|
|
||||||
categorizedNodes[category].push(comp);
|
categorizedNodes[category].push(comp);
|
||||||
nodeTypes[type] = component;
|
nodeTypes[type] = component;
|
||||||
});
|
});
|
||||||
@@ -105,26 +56,14 @@ Object.entries(modules).forEach(([path, mod]) => {
|
|||||||
const darkTheme = createTheme({
|
const darkTheme = createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
mode: "dark",
|
mode: "dark",
|
||||||
background: {
|
background: { default: "#121212", paper: "#1e1e1e" },
|
||||||
default: "#121212",
|
text: { primary: "#ffffff" }
|
||||||
paper: "#1e1e1e"
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
primary: "#ffffff"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
MuiTooltip: {
|
MuiTooltip: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
tooltip: {
|
tooltip: { backgroundColor: "#2a2a2a", color: "#ccc", fontSize: "0.75rem", border: "1px solid #444" },
|
||||||
backgroundColor: "#2a2a2a",
|
arrow: { color: "#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";
|
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() {
|
export default function App() {
|
||||||
const [tabs, setTabs] = useState([
|
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");
|
const [activeTabId, setActiveTabId] = useState("flow_1");
|
||||||
const [currentPage, setCurrentPage] = useState("jobs");
|
|
||||||
|
|
||||||
// navigation state
|
|
||||||
const [currentPage, setCurrentPage] = useState("devices");
|
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 [aboutAnchorEl, setAboutAnchorEl] = useState(null);
|
||||||
const [creditsDialogOpen, setCreditsDialogOpen] = useState(false);
|
const [creditsDialogOpen, setCreditsDialogOpen] = useState(false);
|
||||||
const [confirmCloseOpen, setConfirmCloseOpen] = useState(false);
|
const [confirmCloseOpen, setConfirmCloseOpen] = useState(false);
|
||||||
@@ -313,7 +86,6 @@ export default function App() {
|
|||||||
const [tabMenuTabId, setTabMenuTabId] = useState(null);
|
const [tabMenuTabId, setTabMenuTabId] = useState(null);
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
// persist tabs
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = localStorage.getItem(LOCAL_STORAGE_KEY);
|
const saved = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
@@ -337,310 +109,125 @@ export default function App() {
|
|||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
}, [tabs, activeTabId]);
|
}, [tabs, activeTabId]);
|
||||||
|
|
||||||
const handleSetNodes = useCallback(
|
const handleSetNodes = useCallback((callbackOrArray, tId) => {
|
||||||
(callbackOrArray, tId) => {
|
const targetId = tId || activeTabId;
|
||||||
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;
|
|
||||||
}
|
|
||||||
setTabs((old) =>
|
setTabs((old) =>
|
||||||
old.map((tab) =>
|
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 handleSetEdges = useCallback((callbackOrArray, tId) => {
|
||||||
const activeTab = tabs.find((x) => x.id === activeTabId);
|
const targetId = tId || activeTabId;
|
||||||
if (!activeTab) return;
|
setTabs((old) =>
|
||||||
const data = JSON.stringify(
|
old.map((tab) =>
|
||||||
{
|
tab.id === targetId
|
||||||
nodes: activeTab.nodes,
|
? { ...tab, edges: typeof callbackOrArray === "function" ? callbackOrArray(tab.edges) : callbackOrArray }
|
||||||
edges: activeTab.edges,
|
: tab
|
||||||
tab_name: activeTab.tab_name
|
)
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
);
|
);
|
||||||
const blob = new Blob([data], { type: "application/json" });
|
}, [activeTabId]);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImportFlow = async () => {
|
const handleAboutMenuOpen = (event) => setAboutAnchorEl(event.currentTarget);
|
||||||
if (window.showOpenFilePicker) {
|
const handleAboutMenuClose = () => setAboutAnchorEl(null);
|
||||||
try {
|
const openCreditsDialog = () => { handleAboutMenuClose(); setCreditsDialogOpen(true); };
|
||||||
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 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 = () => {
|
const renderMainContent = () => {
|
||||||
if (currentPage === "workflow-editor") {
|
switch (currentPage) {
|
||||||
return (
|
case "devices":
|
||||||
<Box sx={{ display: "flex", flexGrow: 1, overflow: "hidden" }}>
|
return <DeviceList />;
|
||||||
<NodeSidebar
|
|
||||||
categorizedNodes={categorizedNodes}
|
case "workflows":
|
||||||
handleExportFlow={handleExportFlow}
|
return (
|
||||||
handleImportFlow={handleImportFlow}
|
<WorkflowList
|
||||||
handleOpenCloseAllDialog={handleOpenCloseAllDialog}
|
onOpenWorkflow={(workflow) => {
|
||||||
fileInputRef={fileInputRef}
|
// If workflow name exists in tabs, just switch to it
|
||||||
onFileInputChange={handleFileInputChange}
|
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}
|
case "scripts":
|
||||||
activeTabId={activeTabId}
|
return <ScriptList />;
|
||||||
onTabChange={handleTabChange}
|
|
||||||
onAddTab={createNewTab}
|
case "workflow-editor":
|
||||||
onTabRightClick={handleTabRightClick}
|
return (
|
||||||
|
<Box sx={{ display: "flex", flexGrow: 1, overflow: "hidden" }}>
|
||||||
|
<NodeSidebar
|
||||||
|
categorizedNodes={categorizedNodes}
|
||||||
|
handleExportFlow={() => {}}
|
||||||
|
handleImportFlow={() => {}}
|
||||||
|
handleOpenCloseAllDialog={() => {}}
|
||||||
|
fileInputRef={fileInputRef}
|
||||||
|
onFileInputChange={() => {}}
|
||||||
/>
|
/>
|
||||||
<Box sx={{ flexGrow: 1, position: "relative" }}>
|
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 1, overflow: "hidden" }}>
|
||||||
{tabs.map((tab) => (
|
<FlowTabs
|
||||||
<Box
|
tabs={tabs}
|
||||||
key={tab.id}
|
activeTabId={activeTabId}
|
||||||
sx={{
|
onTabChange={setActiveTabId}
|
||||||
position: "absolute",
|
onAddTab={() => {}}
|
||||||
top: 0,
|
onTabRightClick={() => {}}
|
||||||
bottom: 0,
|
/>
|
||||||
left: 0,
|
<Box sx={{ flexGrow: 1, position: "relative" }}>
|
||||||
right: 0,
|
{tabs.map((tab) => (
|
||||||
display: tab.id === activeTabId ? "block" : "none"
|
<Box
|
||||||
}}
|
key={tab.id}
|
||||||
>
|
sx={{
|
||||||
<ReactFlowProvider id={tab.id}>
|
position: "absolute", top: 0, bottom: 0, left: 0, right: 0,
|
||||||
<FlowEditor
|
display: tab.id === activeTabId ? "block" : "none"
|
||||||
flowId={tab.id}
|
}}
|
||||||
nodes={tab.nodes}
|
>
|
||||||
edges={tab.edges}
|
<ReactFlowProvider id={tab.id}>
|
||||||
setNodes={(val) => handleSetNodes(val, tab.id)}
|
<FlowEditor
|
||||||
setEdges={(val) => handleSetEdges(val, tab.id)}
|
flowId={tab.id}
|
||||||
nodeTypes={nodeTypes}
|
nodes={tab.nodes}
|
||||||
categorizedNodes={categorizedNodes}
|
edges={tab.edges}
|
||||||
/>
|
setNodes={(val) => handleSetNodes(val, tab.id)}
|
||||||
</ReactFlowProvider>
|
setEdges={(val) => handleSetEdges(val, tab.id)}
|
||||||
</Box>
|
nodeTypes={nodeTypes}
|
||||||
))}
|
categorizedNodes={categorizedNodes}
|
||||||
|
/>
|
||||||
|
</ReactFlowProvider>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
</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 ----------
|
default:
|
||||||
const NavItem = ({ icon, label, pageKey, indent = 0, onClick }) => {
|
return (
|
||||||
const active = currentPage === pageKey;
|
<Box sx={{ p: 2 }}>
|
||||||
return (
|
<Typography>Select a section from navigation.</Typography>
|
||||||
<ListItemButton
|
</Box>
|
||||||
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 (
|
return (
|
||||||
@@ -670,164 +257,29 @@ export default function App() {
|
|||||||
</Menu>
|
</Menu>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|
||||||
{/* Main area with new wider nav styled like Node Sidebar */}
|
|
||||||
<Box sx={{ display: "flex", flexGrow: 1, overflow: "hidden" }}>
|
<Box sx={{ display: "flex", flexGrow: 1, overflow: "hidden" }}>
|
||||||
{/* Navigation Sidebar */}
|
<NavigationSidebar currentPage={currentPage} onNavigate={setCurrentPage} />
|
||||||
<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" }}>
|
<Box sx={{ flexGrow: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||||
{renderMainContent()}
|
{renderMainContent()}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
</Box>
|
</Box>
|
||||||
|
<CloseAllDialog open={confirmCloseOpen} onClose={() => setConfirmCloseOpen(false)} onConfirm={() => {}} />
|
||||||
{/* Dialogs / Menus */}
|
|
||||||
<CloseAllDialog open={confirmCloseOpen} onClose={handleCloseDialog} onConfirm={handleConfirmCloseAll} />
|
|
||||||
<CreditsDialog open={creditsDialogOpen} onClose={() => setCreditsDialogOpen(false)} />
|
<CreditsDialog open={creditsDialogOpen} onClose={() => setCreditsDialogOpen(false)} />
|
||||||
<RenameTabDialog
|
<RenameTabDialog
|
||||||
open={renameDialogOpen}
|
open={renameDialogOpen}
|
||||||
value={renameValue}
|
value={renameValue}
|
||||||
onChange={setRenameValue}
|
onChange={setRenameValue}
|
||||||
onCancel={() => setRenameDialogOpen(false)}
|
onCancel={() => setRenameDialogOpen(false)}
|
||||||
onSave={handleRenameDialogSave}
|
onSave={() => {}}
|
||||||
|
/>
|
||||||
|
<TabContextMenu
|
||||||
|
anchor={tabMenuAnchor}
|
||||||
|
onClose={() => setTabMenuAnchor(null)}
|
||||||
|
onRename={() => {}}
|
||||||
|
onCloseTab={() => {}}
|
||||||
/>
|
/>
|
||||||
<TabContextMenu anchor={tabMenuAnchor} onClose={handleCloseTabMenu} onRename={handleRenameTab} onCloseTab={handleCloseTab} />
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
165
Data/Server/WebUI/src/Device_List.jsx
Normal file
165
Data/Server/WebUI/src/Device_List.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
143
Data/Server/WebUI/src/Navigation_Sidebar.jsx
Normal file
143
Data/Server/WebUI/src/Navigation_Sidebar.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
127
Data/Server/WebUI/src/Script_List.jsx
Normal file
127
Data/Server/WebUI/src/Script_List.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
144
Data/Server/WebUI/src/Workflow_List.jsx
Normal file
144
Data/Server/WebUI/src/Workflow_List.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
Reference in New Issue
Block a user