diff --git a/Data/Server/WebUI/package.json b/Data/Server/WebUI/package.json index 0da6e2e..1e39ff9 100644 --- a/Data/Server/WebUI/package.json +++ b/Data/Server/WebUI/package.json @@ -12,6 +12,7 @@ "@emotion/styled": "11.14.0", "@mui/icons-material": "7.0.2", "@mui/material": "7.0.2", + "@mui/x-tree-view": "8.10.0", "normalize.css": "8.0.1", "prismjs": "1.30.0", "react": "19.1.0", diff --git a/Data/Server/WebUI/src/Dialogs.jsx b/Data/Server/WebUI/src/Dialogs.jsx index 5dad12f..0ad7fdc 100644 --- a/Data/Server/WebUI/src/Dialogs.jsx +++ b/Data/Server/WebUI/src/Dialogs.jsx @@ -132,6 +132,39 @@ export function RenameWorkflowDialog({ open, value, onChange, onCancel, onSave } ); } +export function RenameFolderDialog({ open, value, onChange, onCancel, onSave }) { + return ( + + Folder Name + + onChange(e.target.value)} + sx={{ + "& .MuiOutlinedInput-root": { + backgroundColor: "#2a2a2a", + color: "#ccc", + "& fieldset": { borderColor: "#444" }, + "&:hover fieldset": { borderColor: "#666" } + }, + label: { color: "#aaa" }, + mt: 1 + }} + /> + + + + + + + ); +} + export function DeleteDeviceDialog({ open, onCancel, onConfirm }) { return ( diff --git a/Data/Server/WebUI/src/Workflow_List.jsx b/Data/Server/WebUI/src/Workflow_List.jsx index 08338c4..f6ebd21 100644 --- a/Data/Server/WebUI/src/Workflow_List.jsx +++ b/Data/Server/WebUI/src/Workflow_List.jsx @@ -1,273 +1,317 @@ -import React, { useState, useMemo, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { Paper, Box, Typography, Button, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - TableSortLabel, IconButton, Menu, MenuItem } from "@mui/material"; -import { PlayCircle as PlayCircleIcon, MoreVert as MoreVertIcon } from "@mui/icons-material"; -import { RenameWorkflowDialog } from "./Dialogs"; +import { + PlayCircle as PlayCircleIcon, + MoreVert as MoreVertIcon, + Folder as FolderIcon, + Description as DescriptionIcon, + CreateNewFolder as CreateNewFolderIcon +} from "@mui/icons-material"; +import { + SimpleTreeView, + TreeItem, + useTreeViewApiRef, + useTreeViewDragAndDrop +} from "@mui/x-tree-view"; +import { RenameWorkflowDialog, RenameFolderDialog } from "./Dialogs"; -function formatDateTime(dateString) { - if (!dateString) return ""; - const date = new Date(dateString); - if (isNaN(date)) return ""; - const day = String(date.getDate()).padStart(2, "0"); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const year = date.getFullYear(); - let hours = date.getHours(); - const minutes = String(date.getMinutes()).padStart(2, "0"); - const ampm = hours >= 12 ? "PM" : "AM"; - hours = hours % 12 || 12; - return `${day}/${month}/${year} ${hours}:${minutes}${ampm}`; +function buildTree(workflows) { + const map = {}; + const root = []; + (workflows || []).forEach((w) => { + const parts = (w.rel_path || "").split("/"); + let children = root; + let parentPath = ""; + parts.forEach((part, idx) => { + const path = parentPath ? `${parentPath}/${part}` : part; + const isFile = idx === parts.length - 1; + let node = children.find((n) => n.id === path); + if (!node) { + node = { + id: path, + label: isFile + ? w.tab_name && w.tab_name.trim().length > 0 + ? w.tab_name.trim() + : w.file_name + : part, + path, + isFolder: !isFile, + fileName: w.file_name, + workflow: isFile ? w : null, + children: [] + }; + children.push(node); + map[path] = node; + } + if (!isFile) { + children = node.children; + parentPath = path; + } + }); + }); + return { root, map }; } export default function WorkflowList({ onOpenWorkflow }) { - const [rows, setRows] = useState([]); - const [orderBy, setOrderBy] = useState("name"); - const [order, setOrder] = useState("asc"); + const [tree, setTree] = useState([]); + const [nodeMap, setNodeMap] = useState({}); const [menuAnchor, setMenuAnchor] = useState(null); - const [selected, setSelected] = useState(null); - const [renameOpen, setRenameOpen] = useState(false); + const [selectedNode, setSelectedNode] = useState(null); const [renameValue, setRenameValue] = useState(""); + const [renameOpen, setRenameOpen] = useState(false); + const [renameFolderOpen, setRenameFolderOpen] = useState(false); + const apiRef = useTreeViewApiRef(); - const loadRows = useCallback(async () => { + useTreeViewDragAndDrop(apiRef, { + onItemDrop: async (params) => { + const source = nodeMap[params.dragItemId]; + const target = nodeMap[params.dropTargetId]; + if (source && target && !source.isFolder && target.isFolder) { + const newPath = `${target.path}/${source.fileName}`; + try { + await fetch("/api/storage/move_workflow", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: source.path, new_path: newPath }) + }); + loadTree(); + } catch (err) { + console.error("Failed to move workflow:", err); + } + } + } + }); + + const loadTree = useCallback(async () => { try { const resp = await fetch("/api/storage/load_workflows"); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); - const mapped = (data.workflows || []).map((w) => ({ - ...w, - name: w.tab_name && w.tab_name.trim() ? w.tab_name.trim() : w.file_name, - description: "", - category: "", - lastEdited: w.last_edited, - lastEditedEpoch: w.last_edited_epoch - })); - setRows(mapped); + const { root, map } = buildTree(data.workflows || []); + setTree(root); + setNodeMap(map); } catch (err) { console.error("Failed to load workflows:", err); - setRows([]); + setTree([]); + setNodeMap({}); } }, []); useEffect(() => { - loadRows(); - }, [loadRows]); + loadTree(); + }, [loadTree]); - 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) => { - if (orderBy === "lastEdited" || orderBy === "lastEditedEpoch") { - const A = Number(a.lastEditedEpoch || 0); - const B = Number(b.lastEditedEpoch || 0); - return (A - B) * dir; - } - const A = a[orderBy] || ""; - const B = b[orderBy] || ""; - return String(A).localeCompare(String(B)) * dir; - }); - }, [rows, orderBy, order]); - - const handleNewWorkflow = () => { - if (onOpenWorkflow) { - onOpenWorkflow(); - } - }; - - const handleRowClick = (workflow) => { - if (onOpenWorkflow) { - onOpenWorkflow(workflow); - } - }; - - const openMenu = (e, row) => { + const openMenu = (e, node) => { e.stopPropagation(); setMenuAnchor(e.currentTarget); - setSelected(row); + setSelectedNode(node); }; const closeMenu = () => setMenuAnchor(null); - const startRename = () => { + const handleRename = () => { 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); + if (!selectedNode) return; + setRenameValue(selectedNode.label); + if (selectedNode.isFolder) setRenameFolderOpen(true); + else setRenameOpen(true); + }; + + const handleDeleteWorkflow = async () => { + closeMenu(); + if (!selectedNode) return; + try { + await fetch("/api/storage/delete_workflow", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: selectedNode.path }) + }); + loadTree(); + } catch (err) { + console.error("Failed to delete workflow:", err); } }; - const handleRenameSave = async () => { - if (!selected) return; + const handleCreateFolder = () => { + closeMenu(); + setRenameValue(""); + setRenameFolderOpen(true); + }; + + const saveRenameWorkflow = async () => { + if (!selectedNode) 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 }) + body: JSON.stringify({ path: selectedNode.path, new_name: renameValue }) }); - await loadRows(); + loadTree(); } catch (err) { console.error("Failed to rename workflow:", err); } setRenameOpen(false); - setSelected(null); }; - const renderNameCell = (r) => { - 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; - return ( - - {hasPrefix && ( - - {r.breadcrumb_prefix} {">"}{" "} - - )} - - {primary} - - - ); + const saveRenameFolder = async () => { + try { + if (selectedNode && selectedNode.isFolder) { + await fetch("/api/storage/rename_folder", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: selectedNode.path, new_name: renameValue }) + }); + } else { + const basePath = selectedNode ? selectedNode.path : ""; + const newPath = basePath ? `${basePath}/${renameValue}` : renameValue; + await fetch("/api/storage/create_folder", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: newPath }) + }); + } + loadTree(); + } catch (err) { + console.error("Folder operation failed:", err); + } + setRenameFolderOpen(false); }; + const handleNodeSelect = (event, itemId) => { + const node = nodeMap[itemId]; + if (node && !node.isFolder && onOpenWorkflow) { + onOpenWorkflow(node.workflow); + } + }; + + const renderItems = (nodes) => + nodes.map((n) => ( + + {n.isFolder ? ( + + ) : ( + + )} + {n.label} + openMenu(e, n)} + sx={{ color: "#ccc" }} + > + + + + } + > + {n.children && n.children.length > 0 ? renderItems(n.children) : null} + + )); + return ( - + Workflows - List of available workflows. + Manage workflow folders and files. - + + + + + + + + {renderItems(tree)} + - - - - - handleSort("name")} - > - Name - - - - handleSort("description")} - > - Description - - - - handleSort("category")} - > - Category - - - - handleSort("lastEdited")} - > - Last Edited - - - - - - - {sorted.map((r, i) => ( - handleRowClick(r)} - > - {renderNameCell(r)} - {r.description} - {r.category} - {formatDateTime(r.lastEdited)} - e.stopPropagation()}> - openMenu(e, r)} - sx={{ color: "#ccc" }} - > - - - - - ))} - {sorted.length === 0 && ( - - - No workflows found. - - - )} - -
- Rename + {selectedNode?.isFolder && ( + New Folder + )} + Rename + {!selectedNode?.isFolder && ( + Delete + )} setRenameOpen(false)} - onSave={handleRenameSave} + onSave={saveRenameWorkflow} + /> + setRenameFolderOpen(false)} + onSave={saveRenameFolder} />
); } + diff --git a/Data/Server/server.py b/Data/Server/server.py index 0f05448..31c31b2 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -12,6 +12,7 @@ from flask_cors import CORS import time import os # To Read Production ReactJS Server Folder import json # For reading workflow JSON files +import shutil # For moving workflow files and folders from typing import List, Dict # Borealis Python API Endpoints @@ -85,6 +86,84 @@ def ocr_endpoint(): except Exception as e: return jsonify({"error": str(e)}), 500 +# New storage management endpoints + +@app.route("/api/storage/move_workflow", methods=["POST"]) +def move_workflow(): + data = request.get_json(silent=True) or {} + rel_path = (data.get("path") or "").strip() + new_rel = (data.get("new_path") 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)) + new_abs = os.path.abspath(os.path.join(workflows_root, new_rel)) + if not old_abs.startswith(workflows_root) or not os.path.isfile(old_abs): + return jsonify({"error": "Workflow not found"}), 404 + if not new_abs.startswith(workflows_root): + return jsonify({"error": "Invalid destination"}), 400 + os.makedirs(os.path.dirname(new_abs), exist_ok=True) + try: + shutil.move(old_abs, new_abs) + return jsonify({"status": "ok"}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/storage/delete_workflow", methods=["POST"]) +def delete_workflow(): + data = request.get_json(silent=True) or {} + rel_path = (data.get("path") or "").strip() + workflows_root = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "Workflows") + ) + abs_path = os.path.abspath(os.path.join(workflows_root, rel_path)) + if not abs_path.startswith(workflows_root) or not os.path.isfile(abs_path): + return jsonify({"error": "Workflow not found"}), 404 + try: + os.remove(abs_path) + return jsonify({"status": "ok"}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/storage/create_folder", methods=["POST"]) +def create_folder(): + data = request.get_json(silent=True) or {} + rel_path = (data.get("path") or "").strip() + workflows_root = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "Workflows") + ) + abs_path = os.path.abspath(os.path.join(workflows_root, rel_path)) + if not abs_path.startswith(workflows_root): + return jsonify({"error": "Invalid path"}), 400 + try: + os.makedirs(abs_path, exist_ok=True) + return jsonify({"status": "ok"}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/storage/rename_folder", methods=["POST"]) +def rename_folder(): + 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.isdir(old_abs): + return jsonify({"error": "Folder not found"}), 404 + if not new_name: + return jsonify({"error": "Invalid new_name"}), 400 + new_abs = os.path.join(os.path.dirname(old_abs), new_name) + try: + os.rename(old_abs, new_abs) + return jsonify({"status": "ok"}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + # --------------------------------------------- # Borealis Storage API Endpoints # ---------------------------------------------