Files
Borealis-Github-Replica/Data/Server/WebUI/src/App.jsx

442 lines
15 KiB
JavaScript

////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/App.jsx
import React, { useState, useEffect, useCallback, useRef } from "react";
import {
AppBar, Toolbar, Typography, Box, Menu, MenuItem, Button,
CssBaseline, ThemeProvider, createTheme
} from "@mui/material";
import {
KeyboardArrowDown as KeyboardArrowDownIcon,
InfoOutlined as InfoOutlinedIcon,
MergeType as MergeTypeIcon,
People as PeopleIcon
} from "@mui/icons-material";
import { ReactFlowProvider } from "reactflow";
import "reactflow/dist/style.css";
import FlowTabs from "./Flow_Tabs";
import FlowEditor from "./Flow_Editor";
import NodeSidebar from "./Node_Sidebar";
import {
CloseAllDialog, CreditsDialog, RenameTabDialog, TabContextMenu
} from "./Dialogs";
import StatusBar from "./Status_Bar";
// 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 ScheduledJobsList from "./Scheduled_Jobs_List";
import { io } from "socket.io-client";
if (!window.BorealisSocket) {
window.BorealisSocket = io(window.location.origin, { transports: ["websocket"] });
}
if (!window.BorealisUpdateRate) {
window.BorealisUpdateRate = 200;
}
// Load node modules dynamically
const modules = import.meta.glob('./nodes/**/*.jsx', { eager: true });
const nodeTypes = {};
const categorizedNodes = {};
Object.entries(modules).forEach(([path, mod]) => {
const comp = mod.default;
if (!comp) return;
const { type, component } = comp;
if (!type || !component) return;
const parts = path.replace('./nodes/', '').split('/');
const category = parts[0];
if (!categorizedNodes[category]) categorizedNodes[category] = [];
categorizedNodes[category].push(comp);
nodeTypes[type] = component;
});
const darkTheme = createTheme({
palette: {
mode: "dark",
background: { default: "#121212", paper: "#1e1e1e" },
text: { primary: "#ffffff" }
},
components: {
MuiTooltip: {
styleOverrides: {
tooltip: { backgroundColor: "#2a2a2a", color: "#ccc", fontSize: "0.75rem", border: "1px solid #444" },
arrow: { color: "#2a2a2a" }
}
}
}
});
const LOCAL_STORAGE_KEY = "borealis_persistent_state";
export default function App() {
const [tabs, setTabs] = useState([{ id: "flow_1", tab_name: "Flow 1", nodes: [], edges: [] }]);
const [activeTabId, setActiveTabId] = useState("flow_1");
const [currentPage, setCurrentPage] = useState("devices");
const [aboutAnchorEl, setAboutAnchorEl] = useState(null);
const [creditsDialogOpen, setCreditsDialogOpen] = useState(false);
const [confirmCloseOpen, setConfirmCloseOpen] = useState(false);
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const [renameTabId, setRenameTabId] = useState(null);
const [renameValue, setRenameValue] = useState("");
const [tabMenuAnchor, setTabMenuAnchor] = useState(null);
const [tabMenuTabId, setTabMenuTabId] = useState(null);
const fileInputRef = useRef(null);
useEffect(() => {
const saved = localStorage.getItem(LOCAL_STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed.tabs) && parsed.activeTabId) {
setTabs(parsed.tabs);
setActiveTabId(parsed.activeTabId);
}
} catch (err) {
console.warn("Failed to parse saved state:", err);
}
}
}, []);
useEffect(() => {
const timeout = setTimeout(() => {
const data = JSON.stringify({ tabs, activeTabId });
localStorage.setItem(LOCAL_STORAGE_KEY, data);
}, 1000);
return () => clearTimeout(timeout);
}, [tabs, activeTabId]);
const handleSetNodes = useCallback((callbackOrArray, tId) => {
const targetId = tId || activeTabId;
setTabs((old) =>
old.map((tab) =>
tab.id === targetId
? { ...tab, nodes: typeof callbackOrArray === "function" ? callbackOrArray(tab.nodes) : callbackOrArray }
: tab
)
);
}, [activeTabId]);
const handleSetEdges = useCallback((callbackOrArray, tId) => {
const targetId = tId || activeTabId;
setTabs((old) =>
old.map((tab) =>
tab.id === targetId
? { ...tab, edges: typeof callbackOrArray === "function" ? callbackOrArray(tab.edges) : callbackOrArray }
: tab
)
);
}, [activeTabId]);
const handleAboutMenuOpen = (event) => setAboutAnchorEl(event.currentTarget);
const handleAboutMenuClose = () => setAboutAnchorEl(null);
const openCreditsDialog = () => { handleAboutMenuClose(); setCreditsDialogOpen(true); };
const handleTabRightClick = (evt, tabId) => {
evt.preventDefault();
setTabMenuAnchor({ x: evt.clientX, y: evt.clientY });
setTabMenuTabId(tabId);
};
const handleCloseTab = () => {
setTabs((prev) => {
const filtered = prev.filter((t) => t.id !== tabMenuTabId);
if (filtered.length === 0) {
const newTab = { id: "flow_1", tab_name: "Flow 1", nodes: [], edges: [] };
setActiveTabId(newTab.id);
return [newTab];
}
if (activeTabId === tabMenuTabId) {
setActiveTabId(filtered[0].id);
}
return filtered;
});
setTabMenuAnchor(null);
};
const handleRenameTab = () => {
const tab = tabs.find((t) => t.id === tabMenuTabId);
if (tab) {
setRenameTabId(tabMenuTabId);
setRenameValue(tab.tab_name);
setRenameDialogOpen(true);
}
setTabMenuAnchor(null);
};
const handleSaveRename = () => {
setTabs((prev) =>
prev.map((t) => (t.id === renameTabId ? { ...t, tab_name: renameValue } : t))
);
setRenameDialogOpen(false);
};
const handleExportFlow = useCallback(() => {
const tab = tabs.find((t) => t.id === activeTabId);
if (!tab) return;
const payload = {
tab_name: tab.tab_name,
nodes: tab.nodes,
edges: tab.edges
};
const fileName = `${tab.tab_name || "workflow"}.json`;
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}, [tabs, activeTabId]);
const handleImportFlow = useCallback(() => {
if (fileInputRef.current) {
fileInputRef.current.value = null;
fileInputRef.current.click();
}
}, []);
const onFileInputChange = useCallback(
(e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const data = JSON.parse(reader.result);
const newId = "flow_" + Date.now();
setTabs((prev) => [
...prev,
{
id: newId,
tab_name:
data.tab_name || data.name || file.name.replace(/\.json$/i, ""),
nodes: data.nodes || [],
edges: data.edges || []
}
]);
setActiveTabId(newId);
setCurrentPage("workflow-editor");
} catch (err) {
console.error("Failed to import workflow:", err);
}
};
reader.readAsText(file);
e.target.value = "";
},
[setTabs]
);
const handleSaveFlow = useCallback(
async (name) => {
const tab = tabs.find((t) => t.id === activeTabId);
if (!tab || !name) return;
const payload = {
path: tab.folderPath ? `${tab.folderPath}/${name}` : name,
workflow: {
tab_name: tab.tab_name,
nodes: tab.nodes,
edges: tab.edges
}
};
try {
await fetch("/api/storage/save_workflow", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
setTabs((prev) =>
prev.map((t) => (t.id === activeTabId ? { ...t, tab_name: name } : t))
);
} catch (err) {
console.error("Failed to save workflow:", err);
}
},
[tabs, activeTabId]
);
const renderMainContent = () => {
switch (currentPage) {
case "devices":
return <DeviceList />;
case "jobs":
return <ScheduledJobsList />;
case "workflows":
return (
<WorkflowList
onOpenWorkflow={async (workflow, folderPath, name) => {
const newId = "flow_" + Date.now();
if (workflow && workflow.rel_path) {
const folder = workflow.rel_path
.split("/")
.slice(0, -1)
.join("/");
try {
const resp = await fetch(
`/api/storage/load_workflow?path=${encodeURIComponent(
workflow.rel_path
)}`
);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
setTabs([
{
id: newId,
tab_name:
data.tab_name || workflow.name || workflow.file_name || "Workflow",
nodes: data.nodes || [],
edges: data.edges || [],
folderPath: folder
}
]);
} catch (err) {
console.error("Failed to load workflow:", err);
setTabs([
{
id: newId,
tab_name: workflow?.name || "Workflow",
nodes: [],
edges: [],
folderPath: folder
}
]);
}
} else {
setTabs([
{
id: newId,
tab_name: name || "Flow",
nodes: [],
edges: [],
folderPath: folderPath || ""
}
]);
}
setActiveTabId(newId);
setCurrentPage("workflow-editor");
}}
/>
);
case "scripts":
return <ScriptList />;
case "workflow-editor":
return (
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 1, overflow: "hidden" }}>
<Box sx={{ display: "flex", flexGrow: 1, overflow: "hidden" }}>
<NodeSidebar
categorizedNodes={categorizedNodes}
handleExportFlow={handleExportFlow}
handleImportFlow={handleImportFlow}
handleSaveFlow={handleSaveFlow}
handleOpenCloseAllDialog={() => setConfirmCloseOpen(true)}
fileInputRef={fileInputRef}
onFileInputChange={onFileInputChange}
currentTabName={tabs.find((t) => t.id === activeTabId)?.tab_name}
/>
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 1, overflow: "hidden" }}>
<FlowTabs
tabs={tabs}
activeTabId={activeTabId}
onTabChange={setActiveTabId}
onAddTab={() => {}}
onTabRightClick={handleTabRightClick}
/>
<Box sx={{ flexGrow: 1, position: "relative" }}>
{tabs.map((tab) => (
<Box
key={tab.id}
sx={{
position: "absolute", top: 0, bottom: 0, left: 0, right: 0,
display: tab.id === activeTabId ? "block" : "none"
}}
>
<ReactFlowProvider id={tab.id}>
<FlowEditor
flowId={tab.id}
nodes={tab.nodes}
edges={tab.edges}
setNodes={(val) => handleSetNodes(val, tab.id)}
setEdges={(val) => handleSetEdges(val, tab.id)}
nodeTypes={nodeTypes}
categorizedNodes={categorizedNodes}
/>
</ReactFlowProvider>
</Box>
))}
</Box>
</Box>
</Box>
<StatusBar />
</Box>
);
default:
return (
<Box sx={{ p: 2 }}>
<Typography>Select a section from navigation.</Typography>
</Box>
);
}
};
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>
<Box sx={{ display: "flex", flexGrow: 1, overflow: "hidden" }}>
<NavigationSidebar currentPage={currentPage} onNavigate={setCurrentPage} />
<Box sx={{ flexGrow: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{renderMainContent()}
</Box>
</Box>
</Box>
<CloseAllDialog open={confirmCloseOpen} onClose={() => setConfirmCloseOpen(false)} onConfirm={() => {}} />
<CreditsDialog open={creditsDialogOpen} onClose={() => setCreditsDialogOpen(false)} />
<RenameTabDialog
open={renameDialogOpen}
value={renameValue}
onChange={setRenameValue}
onCancel={() => setRenameDialogOpen(false)}
onSave={handleSaveRename}
/>
<TabContextMenu
anchor={tabMenuAnchor}
onClose={() => setTabMenuAnchor(null)}
onRename={handleRenameTab}
onCloseTab={handleCloseTab}
/>
</ThemeProvider>
);
}