mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-09-11 07:08:42 -06:00
Enable workflow import/export and rename
This commit is contained in:
@@ -175,6 +175,86 @@ export default function App() {
|
|||||||
setRenameDialogOpen(false);
|
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 () => {
|
||||||
|
const tab = tabs.find((t) => t.id === activeTabId);
|
||||||
|
if (!tab) return;
|
||||||
|
const name = window.prompt("Enter workflow name", tab.tab_name || "workflow");
|
||||||
|
if (!name) return;
|
||||||
|
const payload = {
|
||||||
|
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)
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to save workflow:", err);
|
||||||
|
}
|
||||||
|
}, [tabs, activeTabId]);
|
||||||
|
|
||||||
const renderMainContent = () => {
|
const renderMainContent = () => {
|
||||||
switch (currentPage) {
|
switch (currentPage) {
|
||||||
case "devices":
|
case "devices":
|
||||||
@@ -230,11 +310,12 @@ export default function App() {
|
|||||||
<Box sx={{ display: "flex", flexGrow: 1, overflow: "hidden" }}>
|
<Box sx={{ display: "flex", flexGrow: 1, overflow: "hidden" }}>
|
||||||
<NodeSidebar
|
<NodeSidebar
|
||||||
categorizedNodes={categorizedNodes}
|
categorizedNodes={categorizedNodes}
|
||||||
handleExportFlow={() => {}}
|
handleExportFlow={handleExportFlow}
|
||||||
handleImportFlow={() => {}}
|
handleImportFlow={handleImportFlow}
|
||||||
handleOpenCloseAllDialog={() => {}}
|
handleSaveFlow={handleSaveFlow}
|
||||||
|
handleOpenCloseAllDialog={() => setConfirmCloseOpen(true)}
|
||||||
fileInputRef={fileInputRef}
|
fileInputRef={fileInputRef}
|
||||||
onFileInputChange={() => {}}
|
onFileInputChange={onFileInputChange}
|
||||||
/>
|
/>
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 1, overflow: "hidden" }}>
|
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 1, overflow: "hidden" }}>
|
||||||
<FlowTabs
|
<FlowTabs
|
||||||
|
@@ -95,6 +95,43 @@ export function RenameTabDialog({ open, value, onChange, onCancel, onSave }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RenameWorkflowDialog({ open, value, onChange, onCancel, onSave }) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
||||||
|
<DialogTitle>Rename Workflow</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label="Workflow Name"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
sx={{
|
||||||
|
"& .MuiOutlinedInput-root": {
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
color: "#ccc",
|
||||||
|
"& fieldset": {
|
||||||
|
borderColor: "#444"
|
||||||
|
},
|
||||||
|
"&:hover fieldset": {
|
||||||
|
borderColor: "#666"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label: { color: "#aaa" },
|
||||||
|
mt: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
||||||
|
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function DeleteDeviceDialog({ open, onCancel, onConfirm }) {
|
export function DeleteDeviceDialog({ open, onCancel, onConfirm }) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
||||||
|
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import {
|
import {
|
||||||
ExpandMore as ExpandMoreIcon,
|
ExpandMore as ExpandMoreIcon,
|
||||||
|
SaveAlt as SaveAltIcon,
|
||||||
Save as SaveIcon,
|
Save as SaveIcon,
|
||||||
FileOpen as FileOpenIcon,
|
FileOpen as FileOpenIcon,
|
||||||
DeleteForever as DeleteForeverIcon,
|
DeleteForever as DeleteForeverIcon,
|
||||||
@@ -25,6 +26,7 @@ export default function NodeSidebar({
|
|||||||
categorizedNodes,
|
categorizedNodes,
|
||||||
handleExportFlow,
|
handleExportFlow,
|
||||||
handleImportFlow,
|
handleImportFlow,
|
||||||
|
handleSaveFlow,
|
||||||
handleOpenCloseAllDialog,
|
handleOpenCloseAllDialog,
|
||||||
fileInputRef,
|
fileInputRef,
|
||||||
onFileInputChange
|
onFileInputChange
|
||||||
@@ -72,10 +74,15 @@ export default function NodeSidebar({
|
|||||||
</AccordionSummary>
|
</AccordionSummary>
|
||||||
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
||||||
<Tooltip title="Export Current Tab to a JSON File" placement="right" arrow>
|
<Tooltip title="Export Current Tab to a JSON File" placement="right" arrow>
|
||||||
<Button fullWidth startIcon={<SaveIcon />} onClick={handleExportFlow} sx={buttonStyle}>
|
<Button fullWidth startIcon={<SaveAltIcon />} onClick={handleExportFlow} sx={buttonStyle}>
|
||||||
Export Current Flow
|
Export Current Flow
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip title="Save Current Flow to Workflows Folder" placement="right" arrow>
|
||||||
|
<Button fullWidth startIcon={<SaveIcon />} onClick={handleSaveFlow} sx={buttonStyle}>
|
||||||
|
Save Workflow
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip title="Import JSON File into New Flow Tab" placement="right" arrow>
|
<Tooltip title="Import JSON File into New Flow Tab" placement="right" arrow>
|
||||||
<Button fullWidth startIcon={<FileOpenIcon />} onClick={handleImportFlow} sx={buttonStyle}>
|
<Button fullWidth startIcon={<FileOpenIcon />} onClick={handleImportFlow} sx={buttonStyle}>
|
||||||
Import Flow
|
Import Flow
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo, useEffect } from "react";
|
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
Paper,
|
Paper,
|
||||||
Box,
|
Box,
|
||||||
@@ -9,9 +9,13 @@ import {
|
|||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
TableSortLabel
|
TableSortLabel,
|
||||||
|
IconButton,
|
||||||
|
Menu,
|
||||||
|
MenuItem
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { PlayCircle as PlayCircleIcon } from "@mui/icons-material";
|
import { PlayCircle as PlayCircleIcon, MoreVert as MoreVertIcon } from "@mui/icons-material";
|
||||||
|
import { RenameWorkflowDialog } from "./Dialogs";
|
||||||
|
|
||||||
function formatDateTime(dateString) {
|
function formatDateTime(dateString) {
|
||||||
if (!dateString) return "";
|
if (!dateString) return "";
|
||||||
@@ -31,17 +35,17 @@ export default function WorkflowList({ onOpenWorkflow }) {
|
|||||||
const [rows, setRows] = useState([]);
|
const [rows, setRows] = useState([]);
|
||||||
const [orderBy, setOrderBy] = useState("name");
|
const [orderBy, setOrderBy] = useState("name");
|
||||||
const [order, setOrder] = useState("asc");
|
const [order, setOrder] = useState("asc");
|
||||||
|
const [menuAnchor, setMenuAnchor] = useState(null);
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
|
const [renameValue, setRenameValue] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
const loadRows = useCallback(async () => {
|
||||||
let alive = true;
|
|
||||||
(async () => {
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/api/storage/load_workflows");
|
const resp = await fetch("/api/storage/load_workflows");
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (!alive) return;
|
const mapped = (data.workflows || []).map((w) => ({
|
||||||
|
|
||||||
const mapped = (data.workflows || []).map(w => ({
|
|
||||||
...w,
|
...w,
|
||||||
name: w.tab_name && w.tab_name.trim() ? w.tab_name.trim() : w.file_name,
|
name: w.tab_name && w.tab_name.trim() ? w.tab_name.trim() : w.file_name,
|
||||||
description: "",
|
description: "",
|
||||||
@@ -54,12 +58,12 @@ export default function WorkflowList({ onOpenWorkflow }) {
|
|||||||
console.error("Failed to load workflows:", err);
|
console.error("Failed to load workflows:", err);
|
||||||
setRows([]);
|
setRows([]);
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRows();
|
||||||
|
}, [loadRows]);
|
||||||
|
|
||||||
const handleSort = (col) => {
|
const handleSort = (col) => {
|
||||||
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
|
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
|
||||||
else {
|
else {
|
||||||
@@ -94,6 +98,41 @@ export default function WorkflowList({ onOpenWorkflow }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openMenu = (e, row) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setMenuAnchor(e.currentTarget);
|
||||||
|
setSelected(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeMenu = () => setMenuAnchor(null);
|
||||||
|
|
||||||
|
const startRename = () => {
|
||||||
|
closeMenu();
|
||||||
|
if (selected) {
|
||||||
|
const initial = selected.tab_name && selected.tab_name.trim().length > 0
|
||||||
|
? selected.tab_name.trim()
|
||||||
|
: selected.file_name.replace(/\.json$/i, "");
|
||||||
|
setRenameValue(initial);
|
||||||
|
setRenameOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRenameSave = async () => {
|
||||||
|
if (!selected) return;
|
||||||
|
try {
|
||||||
|
await fetch("/api/storage/rename_workflow", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ path: selected.rel_path, new_name: renameValue })
|
||||||
|
});
|
||||||
|
await loadRows();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to rename workflow:", err);
|
||||||
|
}
|
||||||
|
setRenameOpen(false);
|
||||||
|
setSelected(null);
|
||||||
|
};
|
||||||
|
|
||||||
const renderNameCell = (r) => {
|
const renderNameCell = (r) => {
|
||||||
const hasPrefix = r.breadcrumb_prefix && r.breadcrumb_prefix.length > 0;
|
const hasPrefix = r.breadcrumb_prefix && r.breadcrumb_prefix.length > 0;
|
||||||
const primary = r.tab_name && r.tab_name.trim().length > 0 ? r.tab_name.trim() : r.file_name;
|
const primary = r.tab_name && r.tab_name.trim().length > 0 ? r.tab_name.trim() : r.file_name;
|
||||||
@@ -179,6 +218,7 @@ export default function WorkflowList({ onOpenWorkflow }) {
|
|||||||
Last Edited
|
Last Edited
|
||||||
</TableSortLabel>
|
</TableSortLabel>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -193,17 +233,41 @@ export default function WorkflowList({ onOpenWorkflow }) {
|
|||||||
<TableCell>{r.description}</TableCell>
|
<TableCell>{r.description}</TableCell>
|
||||||
<TableCell>{r.category}</TableCell>
|
<TableCell>{r.category}</TableCell>
|
||||||
<TableCell>{formatDateTime(r.lastEdited)}</TableCell>
|
<TableCell>{formatDateTime(r.lastEdited)}</TableCell>
|
||||||
|
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => openMenu(e, r)}
|
||||||
|
sx={{ color: "#ccc" }}
|
||||||
|
>
|
||||||
|
<MoreVertIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
{sorted.length === 0 && (
|
{sorted.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4} sx={{ color: "#888" }}>
|
<TableCell colSpan={5} sx={{ color: "#888" }}>
|
||||||
No workflows found.
|
No workflows found.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
<Menu
|
||||||
|
anchorEl={menuAnchor}
|
||||||
|
open={Boolean(menuAnchor)}
|
||||||
|
onClose={closeMenu}
|
||||||
|
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={startRename}>Rename</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
<RenameWorkflowDialog
|
||||||
|
open={renameOpen}
|
||||||
|
value={renameValue}
|
||||||
|
onChange={setRenameValue}
|
||||||
|
onCancel={() => setRenameOpen(false)}
|
||||||
|
onSave={handleRenameSave}
|
||||||
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -186,6 +186,63 @@ def load_workflow():
|
|||||||
obj = _safe_read_json(abs_path)
|
obj = _safe_read_json(abs_path)
|
||||||
return jsonify(obj)
|
return jsonify(obj)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/storage/save_workflow", methods=["POST"])
|
||||||
|
def save_workflow():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
workflow = data.get("workflow")
|
||||||
|
if not name or not isinstance(workflow, dict):
|
||||||
|
return jsonify({"error": "Invalid payload"}), 400
|
||||||
|
|
||||||
|
if not name.lower().endswith(".json"):
|
||||||
|
name += ".json"
|
||||||
|
workflows_root = os.path.abspath(
|
||||||
|
os.path.join(os.path.dirname(__file__), "..", "..", "Workflows")
|
||||||
|
)
|
||||||
|
os.makedirs(workflows_root, exist_ok=True)
|
||||||
|
safe_name = os.path.basename(name)
|
||||||
|
abs_path = os.path.join(workflows_root, safe_name)
|
||||||
|
try:
|
||||||
|
with open(abs_path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(workflow, fh, indent=2)
|
||||||
|
return jsonify({"status": "ok", "file_name": safe_name})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/storage/rename_workflow", methods=["POST"])
|
||||||
|
def rename_workflow():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
rel_path = (data.get("path") or "").strip()
|
||||||
|
new_name = (data.get("new_name") or "").strip()
|
||||||
|
workflows_root = os.path.abspath(
|
||||||
|
os.path.join(os.path.dirname(__file__), "..", "..", "Workflows")
|
||||||
|
)
|
||||||
|
old_abs = os.path.abspath(os.path.join(workflows_root, rel_path))
|
||||||
|
if not old_abs.startswith(workflows_root) or not os.path.isfile(old_abs):
|
||||||
|
return jsonify({"error": "Workflow not found"}), 404
|
||||||
|
if not new_name:
|
||||||
|
return jsonify({"error": "Invalid new_name"}), 400
|
||||||
|
if not new_name.lower().endswith(".json"):
|
||||||
|
new_name += ".json"
|
||||||
|
new_abs = os.path.join(os.path.dirname(old_abs), os.path.basename(new_name))
|
||||||
|
base_name = os.path.splitext(os.path.basename(new_abs))[0]
|
||||||
|
try:
|
||||||
|
os.rename(old_abs, new_abs)
|
||||||
|
obj = _safe_read_json(new_abs)
|
||||||
|
for k in ["tabName", "tab_name", "name", "title"]:
|
||||||
|
if k in obj:
|
||||||
|
obj[k] = base_name
|
||||||
|
if "tab_name" not in obj:
|
||||||
|
obj["tab_name"] = base_name
|
||||||
|
with open(new_abs, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(obj, fh, indent=2)
|
||||||
|
rel_new = os.path.relpath(new_abs, workflows_root).replace(os.sep, "/")
|
||||||
|
return jsonify({"status": "ok", "rel_path": rel_new})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
# ---------------------------------------------
|
# ---------------------------------------------
|
||||||
# Borealis Agent API Endpoints
|
# Borealis Agent API Endpoints
|
||||||
# ---------------------------------------------
|
# ---------------------------------------------
|
||||||
|
Reference in New Issue
Block a user