mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 20:01:57 -06:00
Assembly Structural Overhaul
This commit is contained in:
1
Assemblies/Scripts/Coupon.ps1
Normal file
1
Assemblies/Scripts/Coupon.ps1
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Test
|
||||||
@@ -35,8 +35,8 @@ import Login from "./Login.jsx";
|
|||||||
import SiteList from "./Sites/Site_List";
|
import SiteList from "./Sites/Site_List";
|
||||||
import DeviceList from "./Devices/Device_List";
|
import DeviceList from "./Devices/Device_List";
|
||||||
import DeviceDetails from "./Devices/Device_Details";
|
import DeviceDetails from "./Devices/Device_Details";
|
||||||
import WorkflowList from "./Workflows/Workflow_List";
|
import AssemblyList from "./Assemblies/Assembly_List";
|
||||||
import ScriptEditor from "./Scripting/Script_Editor";
|
import ScriptEditor from "./Assemblies/Script_Editor";
|
||||||
import ScheduledJobsList from "./Scheduling/Scheduled_Jobs_List";
|
import ScheduledJobsList from "./Scheduling/Scheduled_Jobs_List";
|
||||||
import CreateJob from "./Scheduling/Create_Job.jsx";
|
import CreateJob from "./Scheduling/Create_Job.jsx";
|
||||||
import UserManagement from "./Admin/User_Management.jsx";
|
import UserManagement from "./Admin/User_Management.jsx";
|
||||||
@@ -106,6 +106,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
|||||||
const [userDisplayName, setUserDisplayName] = useState(null);
|
const [userDisplayName, setUserDisplayName] = useState(null);
|
||||||
const [editingJob, setEditingJob] = useState(null);
|
const [editingJob, setEditingJob] = useState(null);
|
||||||
const [jobsRefreshToken, setJobsRefreshToken] = useState(0);
|
const [jobsRefreshToken, setJobsRefreshToken] = useState(0);
|
||||||
|
const [scriptToEdit, setScriptToEdit] = useState(null); // { path, mode: 'scripts'|'ansible' }
|
||||||
const [notAuthorizedOpen, setNotAuthorizedOpen] = useState(false);
|
const [notAuthorizedOpen, setNotAuthorizedOpen] = useState(false);
|
||||||
|
|
||||||
// Top-bar search state
|
// Top-bar search state
|
||||||
@@ -151,7 +152,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
|||||||
// Build breadcrumb items for current view
|
// Build breadcrumb items for current view
|
||||||
const breadcrumbs = React.useMemo(() => {
|
const breadcrumbs = React.useMemo(() => {
|
||||||
const items = [];
|
const items = [];
|
||||||
switch (currentPage) {
|
switch (currentPage) {
|
||||||
case "sites":
|
case "sites":
|
||||||
items.push({ label: "Sites", page: "sites" });
|
items.push({ label: "Sites", page: "sites" });
|
||||||
items.push({ label: "Site List", page: "sites" });
|
items.push({ label: "Site List", page: "sites" });
|
||||||
@@ -187,6 +188,15 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
|||||||
items.push({ label: "Automation", page: "jobs" });
|
items.push({ label: "Automation", page: "jobs" });
|
||||||
items.push({ label: "Scripts", page: "scripts" });
|
items.push({ label: "Scripts", page: "scripts" });
|
||||||
break;
|
break;
|
||||||
|
case "ansible_editor":
|
||||||
|
items.push({ label: "Automation", page: "jobs" });
|
||||||
|
items.push({ label: "Ansible Playbooks", page: "assemblies" });
|
||||||
|
items.push({ label: "Playbook Editor" });
|
||||||
|
break;
|
||||||
|
case "assemblies":
|
||||||
|
items.push({ label: "Automation", page: "jobs" });
|
||||||
|
items.push({ label: "Assemblies", page: "assemblies" });
|
||||||
|
break;
|
||||||
case "community":
|
case "community":
|
||||||
items.push({ label: "Automation", page: "jobs" });
|
items.push({ label: "Automation", page: "jobs" });
|
||||||
items.push({ label: "Community Content", page: "community" });
|
items.push({ label: "Community Content", page: "community" });
|
||||||
@@ -595,63 +605,81 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
|||||||
|
|
||||||
case "workflows":
|
case "workflows":
|
||||||
return (
|
return (
|
||||||
<WorkflowList
|
<AssemblyList
|
||||||
onOpenWorkflow={async (workflow, folderPath, name) => {
|
onOpenWorkflow={async (workflow, folderPath, name) => {
|
||||||
const newId = "flow_" + Date.now();
|
const newId = "flow_" + Date.now();
|
||||||
if (workflow && workflow.rel_path) {
|
if (workflow && workflow.rel_path) {
|
||||||
const folder = workflow.rel_path
|
const folder = workflow.rel_path.split("/").slice(0, -1).join("/");
|
||||||
.split("/")
|
|
||||||
.slice(0, -1)
|
|
||||||
.join("/");
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(
|
const resp = await fetch(`/api/storage/load_workflow?path=${encodeURIComponent(workflow.rel_path)}`);
|
||||||
`/api/storage/load_workflow?path=${encodeURIComponent(
|
|
||||||
workflow.rel_path
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
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();
|
||||||
setTabs([
|
setTabs([{ id: newId, tab_name: data.tab_name || workflow.name || workflow.file_name || "Workflow", nodes: data.nodes || [], edges: data.edges || [], folderPath: folder }]);
|
||||||
{
|
|
||||||
id: newId,
|
|
||||||
tab_name:
|
|
||||||
data.tab_name || workflow.name || workflow.file_name || "Workflow",
|
|
||||||
nodes: data.nodes || [],
|
|
||||||
edges: data.edges || [],
|
|
||||||
folderPath: folder
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load workflow:", err);
|
console.error("Failed to load workflow:", err);
|
||||||
setTabs([
|
setTabs([{ id: newId, tab_name: workflow?.name || "Workflow", nodes: [], edges: [], folderPath: folder }]);
|
||||||
{
|
|
||||||
id: newId,
|
|
||||||
tab_name: workflow?.name || "Workflow",
|
|
||||||
nodes: [],
|
|
||||||
edges: [],
|
|
||||||
folderPath: folder
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setTabs([
|
setTabs([{ id: newId, tab_name: name || "Flow", nodes: [], edges: [], folderPath: folderPath || "" }]);
|
||||||
{
|
|
||||||
id: newId,
|
|
||||||
tab_name: name || "Flow",
|
|
||||||
nodes: [],
|
|
||||||
edges: [],
|
|
||||||
folderPath: folderPath || ""
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
setActiveTabId(newId);
|
setActiveTabId(newId);
|
||||||
setCurrentPage("workflow-editor");
|
setCurrentPage("workflow-editor");
|
||||||
}}
|
}}
|
||||||
|
onOpenScript={(rel, mode) => {
|
||||||
|
setScriptToEdit({ path: rel, mode });
|
||||||
|
setCurrentPage(mode === 'ansible' ? 'ansible_editor' : 'scripts');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "assemblies":
|
||||||
|
return (
|
||||||
|
<AssemblyList
|
||||||
|
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");
|
||||||
|
}}
|
||||||
|
onOpenScript={(rel, mode) => {
|
||||||
|
setScriptToEdit({ path: rel, mode });
|
||||||
|
setCurrentPage(mode === 'ansible' ? 'ansible_editor' : 'scripts');
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "scripts":
|
case "scripts":
|
||||||
return <ScriptEditor />;
|
return (
|
||||||
|
<ScriptEditor
|
||||||
|
mode="scripts"
|
||||||
|
initialPath={scriptToEdit?.mode === 'scripts' ? (scriptToEdit?.path || '') : ''}
|
||||||
|
onConsumedInitialPath={() => setScriptToEdit(null)}
|
||||||
|
onSaved={() => setCurrentPage('assemblies')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "ansible_editor":
|
||||||
|
return (
|
||||||
|
<ScriptEditor
|
||||||
|
mode="ansible"
|
||||||
|
initialPath={scriptToEdit?.mode === 'ansible' ? (scriptToEdit?.path || '') : ''}
|
||||||
|
onConsumedInitialPath={() => setScriptToEdit(null)}
|
||||||
|
onSaved={() => setCurrentPage('assemblies')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case "admin_users":
|
case "admin_users":
|
||||||
return <UserManagement isAdmin={isAdmin} />;
|
return <UserManagement isAdmin={isAdmin} />;
|
||||||
|
|||||||
685
Data/Server/WebUI/src/Assemblies/Assembly_List.jsx
Normal file
685
Data/Server/WebUI/src/Assemblies/Assembly_List.jsx
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Paper, Box, Typography, Menu, MenuItem, Button } from "@mui/material";
|
||||||
|
import { Folder as FolderIcon, Description as DescriptionIcon, Polyline as WorkflowsIcon, Code as ScriptIcon, MenuBook as BookIcon } from "@mui/icons-material";
|
||||||
|
import {
|
||||||
|
SimpleTreeView,
|
||||||
|
TreeItem,
|
||||||
|
useTreeViewApiRef
|
||||||
|
} from "@mui/x-tree-view";
|
||||||
|
import {
|
||||||
|
RenameWorkflowDialog,
|
||||||
|
RenameFolderDialog,
|
||||||
|
NewWorkflowDialog,
|
||||||
|
ConfirmDeleteDialog
|
||||||
|
} from "../Dialogs";
|
||||||
|
|
||||||
|
// Generic Island wrapper to mimic Device_Details styling
|
||||||
|
const Island = ({ title, description, icon, children, sx }) => (
|
||||||
|
<Paper elevation={0} sx={{ p: 1.5, borderRadius: 2, bgcolor: '#1c1c1c', border: '1px solid #2a2a2a', mb: 1.5, ...(sx || {}) }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||||
|
{icon ? <Box sx={{ color: '#58a6ff', display: 'flex', alignItems: 'center' }}>{icon}</Box> : null}
|
||||||
|
<Typography variant="caption" sx={{ color: '#58a6ff', fontWeight: 400, fontSize: '14px', letterSpacing: 0.2 }}>{title}</Typography>
|
||||||
|
</Box>
|
||||||
|
{description ? (
|
||||||
|
<Typography variant="body2" sx={{ color: '#aaa', mb: 1 }}>{description}</Typography>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------- Workflows Island -----------------
|
||||||
|
function buildWorkflowTree(workflows, folders) {
|
||||||
|
const map = {};
|
||||||
|
const rootNode = { id: "root", label: "Workflows", path: "", isFolder: true, children: [] };
|
||||||
|
map[rootNode.id] = rootNode;
|
||||||
|
(folders || []).forEach((f) => {
|
||||||
|
const parts = (f || "").split("/");
|
||||||
|
let children = rootNode.children;
|
||||||
|
let parentPath = "";
|
||||||
|
parts.forEach((part) => {
|
||||||
|
const path = parentPath ? `${parentPath}/${part}` : part;
|
||||||
|
let node = children.find((n) => n.id === path);
|
||||||
|
if (!node) {
|
||||||
|
node = { id: path, label: part, path, isFolder: true, children: [] };
|
||||||
|
children.push(node);
|
||||||
|
map[path] = node;
|
||||||
|
}
|
||||||
|
children = node.children;
|
||||||
|
parentPath = path;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
(workflows || []).forEach((w) => {
|
||||||
|
const parts = (w.rel_path || "").split("/");
|
||||||
|
let children = rootNode.children;
|
||||||
|
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()) || 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: [rootNode], map };
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkflowsIsland({ onOpenWorkflow }) {
|
||||||
|
const [tree, setTree] = useState([]);
|
||||||
|
const [nodeMap, setNodeMap] = useState({});
|
||||||
|
const [contextMenu, setContextMenu] = useState(null);
|
||||||
|
const [selectedNode, setSelectedNode] = useState(null);
|
||||||
|
const [renameValue, setRenameValue] = useState("");
|
||||||
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
|
const [renameFolderOpen, setRenameFolderOpen] = useState(false);
|
||||||
|
const [folderDialogMode, setFolderDialogMode] = useState("rename");
|
||||||
|
const [newWorkflowOpen, setNewWorkflowOpen] = useState(false);
|
||||||
|
const [newWorkflowName, setNewWorkflowName] = useState("");
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const apiRef = useTreeViewApiRef();
|
||||||
|
const [dragNode, setDragNode] = useState(null);
|
||||||
|
|
||||||
|
const handleDrop = async (target) => {
|
||||||
|
if (!dragNode || !target.isFolder) return;
|
||||||
|
if (dragNode.path === target.path || target.path.startsWith(`${dragNode.path}/`)) {
|
||||||
|
setDragNode(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newPath = target.path ? `${target.path}/${dragNode.fileName}` : dragNode.fileName;
|
||||||
|
try {
|
||||||
|
await fetch("/api/storage/move_workflow", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ path: dragNode.path, new_path: newPath })
|
||||||
|
});
|
||||||
|
loadTree();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to move workflow:", err);
|
||||||
|
}
|
||||||
|
setDragNode(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 { root, map } = buildWorkflowTree(data.workflows || [], data.folders || []);
|
||||||
|
setTree(root);
|
||||||
|
setNodeMap(map);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load workflows:", err);
|
||||||
|
setTree([]);
|
||||||
|
setNodeMap({});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadTree(); }, [loadTree]);
|
||||||
|
|
||||||
|
const handleContextMenu = (e, node) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedNode(node);
|
||||||
|
setContextMenu(
|
||||||
|
contextMenu === null ? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 } : null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = () => {
|
||||||
|
setContextMenu(null);
|
||||||
|
if (!selectedNode) return;
|
||||||
|
setRenameValue(selectedNode.label);
|
||||||
|
if (selectedNode.isFolder) {
|
||||||
|
setFolderDialogMode("rename");
|
||||||
|
setRenameFolderOpen(true);
|
||||||
|
} else setRenameOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
setContextMenu(null);
|
||||||
|
if (selectedNode && !selectedNode.isFolder && onOpenWorkflow) {
|
||||||
|
onOpenWorkflow(selectedNode.workflow);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
setContextMenu(null);
|
||||||
|
if (!selectedNode) return;
|
||||||
|
setDeleteOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewFolder = () => {
|
||||||
|
if (!selectedNode) return;
|
||||||
|
setContextMenu(null);
|
||||||
|
setFolderDialogMode("create");
|
||||||
|
setRenameValue("");
|
||||||
|
setRenameFolderOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewWorkflow = () => {
|
||||||
|
if (!selectedNode) return;
|
||||||
|
setContextMenu(null);
|
||||||
|
setNewWorkflowName("");
|
||||||
|
setNewWorkflowOpen(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: selectedNode.path, new_name: renameValue })
|
||||||
|
});
|
||||||
|
loadTree();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to rename workflow:", err);
|
||||||
|
}
|
||||||
|
setRenameOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveRenameFolder = async () => {
|
||||||
|
try {
|
||||||
|
if (folderDialogMode === "rename" && selectedNode) {
|
||||||
|
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 confirmDelete = async () => {
|
||||||
|
if (!selectedNode) return;
|
||||||
|
try {
|
||||||
|
if (selectedNode.isFolder) {
|
||||||
|
await fetch("/api/storage/delete_folder", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ path: selectedNode.path })
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
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:", err);
|
||||||
|
}
|
||||||
|
setDeleteOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItems = (nodes) =>
|
||||||
|
(nodes || []).map((n) => (
|
||||||
|
<TreeItem
|
||||||
|
key={n.id}
|
||||||
|
itemId={n.id}
|
||||||
|
label={
|
||||||
|
<Box
|
||||||
|
sx={{ display: "flex", alignItems: "center" }}
|
||||||
|
draggable={!n.isFolder}
|
||||||
|
onDragStart={() => !n.isFolder && setDragNode(n)}
|
||||||
|
onDragOver={(e) => { if (dragNode && n.isFolder) e.preventDefault(); }}
|
||||||
|
onDrop={(e) => { e.preventDefault(); handleDrop(n); }}
|
||||||
|
onContextMenu={(e) => handleContextMenu(e, n)}
|
||||||
|
>
|
||||||
|
{n.isFolder ? (
|
||||||
|
<FolderIcon sx={{ mr: 1, color: "#0475c2" }} />
|
||||||
|
) : (
|
||||||
|
<DescriptionIcon sx={{ mr: 1, color: "#0475c2" }} />
|
||||||
|
)}
|
||||||
|
<Typography sx={{ flexGrow: 1, color: "#e6edf3" }}>{n.label}</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{n.children && n.children.length > 0 ? renderItems(n.children) : null}
|
||||||
|
</TreeItem>
|
||||||
|
));
|
||||||
|
|
||||||
|
const rootChildIds = tree[0]?.children?.map((c) => c.id) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Island title="Workflows" description="Node-Based Automation Pipelines" icon={<WorkflowsIcon fontSize="small" /> }>
|
||||||
|
<Box
|
||||||
|
sx={{ p: 1 }}
|
||||||
|
onDragOver={(e) => { if (dragNode) e.preventDefault(); }}
|
||||||
|
onDrop={(e) => { e.preventDefault(); handleDrop({ path: "", isFolder: true }); }}
|
||||||
|
>
|
||||||
|
<SimpleTreeView
|
||||||
|
key={rootChildIds.join(",")}
|
||||||
|
sx={{ color: "#e6edf3" }}
|
||||||
|
onNodeSelect={handleNodeSelect}
|
||||||
|
apiRef={apiRef}
|
||||||
|
defaultExpandedItems={["root", ...rootChildIds]}
|
||||||
|
>
|
||||||
|
{renderItems(tree)}
|
||||||
|
</SimpleTreeView>
|
||||||
|
</Box>
|
||||||
|
<Menu
|
||||||
|
open={contextMenu !== null}
|
||||||
|
onClose={() => setContextMenu(null)}
|
||||||
|
anchorReference="anchorPosition"
|
||||||
|
anchorPosition={contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined}
|
||||||
|
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
|
||||||
|
>
|
||||||
|
{selectedNode?.isFolder && (
|
||||||
|
<>
|
||||||
|
<MenuItem onClick={handleNewWorkflow}>New Workflow</MenuItem>
|
||||||
|
<MenuItem onClick={handleNewFolder}>New Subfolder</MenuItem>
|
||||||
|
{selectedNode.id !== "root" && (<MenuItem onClick={handleRename}>Rename</MenuItem>)}
|
||||||
|
{selectedNode.id !== "root" && (<MenuItem onClick={handleDelete}>Delete</MenuItem>)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!selectedNode?.isFolder && (
|
||||||
|
<>
|
||||||
|
<MenuItem onClick={handleEdit}>Edit</MenuItem>
|
||||||
|
<MenuItem onClick={handleRename}>Rename</MenuItem>
|
||||||
|
<MenuItem onClick={handleDelete}>Delete</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
<RenameWorkflowDialog open={renameOpen} value={renameValue} onChange={setRenameValue} onCancel={() => setRenameOpen(false)} onSave={saveRenameWorkflow} />
|
||||||
|
<RenameFolderDialog open={renameFolderOpen} value={renameValue} onChange={setRenameValue} onCancel={() => setRenameFolderOpen(false)} onSave={saveRenameFolder} title={folderDialogMode === "rename" ? "Rename Folder" : "New Folder"} confirmText={folderDialogMode === "rename" ? "Save" : "Create"} />
|
||||||
|
<NewWorkflowDialog open={newWorkflowOpen} value={newWorkflowName} onChange={setNewWorkflowName} onCancel={() => setNewWorkflowOpen(false)} onCreate={() => { setNewWorkflowOpen(false); onOpenWorkflow && onOpenWorkflow(null, selectedNode?.path || "", newWorkflowName); }} />
|
||||||
|
<ConfirmDeleteDialog open={deleteOpen} message="If you delete this, there is no undo button, are you sure you want to proceed?" onCancel={() => setDeleteOpen(false)} onConfirm={confirmDelete} />
|
||||||
|
</Island>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Generic Scripts-like Island (used for Scripts and Ansible) -----------------
|
||||||
|
function buildFileTree(rootLabel, items, folders) {
|
||||||
|
const map = {};
|
||||||
|
const rootNode = { id: "root", label: rootLabel, path: "", isFolder: true, children: [] };
|
||||||
|
map[rootNode.id] = rootNode;
|
||||||
|
(folders || []).forEach((f) => {
|
||||||
|
const parts = (f || "").split("/");
|
||||||
|
let children = rootNode.children;
|
||||||
|
let parentPath = "";
|
||||||
|
parts.forEach((part) => {
|
||||||
|
const path = parentPath ? `${parentPath}/${part}` : part;
|
||||||
|
let node = children.find((n) => n.id === path);
|
||||||
|
if (!node) {
|
||||||
|
node = { id: path, label: part, path, isFolder: true, children: [] };
|
||||||
|
children.push(node);
|
||||||
|
map[path] = node;
|
||||||
|
}
|
||||||
|
children = node.children;
|
||||||
|
parentPath = path;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
(items || []).forEach((s) => {
|
||||||
|
const parts = (s.rel_path || "").split("/");
|
||||||
|
let children = rootNode.children;
|
||||||
|
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 ? (s.file_name || part) : part,
|
||||||
|
path,
|
||||||
|
isFolder: !isFile,
|
||||||
|
fileName: s.file_name,
|
||||||
|
meta: isFile ? s : null,
|
||||||
|
children: []
|
||||||
|
};
|
||||||
|
children.push(node);
|
||||||
|
map[path] = node;
|
||||||
|
}
|
||||||
|
if (!isFile) {
|
||||||
|
children = node.children;
|
||||||
|
parentPath = path;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { root: [rootNode], map };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScriptsLikeIsland({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
rootLabel,
|
||||||
|
baseApi, // e.g. '/api/scripts' or '/api/ansible'
|
||||||
|
newItemLabel = "New Script",
|
||||||
|
onEdit // (rel_path) => void
|
||||||
|
}) {
|
||||||
|
const [tree, setTree] = useState([]);
|
||||||
|
const [nodeMap, setNodeMap] = useState({});
|
||||||
|
const [contextMenu, setContextMenu] = useState(null);
|
||||||
|
const [selectedNode, setSelectedNode] = useState(null);
|
||||||
|
const [renameValue, setRenameValue] = useState("");
|
||||||
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
|
const [renameFolderOpen, setRenameFolderOpen] = useState(false);
|
||||||
|
const [folderDialogMode, setFolderDialogMode] = useState("rename");
|
||||||
|
const [newItemOpen, setNewItemOpen] = useState(false);
|
||||||
|
const [newItemName, setNewItemName] = useState("");
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const apiRef = useTreeViewApiRef();
|
||||||
|
const [dragNode, setDragNode] = useState(null);
|
||||||
|
|
||||||
|
const loadTree = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${baseApi}/list`);
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
const { root, map } = buildFileTree(rootLabel, data.scripts || data.items || [], data.folders || []);
|
||||||
|
setTree(root);
|
||||||
|
setNodeMap(map);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to load ${title}:`, err);
|
||||||
|
setTree([]);
|
||||||
|
setNodeMap({});
|
||||||
|
}
|
||||||
|
}, [baseApi, title, rootLabel]);
|
||||||
|
|
||||||
|
useEffect(() => { loadTree(); }, [loadTree]);
|
||||||
|
|
||||||
|
const handleContextMenu = (e, node) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedNode(node);
|
||||||
|
setContextMenu(
|
||||||
|
contextMenu === null ? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 } : null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = async (target) => {
|
||||||
|
if (!dragNode || !target.isFolder) return;
|
||||||
|
if (dragNode.path === target.path || target.path.startsWith(`${dragNode.path}/`)) {
|
||||||
|
setDragNode(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newPath = target.path ? `${target.path}/${dragNode.fileName}` : dragNode.fileName;
|
||||||
|
try {
|
||||||
|
await fetch(`${baseApi}/move_file`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ path: dragNode.path, new_path: newPath })
|
||||||
|
});
|
||||||
|
loadTree();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to move:", err);
|
||||||
|
}
|
||||||
|
setDragNode(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNodeSelect = async (_e, itemId) => {
|
||||||
|
const node = nodeMap[itemId];
|
||||||
|
if (node && !node.isFolder) {
|
||||||
|
setContextMenu(null);
|
||||||
|
onEdit && onEdit(node.path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveRenameFile = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseApi}/rename_file`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ path: selectedNode.path, new_name: renameValue })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
|
||||||
|
setRenameOpen(false);
|
||||||
|
loadTree();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to rename file:", err);
|
||||||
|
setRenameOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveRenameFolder = async () => {
|
||||||
|
try {
|
||||||
|
if (folderDialogMode === "rename" && selectedNode) {
|
||||||
|
await fetch(`${baseApi}/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(`${baseApi}/create_folder`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ path: newPath })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setRenameFolderOpen(false);
|
||||||
|
loadTree();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Folder operation failed:", err);
|
||||||
|
setRenameFolderOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!selectedNode) return;
|
||||||
|
try {
|
||||||
|
if (selectedNode.isFolder) {
|
||||||
|
await fetch(`${baseApi}/delete_folder`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ path: selectedNode.path })
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await fetch(`${baseApi}/delete_file`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ path: selectedNode.path })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setDeleteOpen(false);
|
||||||
|
loadTree();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete:", err);
|
||||||
|
setDeleteOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNewItem = async () => {
|
||||||
|
try {
|
||||||
|
const folder = selectedNode?.isFolder ? selectedNode.path : (selectedNode?.path?.split("/").slice(0, -1).join("/") || "");
|
||||||
|
let name = newItemName || "new";
|
||||||
|
const hasExt = /\.[^./\\]+$/i.test(name);
|
||||||
|
if (!hasExt) {
|
||||||
|
if (String(baseApi || '').endsWith('/api/ansible')) name += '.yml';
|
||||||
|
else name += '.ps1';
|
||||||
|
}
|
||||||
|
const newPath = folder ? `${folder}/${name}` : name;
|
||||||
|
// create empty file by saving blank content
|
||||||
|
const res = await fetch(`${baseApi}/save`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: newPath, content: "" })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data?.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
setNewItemOpen(false);
|
||||||
|
setNewItemName("");
|
||||||
|
loadTree();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItems = (nodes) =>
|
||||||
|
(nodes || []).map((n) => (
|
||||||
|
<TreeItem
|
||||||
|
key={n.id}
|
||||||
|
itemId={n.id}
|
||||||
|
label={
|
||||||
|
<Box
|
||||||
|
sx={{ display: "flex", alignItems: "center" }}
|
||||||
|
draggable={!n.isFolder}
|
||||||
|
onDragStart={() => !n.isFolder && setDragNode(n)}
|
||||||
|
onDragOver={(e) => { if (dragNode && n.isFolder) e.preventDefault(); }}
|
||||||
|
onDrop={(e) => { e.preventDefault(); handleDrop(n); }}
|
||||||
|
onContextMenu={(e) => handleContextMenu(e, n)}
|
||||||
|
onDoubleClick={() => { if (!n.isFolder) onEdit && onEdit(n.path); }}
|
||||||
|
>
|
||||||
|
{n.isFolder ? (
|
||||||
|
<FolderIcon sx={{ mr: 1, color: "#0475c2" }} />
|
||||||
|
) : (
|
||||||
|
<DescriptionIcon sx={{ mr: 1, color: "#0475c2" }} />
|
||||||
|
)}
|
||||||
|
<Typography sx={{ flexGrow: 1, color: "#e6edf3" }}>{n.label}</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{n.children && n.children.length > 0 ? renderItems(n.children) : null}
|
||||||
|
</TreeItem>
|
||||||
|
));
|
||||||
|
|
||||||
|
const rootChildIds = tree[0]?.children?.map((c) => c.id) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Island title={title} description={description} icon={title === 'Scripts' ? <ScriptIcon fontSize="small" /> : <BookIcon fontSize="small" /> }>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}>
|
||||||
|
<Button size="small" onClick={() => { setNewItemName(""); setNewItemOpen(true); }} sx={{ color: '#58a6ff', textTransform: 'none' }}>
|
||||||
|
{newItemLabel}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{ p: 1 }}
|
||||||
|
onDragOver={(e) => { if (dragNode) e.preventDefault(); }}
|
||||||
|
onDrop={(e) => { e.preventDefault(); handleDrop({ path: "", isFolder: true }); }}
|
||||||
|
>
|
||||||
|
<SimpleTreeView
|
||||||
|
key={rootChildIds.join(",")}
|
||||||
|
sx={{ color: "#e6edf3" }}
|
||||||
|
onNodeSelect={handleNodeSelect}
|
||||||
|
apiRef={apiRef}
|
||||||
|
defaultExpandedItems={["root", ...rootChildIds]}
|
||||||
|
>
|
||||||
|
{renderItems(tree)}
|
||||||
|
</SimpleTreeView>
|
||||||
|
</Box>
|
||||||
|
<Menu
|
||||||
|
open={contextMenu !== null}
|
||||||
|
onClose={() => setContextMenu(null)}
|
||||||
|
anchorReference="anchorPosition"
|
||||||
|
anchorPosition={contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined}
|
||||||
|
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
|
||||||
|
>
|
||||||
|
{selectedNode?.isFolder && (
|
||||||
|
<>
|
||||||
|
<MenuItem onClick={() => { setContextMenu(null); setNewItemOpen(true); }}>{newItemLabel}</MenuItem>
|
||||||
|
<MenuItem onClick={() => { setContextMenu(null); setFolderDialogMode("create"); setRenameValue(""); setRenameFolderOpen(true); }}>New Subfolder</MenuItem>
|
||||||
|
{selectedNode.id !== "root" && (<MenuItem onClick={() => { setContextMenu(null); setRenameValue(selectedNode.label); setRenameOpen(true); }}>Rename</MenuItem>)}
|
||||||
|
{selectedNode.id !== "root" && (<MenuItem onClick={() => { setContextMenu(null); setDeleteOpen(true); }}>Delete</MenuItem>)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!selectedNode?.isFolder && (
|
||||||
|
<>
|
||||||
|
<MenuItem onClick={() => { setContextMenu(null); onEdit && onEdit(selectedNode.path); }}>Edit</MenuItem>
|
||||||
|
<MenuItem onClick={() => { setContextMenu(null); setRenameValue(selectedNode.label); setRenameOpen(true); }}>Rename</MenuItem>
|
||||||
|
<MenuItem onClick={() => { setContextMenu(null); setDeleteOpen(true); }}>Delete</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
{/* Simple inline dialogs using shared components */}
|
||||||
|
<RenameFolderDialog open={renameFolderOpen} value={renameValue} onChange={setRenameValue} onCancel={() => setRenameFolderOpen(false)} onSave={saveRenameFolder} title={folderDialogMode === "rename" ? "Rename Folder" : "New Folder"} confirmText={folderDialogMode === "rename" ? "Save" : "Create"} />
|
||||||
|
{/* File rename */}
|
||||||
|
<Paper component={(p) => <div {...p} />} sx={{ display: renameOpen ? 'block' : 'none' }}>
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999 }}>
|
||||||
|
<Paper sx={{ bgcolor: '#121212', color: '#fff', p: 2, minWidth: 360 }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 1 }}>Rename</Typography>
|
||||||
|
<input autoFocus value={renameValue} onChange={(e) => setRenameValue(e.target.value)} style={{ width: '100%', padding: 8, background: '#2a2a2a', color: '#ccc', border: '1px solid #444', borderRadius: 4 }} />
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
|
||||||
|
<Button onClick={() => setRenameOpen(false)} sx={{ color: '#58a6ff' }}>Cancel</Button>
|
||||||
|
<Button onClick={saveRenameFile} sx={{ color: '#58a6ff' }}>Save</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</div>
|
||||||
|
</Paper>
|
||||||
|
<Paper component={(p) => <div {...p} />} sx={{ display: newItemOpen ? 'block' : 'none' }}>
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999 }}>
|
||||||
|
<Paper sx={{ bgcolor: '#121212', color: '#fff', p: 2, minWidth: 360 }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 1 }}>{newItemLabel}</Typography>
|
||||||
|
<input autoFocus value={newItemName} onChange={(e) => setNewItemName(e.target.value)} placeholder="Name" style={{ width: '100%', padding: 8, background: '#2a2a2a', color: '#ccc', border: '1px solid #444', borderRadius: 4 }} />
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
|
||||||
|
<Button onClick={() => setNewItemOpen(false)} sx={{ color: '#58a6ff' }}>Cancel</Button>
|
||||||
|
<Button onClick={createNewItem} sx={{ color: '#58a6ff' }}>Create</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</div>
|
||||||
|
</Paper>
|
||||||
|
<ConfirmDeleteDialog open={deleteOpen} message="If you delete this, there is no undo button, are you sure you want to proceed?" onCancel={() => setDeleteOpen(false)} onConfirm={confirmDelete} />
|
||||||
|
</Island>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
|
||||||
|
return (
|
||||||
|
<Paper sx={{ m: 2, p: 2, bgcolor: '#1e1e1e' }} elevation={2}>
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="h5" sx={{ color: '#e6edf3', mb: 0.5 }}>Assemblies</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: '#aaa' }}>Collections of components used to perform various actions upon targeted devices.</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1.2fr 1fr 1fr' }, gap: 2 }}>
|
||||||
|
{/* Left: Workflows */}
|
||||||
|
<WorkflowsIsland onOpenWorkflow={onOpenWorkflow} />
|
||||||
|
|
||||||
|
{/* Middle: Scripts */}
|
||||||
|
<ScriptsLikeIsland
|
||||||
|
title="Scripts"
|
||||||
|
description="Powershell, Batch, and Bash Scripts"
|
||||||
|
rootLabel="Scripts"
|
||||||
|
baseApi="/api/scripts"
|
||||||
|
newItemLabel="New Script"
|
||||||
|
onEdit={(rel) => onOpenScript && onOpenScript(rel, 'scripts')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Right: Ansible Playbooks */}
|
||||||
|
<ScriptsLikeIsland
|
||||||
|
title="Ansible Playbooks"
|
||||||
|
description="Declarative Instructions for Consistent Automation"
|
||||||
|
rootLabel="Ansible Playbooks"
|
||||||
|
baseApi="/api/ansible"
|
||||||
|
newItemLabel="New Playbook"
|
||||||
|
onEdit={(rel) => onOpenScript && onOpenScript(rel, 'ansible')}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
Data/Server/WebUI/src/Assemblies/Script_Editor.jsx
Normal file
204
Data/Server/WebUI/src/Assemblies/Script_Editor.jsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
|
import { Paper, Box, Typography, Button, Select, FormControl, InputLabel, TextField, MenuItem } from "@mui/material";
|
||||||
|
import Prism from "prismjs";
|
||||||
|
import "prismjs/components/prism-yaml";
|
||||||
|
import "prismjs/components/prism-bash";
|
||||||
|
import "prismjs/components/prism-powershell";
|
||||||
|
import "prismjs/components/prism-batch";
|
||||||
|
import "prismjs/themes/prism-okaidia.css";
|
||||||
|
import Editor from "react-simple-code-editor";
|
||||||
|
import { ConfirmDeleteDialog } from "../Dialogs";
|
||||||
|
|
||||||
|
const TYPE_OPTIONS_ALL = [
|
||||||
|
{ key: "ansible", label: "Ansible Playbook", ext: ".yml", prism: "yaml" },
|
||||||
|
{ key: "powershell", label: "Powershell Script", ext: ".ps1", prism: "powershell" },
|
||||||
|
{ key: "batch", label: "Batch Script", ext: ".bat", prism: "batch" },
|
||||||
|
{ key: "bash", label: "Bash Script", ext: ".sh", prism: "bash" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const keyBy = (arr) => Object.fromEntries(arr.map((o) => [o.key, o]));
|
||||||
|
|
||||||
|
function typeFromFilename(name = "") {
|
||||||
|
const n = name.toLowerCase();
|
||||||
|
if (n.endsWith(".yml")) return "ansible";
|
||||||
|
if (n.endsWith(".ps1")) return "powershell";
|
||||||
|
if (n.endsWith(".bat")) return "batch";
|
||||||
|
if (n.endsWith(".sh")) return "bash";
|
||||||
|
return "powershell";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureExt(baseName, t) {
|
||||||
|
if (!baseName) return baseName;
|
||||||
|
if (/\.[^./\\]+$/i.test(baseName)) return baseName;
|
||||||
|
const TYPES = keyBy(TYPE_OPTIONS_ALL);
|
||||||
|
const type = TYPES[t] || TYPES.powershell;
|
||||||
|
return baseName + type.ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightedHtml(code, prismLang) {
|
||||||
|
try {
|
||||||
|
const grammar = Prism.languages[prismLang] || Prism.languages.markup;
|
||||||
|
return Prism.highlight(code ?? "", grammar, prismLang);
|
||||||
|
} catch {
|
||||||
|
return (code ?? "").replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function RenameFileDialog({ open, value, onChange, onCancel, onSave }) {
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 9999 }}>
|
||||||
|
<Paper sx={{ bgcolor: "#121212", color: "#fff", p: 2, minWidth: 360 }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 1 }}>Rename</Typography>
|
||||||
|
<TextField autoFocus margin="dense" label="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 }} />
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 2 }}>
|
||||||
|
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
||||||
|
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewItemDialog({ open, name, type, typeOptions, onChangeName, onChangeType, onCancel, onCreate }) {
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 9999 }}>
|
||||||
|
<Paper sx={{ bgcolor: "#121212", color: "#fff", p: 2, minWidth: 360 }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 1 }}>New</Typography>
|
||||||
|
<TextField autoFocus margin="dense" label="Name" fullWidth variant="outlined" value={name} onChange={(e) => onChangeName(e.target.value)}
|
||||||
|
sx={{ "& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } }, label: { color: "#aaa" }, mt: 1 }} />
|
||||||
|
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||||
|
<InputLabel sx={{ color: "#aaa" }}>Type</InputLabel>
|
||||||
|
<Select value={type} label="Type" onChange={(e) => onChangeType(e.target.value)}
|
||||||
|
sx={{ color: "#e6edf3", bgcolor: "#1e1e1e", "& .MuiOutlinedInput-notchedOutline": { borderColor: "#444" }, "&:hover .MuiOutlinedInput-notchedOutline": { borderColor: "#666" } }}>
|
||||||
|
{typeOptions.map((o) => (<MenuItem key={o.key} value={o.key}>{o.label}</MenuItem>))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 2 }}>
|
||||||
|
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
||||||
|
<Button onClick={onCreate} sx={{ color: "#58a6ff" }}>Create</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScriptEditor({ mode = "scripts", initialPath = "", onConsumedInitialPath, onSaved }) {
|
||||||
|
const isAnsible = mode === "ansible";
|
||||||
|
const baseApi = isAnsible ? "/api/ansible" : "/api/scripts";
|
||||||
|
const TYPE_OPTIONS = useMemo(() => (isAnsible ? TYPE_OPTIONS_ALL.filter(o => o.key === 'ansible') : TYPE_OPTIONS_ALL.filter(o => o.key !== 'ansible')), [isAnsible]);
|
||||||
|
|
||||||
|
const [currentPath, setCurrentPath] = useState("");
|
||||||
|
const [fileName, setFileName] = useState("");
|
||||||
|
const [type, setType] = useState(isAnsible ? "ansible" : "powershell");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
|
||||||
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
|
const [renameValue, setRenameValue] = useState("");
|
||||||
|
const [newOpen, setNewOpen] = useState(false);
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [newType, setNewType] = useState(isAnsible ? "ansible" : "powershell");
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
if (!initialPath) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${baseApi}/load?path=${encodeURIComponent(initialPath)}`);
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
setCurrentPath(data.rel_path || initialPath);
|
||||||
|
const fname = data.file_name || initialPath.split('/').pop() || '';
|
||||||
|
setFileName(fname);
|
||||||
|
setType(typeFromFilename(fname));
|
||||||
|
setCode(data.content || "");
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
if (onConsumedInitialPath) onConsumedInitialPath();
|
||||||
|
})();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [initialPath, baseApi]);
|
||||||
|
|
||||||
|
const saveFile = async () => {
|
||||||
|
if (!currentPath && !fileName) {
|
||||||
|
setNewName("");
|
||||||
|
setNewType(isAnsible ? "ansible" : type);
|
||||||
|
setNewOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalizedName = currentPath ? undefined : ensureExt(fileName, type);
|
||||||
|
const payload = { path: currentPath || undefined, name: normalizedName, content: code, type };
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${baseApi}/save`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
|
||||||
|
if (data.rel_path) {
|
||||||
|
setCurrentPath(data.rel_path);
|
||||||
|
const fname = data.rel_path.split('/').pop();
|
||||||
|
setFileName(fname);
|
||||||
|
setType(typeFromFilename(fname));
|
||||||
|
onSaved && onSaved();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to save:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveRenameFile = async () => {
|
||||||
|
try {
|
||||||
|
const finalName = ensureExt(renameValue, type);
|
||||||
|
const res = await fetch(`${baseApi}/rename_file`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: currentPath, new_name: finalName, type }) });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
|
||||||
|
setCurrentPath(data.rel_path || currentPath);
|
||||||
|
const fname = (data.rel_path || currentPath).split('/').pop();
|
||||||
|
setFileName(fname);
|
||||||
|
setType(typeFromFilename(fname));
|
||||||
|
setRenameOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to rename file:", err);
|
||||||
|
setRenameOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNew = () => {
|
||||||
|
const finalName = ensureExt(newName || (isAnsible ? "playbook" : "script"), newType);
|
||||||
|
setCurrentPath(finalName);
|
||||||
|
setFileName(finalName);
|
||||||
|
setType(newType);
|
||||||
|
setCode("");
|
||||||
|
setNewOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", flex: 1, height: "100%", overflow: "hidden" }}>
|
||||||
|
<Paper sx={{ my: 2, mx: 2, p: 1.5, bgcolor: "#1e1e1e", display: "flex", flexDirection: "column", flex: 1 }} elevation={2}>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2 }}>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 220 }}>
|
||||||
|
<InputLabel sx={{ color: "#aaa" }}>Type</InputLabel>
|
||||||
|
<Select value={type} label="Type" onChange={(e) => setType(e.target.value)} sx={{ color: "#e6edf3", bgcolor: "#1e1e1e", "& .MuiOutlinedInput-notchedOutline": { borderColor: "#444" }, "&:hover .MuiOutlinedInput-notchedOutline": { borderColor: "#666" } }}>
|
||||||
|
{TYPE_OPTIONS.map((o) => (<MenuItem key={o.key} value={o.key}>{o.label}</MenuItem>))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<Box sx={{ flex: 1 }} />
|
||||||
|
{fileName && (
|
||||||
|
<Button onClick={() => { setRenameValue(fileName); setRenameOpen(true); }} sx={{ color: "#58a6ff", textTransform: "none" }}>Rename: {fileName}</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={saveFile} sx={{ color: "#58a6ff", borderColor: "#58a6ff", textTransform: "none", border: "1px solid #58a6ff", backgroundColor: "#1e1e1e", "&:hover": { backgroundColor: "#1b1b1b" } }}>Save</Button>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1, minHeight: 300, border: "1px solid #444", borderRadius: 1, background: "#121212", overflow: "auto" }}>
|
||||||
|
<Editor value={code} onValueChange={setCode} highlight={(src) => highlightedHtml(src, (keyBy(TYPE_OPTIONS_ALL)[type]?.prism || 'yaml'))} padding={12} placeholder={currentPath ? `Editing: ${currentPath}` : (isAnsible ? "New Playbook..." : "New Script...")}
|
||||||
|
style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', fontSize: 14, color: "#e6edf3", background: "#121212", outline: "none", minHeight: 300, lineHeight: 1.4, caretColor: "#58a6ff" }} />
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Dialogs */}
|
||||||
|
<RenameFileDialog open={renameOpen} value={renameValue} onChange={setRenameValue} onCancel={() => setRenameOpen(false)} onSave={saveRenameFile} />
|
||||||
|
<NewItemDialog open={newOpen} name={newName} type={newType} typeOptions={TYPE_OPTIONS} onChangeName={setNewName} onChangeType={setNewType} onCancel={() => setNewOpen(false)} onCreate={createNew} />
|
||||||
|
<ConfirmDeleteDialog open={deleteOpen} message="If you delete this, there is no undo button, are you sure you want to proceed?" onCancel={() => setDeleteOpen(false)} onConfirm={() => { setDeleteOpen(false); onSaved && onSaved(); }} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -18,7 +18,8 @@ import {
|
|||||||
Work as JobsIcon,
|
Work as JobsIcon,
|
||||||
Polyline as WorkflowsIcon,
|
Polyline as WorkflowsIcon,
|
||||||
Code as ScriptIcon,
|
Code as ScriptIcon,
|
||||||
PeopleOutline as CommunityIcon
|
PeopleOutline as CommunityIcon,
|
||||||
|
Apps as AssembliesIcon
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { LocationCity as SitesIcon } from "@mui/icons-material";
|
import { LocationCity as SitesIcon } from "@mui/icons-material";
|
||||||
import { ManageAccounts as AdminUsersIcon, Dns as ServerInfoIcon } from "@mui/icons-material";
|
import { ManageAccounts as AdminUsersIcon, Dns as ServerInfoIcon } from "@mui/icons-material";
|
||||||
@@ -197,7 +198,7 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
|||||||
|
|
||||||
{/* Automation */}
|
{/* Automation */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const groupActive = ["jobs", "scripts", "workflows", "community"].includes(currentPage);
|
const groupActive = ["jobs", "assemblies", "community"].includes(currentPage);
|
||||||
return (
|
return (
|
||||||
<Accordion
|
<Accordion
|
||||||
expanded={expandedNav.automation}
|
expanded={expandedNav.automation}
|
||||||
@@ -235,8 +236,7 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
|||||||
</AccordionSummary>
|
</AccordionSummary>
|
||||||
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
||||||
<NavItem icon={<JobsIcon fontSize="small" />} label="Scheduled Jobs" pageKey="jobs" />
|
<NavItem icon={<JobsIcon fontSize="small" />} label="Scheduled Jobs" pageKey="jobs" />
|
||||||
<NavItem icon={<ScriptIcon fontSize="small" />} label="Scripts" pageKey="scripts" />
|
<NavItem icon={<AssembliesIcon fontSize="small" />} label="Assemblies" pageKey="assemblies" />
|
||||||
<NavItem icon={<WorkflowsIcon fontSize="small" />} label="Workflows" pageKey="workflows" />
|
|
||||||
<NavItem icon={<CommunityIcon fontSize="small" />} label="Community Content" pageKey="community" />
|
<NavItem icon={<CommunityIcon fontSize="small" />} label="Community Content" pageKey="community" />
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|||||||
@@ -1,693 +0,0 @@
|
|||||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Scripting/Script_Editor.jsx
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
||||||
import {
|
|
||||||
Paper,
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Button,
|
|
||||||
Menu,
|
|
||||||
MenuItem,
|
|
||||||
Select,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
TextField
|
|
||||||
} from "@mui/material";
|
|
||||||
import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material";
|
|
||||||
import {
|
|
||||||
SimpleTreeView,
|
|
||||||
TreeItem,
|
|
||||||
useTreeViewApiRef
|
|
||||||
} from "@mui/x-tree-view";
|
|
||||||
|
|
||||||
// Reuse shared dialogs for consistency
|
|
||||||
import { ConfirmDeleteDialog, RenameFolderDialog } from "../Dialogs";
|
|
||||||
|
|
||||||
// Prism for syntax highlighting (same theme as JSON node)
|
|
||||||
import Prism from "prismjs";
|
|
||||||
import "prismjs/components/prism-yaml";
|
|
||||||
import "prismjs/components/prism-bash";
|
|
||||||
import "prismjs/components/prism-powershell";
|
|
||||||
import "prismjs/components/prism-batch";
|
|
||||||
import "prismjs/themes/prism-okaidia.css";
|
|
||||||
import Editor from "react-simple-code-editor";
|
|
||||||
|
|
||||||
// ---------- helpers ----------
|
|
||||||
const TYPE_OPTIONS = [
|
|
||||||
{ key: "powershell", label: "Powershell Script", ext: ".ps1", prism: "powershell" },
|
|
||||||
{ key: "batch", label: "Batch Script", ext: ".bat", prism: "batch" },
|
|
||||||
{ key: "bash", label: "Bash Script", ext: ".sh", prism: "bash" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const keyBy = (arr) => Object.fromEntries(arr.map((o) => [o.key, o]));
|
|
||||||
const TYPES = keyBy(TYPE_OPTIONS);
|
|
||||||
|
|
||||||
function typeFromFilename(name = "") {
|
|
||||||
const n = name.toLowerCase();
|
|
||||||
if (n.endsWith(".ps1")) return "powershell";
|
|
||||||
if (n.endsWith(".bat")) return "batch";
|
|
||||||
if (n.endsWith(".sh")) return "bash";
|
|
||||||
// Default editor type
|
|
||||||
return "powershell";
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureExt(baseName, typeKey) {
|
|
||||||
if (!baseName) return baseName;
|
|
||||||
// If user already provided any extension, keep it.
|
|
||||||
if (/\.[^./\\]+$/i.test(baseName)) return baseName;
|
|
||||||
const t = TYPES[typeKey] || TYPES.powershell;
|
|
||||||
return baseName + t.ext;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTree(scripts, folders) {
|
|
||||||
const map = {};
|
|
||||||
const rootNode = {
|
|
||||||
id: "root",
|
|
||||||
label: "Scripts",
|
|
||||||
path: "",
|
|
||||||
isFolder: true,
|
|
||||||
children: []
|
|
||||||
};
|
|
||||||
map[rootNode.id] = rootNode;
|
|
||||||
|
|
||||||
(folders || []).forEach((f) => {
|
|
||||||
const parts = (f || "").split("/");
|
|
||||||
let children = rootNode.children;
|
|
||||||
let parentPath = "";
|
|
||||||
parts.forEach((part) => {
|
|
||||||
const path = parentPath ? `${parentPath}/${part}` : part;
|
|
||||||
let node = children.find((n) => n.id === path);
|
|
||||||
if (!node) {
|
|
||||||
node = { id: path, label: part, path, isFolder: true, children: [] };
|
|
||||||
children.push(node);
|
|
||||||
map[path] = node;
|
|
||||||
}
|
|
||||||
children = node.children;
|
|
||||||
parentPath = path;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
(scripts || []).forEach((s) => {
|
|
||||||
const parts = (s.rel_path || "").split("/");
|
|
||||||
let children = rootNode.children;
|
|
||||||
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 ? s.file_name : part,
|
|
||||||
path,
|
|
||||||
isFolder: !isFile,
|
|
||||||
fileName: s.file_name,
|
|
||||||
script: isFile ? s : null,
|
|
||||||
children: []
|
|
||||||
};
|
|
||||||
children.push(node);
|
|
||||||
map[path] = node;
|
|
||||||
}
|
|
||||||
if (!isFile) {
|
|
||||||
children = node.children;
|
|
||||||
parentPath = path;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return { root: [rootNode], map };
|
|
||||||
}
|
|
||||||
|
|
||||||
function highlightedHtml(code, prismLang) {
|
|
||||||
try {
|
|
||||||
const grammar = Prism.languages[prismLang] || Prism.languages.markup;
|
|
||||||
return Prism.highlight(code ?? "", grammar, prismLang);
|
|
||||||
} catch {
|
|
||||||
return (code ?? "").replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local dialog: rename script file
|
|
||||||
function RenameScriptDialog({ open, value, onChange, onCancel, onSave, onBlur }) {
|
|
||||||
return (
|
|
||||||
<Paper
|
|
||||||
component={(props) => (
|
|
||||||
<div {...props} />
|
|
||||||
)}
|
|
||||||
sx={{ display: open ? "block" : "none" }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
inset: 0,
|
|
||||||
background: "rgba(0,0,0,0.4)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
zIndex: 9999
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Paper sx={{ bgcolor: "#121212", color: "#fff", p: 2, minWidth: 360 }}>
|
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>Rename Script</Typography>
|
|
||||||
<TextField
|
|
||||||
autoFocus
|
|
||||||
margin="dense"
|
|
||||||
label="File Name"
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
onBlur={() => onBlur && onBlur()}
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#2a2a2a",
|
|
||||||
color: "#ccc",
|
|
||||||
"& fieldset": { borderColor: "#444" },
|
|
||||||
"&:hover fieldset": { borderColor: "#666" }
|
|
||||||
},
|
|
||||||
label: { color: "#aaa" },
|
|
||||||
mt: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 2 }}>
|
|
||||||
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
|
||||||
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
</div>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local dialog: new script (name + type)
|
|
||||||
function NewScriptDialog({ open, name, type, onChangeName, onChangeType, onCancel, onCreate, onBlurName }) {
|
|
||||||
return (
|
|
||||||
<Paper component={(p) => <div {...p} />} sx={{ display: open ? "block" : "none" }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
inset: 0,
|
|
||||||
background: "rgba(0,0,0,0.4)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
zIndex: 9999
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Paper sx={{ bgcolor: "#121212", color: "#fff", p: 2, minWidth: 360 }}>
|
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>New Script</Typography>
|
|
||||||
<TextField
|
|
||||||
autoFocus
|
|
||||||
margin="dense"
|
|
||||||
label="File Name"
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => onChangeName(e.target.value)}
|
|
||||||
onBlur={() => onBlurName && onBlurName()}
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "#2a2a2a",
|
|
||||||
color: "#ccc",
|
|
||||||
"& fieldset": { borderColor: "#444" },
|
|
||||||
"&:hover fieldset": { borderColor: "#666" }
|
|
||||||
},
|
|
||||||
label: { color: "#aaa" },
|
|
||||||
mt: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
|
||||||
<InputLabel sx={{ color: "#aaa" }}>Type</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={type}
|
|
||||||
label="Type"
|
|
||||||
onChange={(e) => onChangeType(e.target.value)}
|
|
||||||
sx={{
|
|
||||||
color: "#e6edf3",
|
|
||||||
bgcolor: "#1e1e1e",
|
|
||||||
"& .MuiOutlinedInput-notchedOutline": { borderColor: "#444" },
|
|
||||||
"&:hover .MuiOutlinedInput-notchedOutline": { borderColor: "#666" }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{TYPE_OPTIONS.map((o) => (
|
|
||||||
<MenuItem key={o.key} value={o.key}>{o.label}</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 2 }}>
|
|
||||||
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
|
||||||
<Button onClick={onCreate} sx={{ color: "#58a6ff" }}>Create</Button>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
</div>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ScriptEditor() {
|
|
||||||
// Tree state
|
|
||||||
const [tree, setTree] = useState([]);
|
|
||||||
const [nodeMap, setNodeMap] = useState({});
|
|
||||||
const [contextMenu, setContextMenu] = useState(null);
|
|
||||||
const [selectedNode, setSelectedNode] = useState(null);
|
|
||||||
const apiRef = useTreeViewApiRef();
|
|
||||||
const [dragNode, setDragNode] = useState(null);
|
|
||||||
|
|
||||||
// Editor state
|
|
||||||
const [currentPath, setCurrentPath] = useState("");
|
|
||||||
const [currentFolder, setCurrentFolder] = useState("");
|
|
||||||
const [fileName, setFileName] = useState("");
|
|
||||||
const [type, setType] = useState("powershell");
|
|
||||||
const [code, setCode] = useState("");
|
|
||||||
|
|
||||||
// Dialog state
|
|
||||||
const [renameOpen, setRenameOpen] = useState(false);
|
|
||||||
const [renameValue, setRenameValue] = useState("");
|
|
||||||
const [renameFolderOpen, setRenameFolderOpen] = useState(false);
|
|
||||||
const [folderDialogMode, setFolderDialogMode] = useState("rename");
|
|
||||||
const [newScriptOpen, setNewScriptOpen] = useState(false);
|
|
||||||
const [newScriptName, setNewScriptName] = useState("");
|
|
||||||
const [newScriptType, setNewScriptType] = useState("powershell");
|
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
||||||
|
|
||||||
const prismLang = useMemo(() => (TYPES[type]?.prism || "yaml"), [type]);
|
|
||||||
|
|
||||||
const loadTree = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/api/scripts/list");
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
const { root, map } = buildTree(data.scripts || [], data.folders || []);
|
|
||||||
setTree(root);
|
|
||||||
setNodeMap(map);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load scripts:", err);
|
|
||||||
setTree([]);
|
|
||||||
setNodeMap({});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { loadTree(); }, [loadTree]);
|
|
||||||
|
|
||||||
// Context menu
|
|
||||||
const handleContextMenu = (e, node) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedNode(node);
|
|
||||||
setContextMenu(
|
|
||||||
contextMenu === null
|
|
||||||
? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 }
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = async (target) => {
|
|
||||||
if (!dragNode || !target.isFolder) return;
|
|
||||||
// Prevent dropping into itself or its descendants
|
|
||||||
if (dragNode.path === target.path || target.path.startsWith(`${dragNode.path}/`)) {
|
|
||||||
setDragNode(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newPath = target.path ? `${target.path}/${dragNode.fileName}` : dragNode.fileName;
|
|
||||||
try {
|
|
||||||
await fetch("/api/scripts/move_file", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ path: dragNode.path, new_path: newPath })
|
|
||||||
});
|
|
||||||
loadTree();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to move:", err);
|
|
||||||
}
|
|
||||||
setDragNode(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNodeSelect = async (_e, itemId) => {
|
|
||||||
const node = nodeMap[itemId];
|
|
||||||
if (node && !node.isFolder) {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/scripts/load?path=${encodeURIComponent(node.path)}`);
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
const folder = node.path.split("/").slice(0, -1).join("/");
|
|
||||||
setCurrentFolder(folder);
|
|
||||||
setCurrentPath(node.path);
|
|
||||||
setFileName(data.file_name || node.fileName || node.label);
|
|
||||||
setType(typeFromFilename(data.file_name || node.fileName || node.label));
|
|
||||||
setCode(data.content || "");
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load script:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveScript = async () => {
|
|
||||||
if (!currentPath && !fileName) {
|
|
||||||
// prompt for name/type first if user started typing without creating
|
|
||||||
setNewScriptName("");
|
|
||||||
setNewScriptType(type);
|
|
||||||
setNewScriptOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Ensure filename extension aligns with selected type when saving new file by name
|
|
||||||
const normalizedName = currentPath ? undefined : ensureExt(fileName, type);
|
|
||||||
const payload = {
|
|
||||||
path: currentPath || undefined,
|
|
||||||
name: normalizedName,
|
|
||||||
content: code,
|
|
||||||
type
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/api/scripts/save", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
|
|
||||||
// Update state with normalized rel_path (may include ext changes)
|
|
||||||
if (data.rel_path) {
|
|
||||||
setCurrentPath(data.rel_path);
|
|
||||||
const fname = data.rel_path.split("/").pop();
|
|
||||||
setFileName(fname);
|
|
||||||
setType(typeFromFilename(fname));
|
|
||||||
// ensure tree refresh
|
|
||||||
loadTree();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to save script:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRename = () => {
|
|
||||||
setContextMenu(null);
|
|
||||||
if (!selectedNode) return;
|
|
||||||
setRenameValue(selectedNode.label);
|
|
||||||
if (selectedNode.isFolder) {
|
|
||||||
setFolderDialogMode("rename");
|
|
||||||
setRenameFolderOpen(true);
|
|
||||||
} else setRenameOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNewFolder = () => {
|
|
||||||
if (!selectedNode) return;
|
|
||||||
setContextMenu(null);
|
|
||||||
setFolderDialogMode("create");
|
|
||||||
setRenameValue("");
|
|
||||||
setRenameFolderOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNewScript = () => {
|
|
||||||
if (!selectedNode) return;
|
|
||||||
setContextMenu(null);
|
|
||||||
setNewScriptName("");
|
|
||||||
setNewScriptType("powershell");
|
|
||||||
setNewScriptOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveRenameFile = async () => {
|
|
||||||
try {
|
|
||||||
const finalName = ensureExt(renameValue, type);
|
|
||||||
const res = await fetch("/api/scripts/rename_file", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ path: selectedNode.path, new_name: finalName, type })
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
|
|
||||||
if (currentPath === selectedNode.path) {
|
|
||||||
setCurrentPath(data.rel_path || selectedNode.path);
|
|
||||||
const fname = (data.rel_path || selectedNode.path).split("/").pop();
|
|
||||||
setFileName(fname);
|
|
||||||
setType(typeFromFilename(fname));
|
|
||||||
}
|
|
||||||
setRenameOpen(false);
|
|
||||||
loadTree();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to rename file:", err);
|
|
||||||
setRenameOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveRenameFolder = async () => {
|
|
||||||
try {
|
|
||||||
if (folderDialogMode === "rename" && selectedNode) {
|
|
||||||
await fetch("/api/scripts/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/scripts/create_folder", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ path: newPath })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setRenameFolderOpen(false);
|
|
||||||
loadTree();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Folder operation failed:", err);
|
|
||||||
setRenameFolderOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
|
||||||
if (!selectedNode) return;
|
|
||||||
try {
|
|
||||||
if (selectedNode.isFolder) {
|
|
||||||
await fetch("/api/scripts/delete_folder", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ path: selectedNode.path })
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await fetch("/api/scripts/delete_file", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ path: selectedNode.path })
|
|
||||||
});
|
|
||||||
if (currentPath === selectedNode.path) {
|
|
||||||
setCurrentPath("");
|
|
||||||
setFileName("");
|
|
||||||
setCode("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setDeleteOpen(false);
|
|
||||||
loadTree();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to delete:", err);
|
|
||||||
setDeleteOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createNewScript = () => {
|
|
||||||
const folder = selectedNode?.isFolder
|
|
||||||
? selectedNode.path
|
|
||||||
: (selectedNode?.path?.split("/").slice(0, -1).join("/") || currentFolder || "");
|
|
||||||
const finalName = ensureExt(newScriptName || "script", newScriptType);
|
|
||||||
const newPath = folder ? `${folder}/${finalName}` : finalName;
|
|
||||||
setCurrentFolder(folder);
|
|
||||||
setCurrentPath(newPath);
|
|
||||||
setFileName(finalName);
|
|
||||||
setType(newScriptType);
|
|
||||||
setCode("");
|
|
||||||
setNewScriptOpen(false);
|
|
||||||
// Note: not saved until user clicks Save
|
|
||||||
};
|
|
||||||
|
|
||||||
const rootChildIds = tree[0]?.children?.map((c) => c.id) || [];
|
|
||||||
// live highlighting handled by react-simple-code-editor
|
|
||||||
|
|
||||||
const renderItems = (nodes) =>
|
|
||||||
(nodes || []).map((n) => (
|
|
||||||
<TreeItem
|
|
||||||
key={n.id}
|
|
||||||
itemId={n.id}
|
|
||||||
label={
|
|
||||||
<Box
|
|
||||||
sx={{ display: "flex", alignItems: "center" }}
|
|
||||||
draggable={!n.isFolder}
|
|
||||||
onDragStart={() => !n.isFolder && setDragNode(n)}
|
|
||||||
onDragOver={(e) => { if (dragNode && n.isFolder) e.preventDefault(); }}
|
|
||||||
onDrop={(e) => { e.preventDefault(); handleDrop(n); }}
|
|
||||||
onContextMenu={(e) => handleContextMenu(e, n)}
|
|
||||||
>
|
|
||||||
{n.isFolder ? (
|
|
||||||
<FolderIcon sx={{ mr: 1, color: "#0475c2" }} />
|
|
||||||
) : (
|
|
||||||
<DescriptionIcon sx={{ mr: 1, color: "#0475c2" }} />
|
|
||||||
)}
|
|
||||||
<Typography sx={{ flexGrow: 1, color: "#e6edf3" }}>{n.label}</Typography>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{n.children && n.children.length > 0 ? renderItems(n.children) : null}
|
|
||||||
</TreeItem>
|
|
||||||
));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: "flex", flex: 1, height: "100%", overflow: "hidden" }}>
|
|
||||||
{/* Left: Tree */}
|
|
||||||
<Paper sx={{ my: 2, ml: 2, mr: 1, p: 0, bgcolor: "#1e1e1e", width: 320, flexShrink: 0 }} elevation={2}>
|
|
||||||
<Box sx={{ p: 2, pb: 1 }}>
|
|
||||||
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>Scripts</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
|
||||||
Create, edit, and organize various types of scripts.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{ p: 2 }}
|
|
||||||
onDragOver={(e) => { if (dragNode) e.preventDefault(); }}
|
|
||||||
onDrop={(e) => { e.preventDefault(); handleDrop({ path: "", isFolder: true }); }}
|
|
||||||
>
|
|
||||||
<SimpleTreeView
|
|
||||||
key={rootChildIds.join(",")}
|
|
||||||
sx={{ color: "#e6edf3" }}
|
|
||||||
onNodeSelect={handleNodeSelect}
|
|
||||||
apiRef={apiRef}
|
|
||||||
defaultExpandedItems={["root", ...rootChildIds]}
|
|
||||||
>
|
|
||||||
{renderItems(tree)}
|
|
||||||
</SimpleTreeView>
|
|
||||||
</Box>
|
|
||||||
<Menu
|
|
||||||
open={contextMenu !== null}
|
|
||||||
onClose={() => setContextMenu(null)}
|
|
||||||
anchorReference="anchorPosition"
|
|
||||||
anchorPosition={contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined}
|
|
||||||
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
|
|
||||||
>
|
|
||||||
{selectedNode?.isFolder && (
|
|
||||||
<>
|
|
||||||
<MenuItem onClick={handleNewScript}>New Script</MenuItem>
|
|
||||||
<MenuItem onClick={handleNewFolder}>New Subfolder</MenuItem>
|
|
||||||
{selectedNode.id !== "root" && (
|
|
||||||
<MenuItem onClick={handleRename}>Rename</MenuItem>
|
|
||||||
)}
|
|
||||||
{selectedNode.id !== "root" && (
|
|
||||||
<MenuItem onClick={() => { setContextMenu(null); setDeleteOpen(true); }}>Delete</MenuItem>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!selectedNode?.isFolder && (
|
|
||||||
<>
|
|
||||||
<MenuItem onClick={() => { setContextMenu(null); handleNodeSelect(null, selectedNode.id); }}>Edit</MenuItem>
|
|
||||||
<MenuItem onClick={handleRename}>Rename</MenuItem>
|
|
||||||
<MenuItem onClick={() => { setContextMenu(null); setDeleteOpen(true); }}>Delete</MenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Menu>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* Right: Editor */}
|
|
||||||
<Paper sx={{ my: 2, mr: 2, ml: 1, p: 1.5, bgcolor: "#1e1e1e", display: "flex", flexDirection: "column", flex: 1 }} elevation={2}>
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2 }}>
|
|
||||||
<FormControl size="small" sx={{ minWidth: 220 }}>
|
|
||||||
<InputLabel sx={{ color: "#aaa" }}>Type</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={type}
|
|
||||||
label="Type"
|
|
||||||
onChange={(e) => setType(e.target.value)}
|
|
||||||
sx={{
|
|
||||||
color: "#e6edf3",
|
|
||||||
bgcolor: "#1e1e1e",
|
|
||||||
"& .MuiOutlinedInput-notchedOutline": { borderColor: "#444" },
|
|
||||||
"&:hover .MuiOutlinedInput-notchedOutline": { borderColor: "#666" }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{TYPE_OPTIONS.map((o) => (
|
|
||||||
<MenuItem key={o.key} value={o.key}>{o.label}</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<Box sx={{ flex: 1 }} />
|
|
||||||
|
|
||||||
{fileName && (
|
|
||||||
<Button
|
|
||||||
onClick={() => { setRenameValue(fileName); setRenameOpen(true); }}
|
|
||||||
sx={{ color: "#58a6ff", textTransform: "none" }}
|
|
||||||
>
|
|
||||||
Rename: {fileName}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={saveScript}
|
|
||||||
sx={{
|
|
||||||
color: "#58a6ff",
|
|
||||||
borderColor: "#58a6ff",
|
|
||||||
textTransform: "none",
|
|
||||||
border: "1px solid #58a6ff",
|
|
||||||
backgroundColor: "#1e1e1e",
|
|
||||||
"&:hover": { backgroundColor: "#1b1b1b" }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Single-pane live-highlight editor */}
|
|
||||||
<Box sx={{ flex: 1, minHeight: 300, border: "1px solid #444", borderRadius: 1, background: "#121212", overflow: "auto" }}>
|
|
||||||
<Editor
|
|
||||||
value={code}
|
|
||||||
onValueChange={setCode}
|
|
||||||
highlight={(src) => highlightedHtml(src, prismLang)}
|
|
||||||
padding={12}
|
|
||||||
placeholder={currentPath ? `Editing: ${currentPath}` : "New Script..."}
|
|
||||||
style={{
|
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
|
||||||
fontSize: 14,
|
|
||||||
color: "#e6edf3",
|
|
||||||
background: "#121212",
|
|
||||||
outline: "none",
|
|
||||||
minHeight: 300,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
caretColor: "#58a6ff"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* Dialogs */}
|
|
||||||
<RenameScriptDialog
|
|
||||||
open={renameOpen}
|
|
||||||
value={renameValue}
|
|
||||||
onChange={setRenameValue}
|
|
||||||
onCancel={() => setRenameOpen(false)}
|
|
||||||
onSave={saveRenameFile}
|
|
||||||
onBlur={() => setRenameValue((v) => ensureExt(v, type))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RenameFolderDialog
|
|
||||||
open={renameFolderOpen}
|
|
||||||
value={renameValue}
|
|
||||||
onChange={setRenameValue}
|
|
||||||
onCancel={() => setRenameFolderOpen(false)}
|
|
||||||
onSave={saveRenameFolder}
|
|
||||||
title={folderDialogMode === "rename" ? "Rename Folder" : "New Folder"}
|
|
||||||
confirmText={folderDialogMode === "rename" ? "Save" : "Create"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<NewScriptDialog
|
|
||||||
open={newScriptOpen}
|
|
||||||
name={newScriptName}
|
|
||||||
type={newScriptType}
|
|
||||||
onChangeName={setNewScriptName}
|
|
||||||
onChangeType={setNewScriptType}
|
|
||||||
onCancel={() => setNewScriptOpen(false)}
|
|
||||||
onCreate={createNewScript}
|
|
||||||
onBlurName={() => setNewScriptName((v) => ensureExt(v, newScriptType))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDeleteDialog
|
|
||||||
open={deleteOpen}
|
|
||||||
message="If you delete this, there is no undo button, are you sure you want to proceed?"
|
|
||||||
onCancel={() => setDeleteOpen(false)}
|
|
||||||
onConfirm={confirmDelete}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
////////// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
|
||||||
import { Paper, Box, Typography, Menu, MenuItem } from "@mui/material";
|
|
||||||
import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material";
|
|
||||||
import {
|
|
||||||
SimpleTreeView,
|
|
||||||
TreeItem,
|
|
||||||
useTreeViewApiRef
|
|
||||||
} from "@mui/x-tree-view";
|
|
||||||
import {
|
|
||||||
RenameWorkflowDialog,
|
|
||||||
RenameFolderDialog,
|
|
||||||
NewWorkflowDialog,
|
|
||||||
ConfirmDeleteDialog
|
|
||||||
} from "../Dialogs";
|
|
||||||
|
|
||||||
function buildTree(workflows, folders) {
|
|
||||||
const map = {};
|
|
||||||
const rootNode = {
|
|
||||||
id: "root",
|
|
||||||
label: "Workflows",
|
|
||||||
path: "",
|
|
||||||
isFolder: true,
|
|
||||||
children: []
|
|
||||||
};
|
|
||||||
map[rootNode.id] = rootNode;
|
|
||||||
|
|
||||||
(folders || []).forEach((f) => {
|
|
||||||
const parts = (f || "").split("/");
|
|
||||||
let children = rootNode.children;
|
|
||||||
let parentPath = "";
|
|
||||||
parts.forEach((part) => {
|
|
||||||
const path = parentPath ? `${parentPath}/${part}` : part;
|
|
||||||
let node = children.find((n) => n.id === path);
|
|
||||||
if (!node) {
|
|
||||||
node = { id: path, label: part, path, isFolder: true, children: [] };
|
|
||||||
children.push(node);
|
|
||||||
map[path] = node;
|
|
||||||
}
|
|
||||||
children = node.children;
|
|
||||||
parentPath = path;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
(workflows || []).forEach((w) => {
|
|
||||||
const parts = (w.rel_path || "").split("/");
|
|
||||||
let children = rootNode.children;
|
|
||||||
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: [rootNode], map };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WorkflowList({ onOpenWorkflow }) {
|
|
||||||
const [tree, setTree] = useState([]);
|
|
||||||
const [nodeMap, setNodeMap] = useState({});
|
|
||||||
const [contextMenu, setContextMenu] = useState(null);
|
|
||||||
const [selectedNode, setSelectedNode] = useState(null);
|
|
||||||
const [renameValue, setRenameValue] = useState("");
|
|
||||||
const [renameOpen, setRenameOpen] = useState(false);
|
|
||||||
const [renameFolderOpen, setRenameFolderOpen] = useState(false);
|
|
||||||
const [folderDialogMode, setFolderDialogMode] = useState("rename");
|
|
||||||
const [newWorkflowOpen, setNewWorkflowOpen] = useState(false);
|
|
||||||
const [newWorkflowName, setNewWorkflowName] = useState("");
|
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
||||||
const apiRef = useTreeViewApiRef();
|
|
||||||
const [dragNode, setDragNode] = useState(null);
|
|
||||||
|
|
||||||
const handleDrop = async (target) => {
|
|
||||||
if (!dragNode || !target.isFolder) return;
|
|
||||||
// Prevent dropping into itself or its descendants
|
|
||||||
if (dragNode.path === target.path || target.path.startsWith(`${dragNode.path}/`)) {
|
|
||||||
setDragNode(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newPath = target.path ? `${target.path}/${dragNode.fileName}` : dragNode.fileName;
|
|
||||||
try {
|
|
||||||
await fetch("/api/storage/move_workflow", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ path: dragNode.path, new_path: newPath })
|
|
||||||
});
|
|
||||||
loadTree();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to move workflow:", err);
|
|
||||||
}
|
|
||||||
setDragNode(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 { root, map } = buildTree(data.workflows || [], data.folders || []);
|
|
||||||
setTree(root);
|
|
||||||
setNodeMap(map);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load workflows:", err);
|
|
||||||
setTree([]);
|
|
||||||
setNodeMap({});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadTree();
|
|
||||||
}, [loadTree]);
|
|
||||||
|
|
||||||
const handleContextMenu = (e, node) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedNode(node);
|
|
||||||
setContextMenu(
|
|
||||||
contextMenu === null
|
|
||||||
? {
|
|
||||||
mouseX: e.clientX - 2,
|
|
||||||
mouseY: e.clientY - 4
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRename = () => {
|
|
||||||
setContextMenu(null);
|
|
||||||
if (!selectedNode) return;
|
|
||||||
setRenameValue(selectedNode.label);
|
|
||||||
if (selectedNode.isFolder) {
|
|
||||||
setFolderDialogMode("rename");
|
|
||||||
setRenameFolderOpen(true);
|
|
||||||
} else setRenameOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = () => {
|
|
||||||
setContextMenu(null);
|
|
||||||
if (selectedNode && !selectedNode.isFolder && onOpenWorkflow) {
|
|
||||||
onOpenWorkflow(selectedNode.workflow);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
setContextMenu(null);
|
|
||||||
if (!selectedNode) return;
|
|
||||||
setDeleteOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNewFolder = () => {
|
|
||||||
if (!selectedNode) return;
|
|
||||||
setContextMenu(null);
|
|
||||||
setFolderDialogMode("create");
|
|
||||||
setRenameValue("");
|
|
||||||
setRenameFolderOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNewWorkflow = () => {
|
|
||||||
if (!selectedNode) return;
|
|
||||||
setContextMenu(null);
|
|
||||||
setNewWorkflowName("");
|
|
||||||
setNewWorkflowOpen(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: selectedNode.path, new_name: renameValue })
|
|
||||||
});
|
|
||||||
loadTree();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to rename workflow:", err);
|
|
||||||
}
|
|
||||||
setRenameOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveRenameFolder = async () => {
|
|
||||||
try {
|
|
||||||
if (folderDialogMode === "rename" && selectedNode) {
|
|
||||||
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 confirmDelete = async () => {
|
|
||||||
if (!selectedNode) return;
|
|
||||||
try {
|
|
||||||
if (selectedNode.isFolder) {
|
|
||||||
await fetch("/api/storage/delete_folder", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ path: selectedNode.path })
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
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:", err);
|
|
||||||
}
|
|
||||||
setDeleteOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderItems = (nodes) =>
|
|
||||||
nodes.map((n) => (
|
|
||||||
<TreeItem
|
|
||||||
key={n.id}
|
|
||||||
itemId={n.id}
|
|
||||||
label={
|
|
||||||
<Box
|
|
||||||
sx={{ display: "flex", alignItems: "center" }}
|
|
||||||
draggable={!n.isFolder}
|
|
||||||
onDragStart={() => !n.isFolder && setDragNode(n)}
|
|
||||||
onDragOver={(e) => {
|
|
||||||
if (dragNode && n.isFolder) e.preventDefault();
|
|
||||||
}}
|
|
||||||
onDrop={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleDrop(n);
|
|
||||||
}}
|
|
||||||
onContextMenu={(e) => handleContextMenu(e, n)}
|
|
||||||
>
|
|
||||||
{n.isFolder ? (
|
|
||||||
<FolderIcon sx={{ mr: 1, color: "#0475c2" }} />
|
|
||||||
) : (
|
|
||||||
<DescriptionIcon sx={{ mr: 1, color: "#0475c2" }} />
|
|
||||||
)}
|
|
||||||
<Typography sx={{ flexGrow: 1, color: "#e6edf3" }}>{n.label}</Typography>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{n.children && n.children.length > 0 ? renderItems(n.children) : null}
|
|
||||||
</TreeItem>
|
|
||||||
));
|
|
||||||
|
|
||||||
const rootChildIds = tree[0]?.children?.map((c) => c.id) || [];
|
|
||||||
|
|
||||||
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" }}>
|
|
||||||
Create, edit, and rearrange workflows within an organized folder structure.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box />
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{ p: 2 }}
|
|
||||||
onDragOver={(e) => {
|
|
||||||
if (dragNode) e.preventDefault();
|
|
||||||
}}
|
|
||||||
onDrop={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleDrop({ path: "", isFolder: true });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SimpleTreeView
|
|
||||||
key={rootChildIds.join(",")}
|
|
||||||
sx={{ color: "#e6edf3" }}
|
|
||||||
onNodeSelect={handleNodeSelect}
|
|
||||||
apiRef={apiRef}
|
|
||||||
defaultExpandedItems={["root", ...rootChildIds]}
|
|
||||||
>
|
|
||||||
{renderItems(tree)}
|
|
||||||
</SimpleTreeView>
|
|
||||||
</Box>
|
|
||||||
<Menu
|
|
||||||
open={contextMenu !== null}
|
|
||||||
onClose={() => setContextMenu(null)}
|
|
||||||
anchorReference="anchorPosition"
|
|
||||||
anchorPosition=
|
|
||||||
{contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined}
|
|
||||||
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
|
|
||||||
>
|
|
||||||
{selectedNode?.isFolder && (
|
|
||||||
<>
|
|
||||||
<MenuItem onClick={handleNewWorkflow}>New Workflow</MenuItem>
|
|
||||||
<MenuItem onClick={handleNewFolder}>New Subfolder</MenuItem>
|
|
||||||
{selectedNode.id !== "root" && (
|
|
||||||
<MenuItem onClick={handleRename}>Rename</MenuItem>
|
|
||||||
)}
|
|
||||||
{selectedNode.id !== "root" && (
|
|
||||||
<MenuItem onClick={handleDelete}>Delete</MenuItem>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!selectedNode?.isFolder && (
|
|
||||||
<>
|
|
||||||
<MenuItem onClick={handleEdit}>Edit</MenuItem>
|
|
||||||
<MenuItem onClick={handleRename}>Rename</MenuItem>
|
|
||||||
<MenuItem onClick={handleDelete}>Delete</MenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Menu>
|
|
||||||
<RenameWorkflowDialog
|
|
||||||
open={renameOpen}
|
|
||||||
value={renameValue}
|
|
||||||
onChange={setRenameValue}
|
|
||||||
onCancel={() => setRenameOpen(false)}
|
|
||||||
onSave={saveRenameWorkflow}
|
|
||||||
/>
|
|
||||||
<RenameFolderDialog
|
|
||||||
open={renameFolderOpen}
|
|
||||||
value={renameValue}
|
|
||||||
onChange={setRenameValue}
|
|
||||||
onCancel={() => setRenameFolderOpen(false)}
|
|
||||||
onSave={saveRenameFolder}
|
|
||||||
title={folderDialogMode === "rename" ? "Rename Folder" : "New Folder"}
|
|
||||||
confirmText={folderDialogMode === "rename" ? "Save" : "Create"}
|
|
||||||
/>
|
|
||||||
<NewWorkflowDialog
|
|
||||||
open={newWorkflowOpen}
|
|
||||||
value={newWorkflowName}
|
|
||||||
onChange={setNewWorkflowName}
|
|
||||||
onCancel={() => setNewWorkflowOpen(false)}
|
|
||||||
onCreate={() => {
|
|
||||||
setNewWorkflowOpen(false);
|
|
||||||
onOpenWorkflow && onOpenWorkflow(null, selectedNode.path, newWorkflowName);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ConfirmDeleteDialog
|
|
||||||
open={deleteOpen}
|
|
||||||
message="If you delete this, there is no undo button, are you sure you want to proceed?"
|
|
||||||
onCancel={() => setDeleteOpen(false)}
|
|
||||||
onConfirm={confirmDelete}
|
|
||||||
/>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1016,6 +1016,223 @@ def delete_script_file():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
# ---------------------------------------------
|
||||||
|
# Ansible Playbooks Storage API Endpoints
|
||||||
|
# ---------------------------------------------
|
||||||
|
def _ansible_root() -> str:
|
||||||
|
return os.path.abspath(
|
||||||
|
os.path.join(os.path.dirname(__file__), "..", "..", "Assemblies", "Ansible_Playbooks")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_ansible_relpath(rel_path: str) -> bool:
|
||||||
|
try:
|
||||||
|
p = (rel_path or "").replace("\\", "/").lstrip("/")
|
||||||
|
# allow any subpath; prevent empty
|
||||||
|
return bool(p)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/ansible/list", methods=["GET"])
|
||||||
|
def list_ansible():
|
||||||
|
"""Scan <ProjectRoot>/Assemblies/Ansible_Playbooks for .yml playbooks and return list + folders."""
|
||||||
|
root = _ansible_root()
|
||||||
|
results: List[Dict] = []
|
||||||
|
folders: List[str] = []
|
||||||
|
if not os.path.isdir(root):
|
||||||
|
os.makedirs(root, exist_ok=True)
|
||||||
|
return jsonify({ "root": root, "items": [], "folders": [] }), 200
|
||||||
|
for r, dirs, files in os.walk(root):
|
||||||
|
rel_root = os.path.relpath(r, root)
|
||||||
|
if rel_root != ".":
|
||||||
|
folders.append(rel_root.replace(os.sep, "/"))
|
||||||
|
for fname in files:
|
||||||
|
if not fname.lower().endswith(".yml"):
|
||||||
|
continue
|
||||||
|
full_path = os.path.join(r, fname)
|
||||||
|
rel_path = os.path.relpath(full_path, root).replace(os.sep, "/")
|
||||||
|
try:
|
||||||
|
mtime = os.path.getmtime(full_path)
|
||||||
|
except Exception:
|
||||||
|
mtime = 0.0
|
||||||
|
results.append({
|
||||||
|
"file_name": fname,
|
||||||
|
"rel_path": rel_path,
|
||||||
|
"type": "ansible",
|
||||||
|
"last_edited": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(mtime)),
|
||||||
|
"last_edited_epoch": mtime
|
||||||
|
})
|
||||||
|
results.sort(key=lambda x: x.get("last_edited_epoch", 0.0), reverse=True)
|
||||||
|
return jsonify({ "root": root, "items": results, "folders": folders })
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/ansible/load", methods=["GET"])
|
||||||
|
def load_ansible():
|
||||||
|
rel_path = request.args.get("path", "")
|
||||||
|
root = _ansible_root()
|
||||||
|
abs_path = os.path.abspath(os.path.join(root, rel_path))
|
||||||
|
if (not abs_path.startswith(root)) or (not _is_valid_ansible_relpath(rel_path)) or (not os.path.isfile(abs_path)):
|
||||||
|
return jsonify({"error": "Playbook not found"}), 404
|
||||||
|
try:
|
||||||
|
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
|
||||||
|
content = fh.read()
|
||||||
|
return jsonify({
|
||||||
|
"file_name": os.path.basename(abs_path),
|
||||||
|
"rel_path": os.path.relpath(abs_path, root).replace(os.sep, "/"),
|
||||||
|
"type": "ansible",
|
||||||
|
"content": content
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/ansible/save", methods=["POST"])
|
||||||
|
def save_ansible():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
rel_path = (data.get("path") or "").strip()
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
content = data.get("content")
|
||||||
|
if content is None:
|
||||||
|
return jsonify({"error": "Missing content"}), 400
|
||||||
|
root = _ansible_root()
|
||||||
|
os.makedirs(root, exist_ok=True)
|
||||||
|
if rel_path:
|
||||||
|
base, ext = os.path.splitext(rel_path)
|
||||||
|
if not ext:
|
||||||
|
rel_path = base + ".yml"
|
||||||
|
abs_path = os.path.abspath(os.path.join(root, rel_path))
|
||||||
|
else:
|
||||||
|
if not name:
|
||||||
|
return jsonify({"error": "Missing name"}), 400
|
||||||
|
ext = os.path.splitext(name)[1]
|
||||||
|
if not ext:
|
||||||
|
name = os.path.splitext(name)[0] + ".yml"
|
||||||
|
abs_path = os.path.abspath(os.path.join(root, os.path.basename(name)))
|
||||||
|
if not abs_path.startswith(root):
|
||||||
|
return jsonify({"error": "Invalid path"}), 400
|
||||||
|
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||||
|
try:
|
||||||
|
with open(abs_path, "w", encoding="utf-8", newline="\n") as fh:
|
||||||
|
fh.write(str(content))
|
||||||
|
rel_new = os.path.relpath(abs_path, root).replace(os.sep, "/")
|
||||||
|
return jsonify({"status": "ok", "rel_path": rel_new})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/ansible/rename_file", methods=["POST"])
|
||||||
|
def rename_ansible_file():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
rel_path = (data.get("path") or "").strip()
|
||||||
|
new_name = (data.get("new_name") or "").strip()
|
||||||
|
root = _ansible_root()
|
||||||
|
old_abs = os.path.abspath(os.path.join(root, rel_path))
|
||||||
|
if not old_abs.startswith(root) or not os.path.isfile(old_abs):
|
||||||
|
return jsonify({"error": "File not found"}), 404
|
||||||
|
if not new_name:
|
||||||
|
return jsonify({"error": "Invalid new_name"}), 400
|
||||||
|
if not os.path.splitext(new_name)[1]:
|
||||||
|
new_name = os.path.splitext(new_name)[0] + ".yml"
|
||||||
|
new_abs = os.path.join(os.path.dirname(old_abs), os.path.basename(new_name))
|
||||||
|
try:
|
||||||
|
os.rename(old_abs, new_abs)
|
||||||
|
rel_new = os.path.relpath(new_abs, root).replace(os.sep, "/")
|
||||||
|
return jsonify({"status": "ok", "rel_path": rel_new})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/ansible/move_file", methods=["POST"])
|
||||||
|
def move_ansible_file():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
rel_path = (data.get("path") or "").strip()
|
||||||
|
new_rel = (data.get("new_path") or "").strip()
|
||||||
|
root = _ansible_root()
|
||||||
|
old_abs = os.path.abspath(os.path.join(root, rel_path))
|
||||||
|
new_abs = os.path.abspath(os.path.join(root, new_rel))
|
||||||
|
if not old_abs.startswith(root) or not os.path.isfile(old_abs):
|
||||||
|
return jsonify({"error": "File not found"}), 404
|
||||||
|
if (not new_abs.startswith(root)) or (not _is_valid_ansible_relpath(new_rel)):
|
||||||
|
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/ansible/delete_file", methods=["POST"])
|
||||||
|
def delete_ansible_file():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
rel_path = (data.get("path") or "").strip()
|
||||||
|
root = _ansible_root()
|
||||||
|
abs_path = os.path.abspath(os.path.join(root, rel_path))
|
||||||
|
if (not abs_path.startswith(root)) or (not _is_valid_ansible_relpath(rel_path)) or (not os.path.isfile(abs_path)):
|
||||||
|
return jsonify({"error": "File 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/ansible/create_folder", methods=["POST"])
|
||||||
|
def ansible_create_folder():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
rel_path = (data.get("path") or "").strip()
|
||||||
|
root = _ansible_root()
|
||||||
|
rel_path = (rel_path or "").replace("\\", "/").strip("/")
|
||||||
|
abs_path = os.path.abspath(os.path.join(root, rel_path))
|
||||||
|
if not abs_path.startswith(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/ansible/delete_folder", methods=["POST"])
|
||||||
|
def ansible_delete_folder():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
rel_path = (data.get("path") or "").strip()
|
||||||
|
root = _ansible_root()
|
||||||
|
abs_path = os.path.abspath(os.path.join(root, rel_path))
|
||||||
|
if (not abs_path.startswith(root)) or (not _is_valid_ansible_relpath(rel_path)) or (not os.path.isdir(abs_path)):
|
||||||
|
return jsonify({"error": "Folder not found"}), 404
|
||||||
|
rel_norm = (rel_path or "").replace("\\", "/").strip("/")
|
||||||
|
if rel_norm in ("",):
|
||||||
|
return jsonify({"error": "Cannot delete top-level folder"}), 400
|
||||||
|
try:
|
||||||
|
shutil.rmtree(abs_path)
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/ansible/rename_folder", methods=["POST"])
|
||||||
|
def ansible_rename_folder():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
rel_path = (data.get("path") or "").strip()
|
||||||
|
new_name = (data.get("new_name") or "").strip()
|
||||||
|
root = _ansible_root()
|
||||||
|
old_abs = os.path.abspath(os.path.join(root, rel_path))
|
||||||
|
if not old_abs.startswith(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
|
||||||
|
rel_norm = (rel_path or "").replace("\\", "/").strip("/")
|
||||||
|
if rel_norm in ("",):
|
||||||
|
return jsonify({"error": "Cannot rename top-level folder"}), 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
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/scripts/create_folder", methods=["POST"])
|
@app.route("/api/scripts/create_folder", methods=["POST"])
|
||||||
def scripts_create_folder():
|
def scripts_create_folder():
|
||||||
|
|||||||
Reference in New Issue
Block a user