mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-27 20:21:57 -06:00
Assembly Structural Overhaul
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user