Enable workflow import/export and rename

This commit is contained in:
2025-08-09 22:20:59 -06:00
parent 8eb676fd9f
commit 3b6f8bfbac
5 changed files with 281 additions and 35 deletions

View File

@@ -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

View File

@@ -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" } }}>

View File

@@ -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

View File

@@ -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>
); );
} }

View File

@@ -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
# --------------------------------------------- # ---------------------------------------------