diff --git a/Assemblies/Scripts/Coupon.ps1 b/Assemblies/Scripts/Coupon.ps1 new file mode 100644 index 0000000..8318c86 --- /dev/null +++ b/Assemblies/Scripts/Coupon.ps1 @@ -0,0 +1 @@ +Test \ No newline at end of file diff --git a/Data/Server/WebUI/src/App.jsx b/Data/Server/WebUI/src/App.jsx index e466c71..6471341 100644 --- a/Data/Server/WebUI/src/App.jsx +++ b/Data/Server/WebUI/src/App.jsx @@ -35,8 +35,8 @@ import Login from "./Login.jsx"; import SiteList from "./Sites/Site_List"; import DeviceList from "./Devices/Device_List"; import DeviceDetails from "./Devices/Device_Details"; -import WorkflowList from "./Workflows/Workflow_List"; -import ScriptEditor from "./Scripting/Script_Editor"; +import AssemblyList from "./Assemblies/Assembly_List"; +import ScriptEditor from "./Assemblies/Script_Editor"; import ScheduledJobsList from "./Scheduling/Scheduled_Jobs_List"; import CreateJob from "./Scheduling/Create_Job.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 [editingJob, setEditingJob] = useState(null); const [jobsRefreshToken, setJobsRefreshToken] = useState(0); + const [scriptToEdit, setScriptToEdit] = useState(null); // { path, mode: 'scripts'|'ansible' } const [notAuthorizedOpen, setNotAuthorizedOpen] = useState(false); // Top-bar search state @@ -151,7 +152,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; // Build breadcrumb items for current view const breadcrumbs = React.useMemo(() => { const items = []; - switch (currentPage) { + switch (currentPage) { case "sites": items.push({ label: "Sites", 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: "Scripts", page: "scripts" }); 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": items.push({ label: "Automation", page: "jobs" }); items.push({ label: "Community Content", page: "community" }); @@ -595,63 +605,81 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; case "workflows": return ( - { const newId = "flow_" + Date.now(); if (workflow && workflow.rel_path) { - const folder = workflow.rel_path - .split("/") - .slice(0, -1) - .join("/"); + const folder = workflow.rel_path.split("/").slice(0, -1).join("/"); try { - const resp = await fetch( - `/api/storage/load_workflow?path=${encodeURIComponent( - workflow.rel_path - )}` - ); + 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 - } - ]); + 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 - } - ]); + setTabs([{ id: newId, tab_name: workflow?.name || "Workflow", nodes: [], edges: [], folderPath: folder }]); } } else { - setTabs([ - { - id: newId, - tab_name: name || "Flow", - nodes: [], - edges: [], - folderPath: folderPath || "" - } - ]); + 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 "assemblies": + return ( + { + 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": - return ; + return ( + setScriptToEdit(null)} + onSaved={() => setCurrentPage('assemblies')} + /> + ); + + case "ansible_editor": + return ( + setScriptToEdit(null)} + onSaved={() => setCurrentPage('assemblies')} + /> + ); case "admin_users": return ; diff --git a/Data/Server/WebUI/src/Assemblies/Assembly_List.jsx b/Data/Server/WebUI/src/Assemblies/Assembly_List.jsx new file mode 100644 index 0000000..4351cec --- /dev/null +++ b/Data/Server/WebUI/src/Assemblies/Assembly_List.jsx @@ -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 }) => ( + + + {icon ? {icon} : null} + {title} + + {description ? ( + {description} + ) : null} + {children} + +); + +// ---------------- 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) => ( + !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 ? ( + + ) : ( + + )} + {n.label} + + } + > + {n.children && n.children.length > 0 ? renderItems(n.children) : null} + + )); + + const rootChildIds = tree[0]?.children?.map((c) => c.id) || []; + + return ( + }> + { if (dragNode) e.preventDefault(); }} + onDrop={(e) => { e.preventDefault(); handleDrop({ path: "", isFolder: true }); }} + > + + {renderItems(tree)} + + + setContextMenu(null)} + anchorReference="anchorPosition" + anchorPosition={contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined} + PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }} + > + {selectedNode?.isFolder && ( + <> + New Workflow + New Subfolder + {selectedNode.id !== "root" && (Rename)} + {selectedNode.id !== "root" && (Delete)} + + )} + {!selectedNode?.isFolder && ( + <> + Edit + Rename + Delete + + )} + + setRenameOpen(false)} onSave={saveRenameWorkflow} /> + setRenameFolderOpen(false)} onSave={saveRenameFolder} title={folderDialogMode === "rename" ? "Rename Folder" : "New Folder"} confirmText={folderDialogMode === "rename" ? "Save" : "Create"} /> + setNewWorkflowOpen(false)} onCreate={() => { setNewWorkflowOpen(false); onOpenWorkflow && onOpenWorkflow(null, selectedNode?.path || "", newWorkflowName); }} /> + setDeleteOpen(false)} onConfirm={confirmDelete} /> + + ); +} + +// ---------------- 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) => ( + !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 ? ( + + ) : ( + + )} + {n.label} + + } + > + {n.children && n.children.length > 0 ? renderItems(n.children) : null} + + )); + + const rootChildIds = tree[0]?.children?.map((c) => c.id) || []; + + return ( + : }> + + + + { if (dragNode) e.preventDefault(); }} + onDrop={(e) => { e.preventDefault(); handleDrop({ path: "", isFolder: true }); }} + > + + {renderItems(tree)} + + + setContextMenu(null)} + anchorReference="anchorPosition" + anchorPosition={contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined} + PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }} + > + {selectedNode?.isFolder && ( + <> + { setContextMenu(null); setNewItemOpen(true); }}>{newItemLabel} + { setContextMenu(null); setFolderDialogMode("create"); setRenameValue(""); setRenameFolderOpen(true); }}>New Subfolder + {selectedNode.id !== "root" && ( { setContextMenu(null); setRenameValue(selectedNode.label); setRenameOpen(true); }}>Rename)} + {selectedNode.id !== "root" && ( { setContextMenu(null); setDeleteOpen(true); }}>Delete)} + + )} + {!selectedNode?.isFolder && ( + <> + { setContextMenu(null); onEdit && onEdit(selectedNode.path); }}>Edit + { setContextMenu(null); setRenameValue(selectedNode.label); setRenameOpen(true); }}>Rename + { setContextMenu(null); setDeleteOpen(true); }}>Delete + + )} + + {/* Simple inline dialogs using shared components */} + setRenameFolderOpen(false)} onSave={saveRenameFolder} title={folderDialogMode === "rename" ? "Rename Folder" : "New Folder"} confirmText={folderDialogMode === "rename" ? "Save" : "Create"} /> + {/* File rename */} +
} sx={{ display: renameOpen ? 'block' : 'none' }}> +
+ + Rename + setRenameValue(e.target.value)} style={{ width: '100%', padding: 8, background: '#2a2a2a', color: '#ccc', border: '1px solid #444', borderRadius: 4 }} /> + + + + + +
+ +
} sx={{ display: newItemOpen ? 'block' : 'none' }}> +
+ + {newItemLabel} + setNewItemName(e.target.value)} placeholder="Name" style={{ width: '100%', padding: 8, background: '#2a2a2a', color: '#ccc', border: '1px solid #444', borderRadius: 4 }} /> + + + + + +
+ + setDeleteOpen(false)} onConfirm={confirmDelete} /> + + ); +} + +export default function AssemblyList({ onOpenWorkflow, onOpenScript }) { + return ( + + + Assemblies + Collections of components used to perform various actions upon targeted devices. + + + {/* Left: Workflows */} + + + {/* Middle: Scripts */} + onOpenScript && onOpenScript(rel, 'scripts')} + /> + + {/* Right: Ansible Playbooks */} + onOpenScript && onOpenScript(rel, 'ansible')} + /> + + + ); +} diff --git a/Data/Server/WebUI/src/Assemblies/Script_Editor.jsx b/Data/Server/WebUI/src/Assemblies/Script_Editor.jsx new file mode 100644 index 0000000..ad4272b --- /dev/null +++ b/Data/Server/WebUI/src/Assemblies/Script_Editor.jsx @@ -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 ( +
+ + Rename + onChange(e.target.value)} + sx={{ "& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } }, label: { color: "#aaa" }, mt: 1 }} /> + + + + + +
+ ); +} + +function NewItemDialog({ open, name, type, typeOptions, onChangeName, onChangeType, onCancel, onCreate }) { + if (!open) return null; + return ( +
+ + New + onChangeName(e.target.value)} + sx={{ "& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } }, label: { color: "#aaa" }, mt: 1 }} /> + + Type + + + + + + + +
+ ); +} + +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 ( + + + + + Type + + + + {fileName && ( + + )} + + + + 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" }} /> + + + + {/* Dialogs */} + setRenameOpen(false)} onSave={saveRenameFile} /> + setNewOpen(false)} onCreate={createNew} /> + setDeleteOpen(false)} onConfirm={() => { setDeleteOpen(false); onSaved && onSaved(); }} /> + + ); +} + diff --git a/Data/Server/WebUI/src/Navigation_Sidebar.jsx b/Data/Server/WebUI/src/Navigation_Sidebar.jsx index 3601cff..eda1a09 100644 --- a/Data/Server/WebUI/src/Navigation_Sidebar.jsx +++ b/Data/Server/WebUI/src/Navigation_Sidebar.jsx @@ -18,7 +18,8 @@ import { Work as JobsIcon, Polyline as WorkflowsIcon, Code as ScriptIcon, - PeopleOutline as CommunityIcon + PeopleOutline as CommunityIcon, + Apps as AssembliesIcon } from "@mui/icons-material"; import { LocationCity as SitesIcon } 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 */} {(() => { - const groupActive = ["jobs", "scripts", "workflows", "community"].includes(currentPage); + const groupActive = ["jobs", "assemblies", "community"].includes(currentPage); return ( } label="Scheduled Jobs" pageKey="jobs" /> - } label="Scripts" pageKey="scripts" /> - } label="Workflows" pageKey="workflows" /> + } label="Assemblies" pageKey="assemblies" /> } label="Community Content" pageKey="community" /> diff --git a/Data/Server/WebUI/src/Scripting/Script_Editor.jsx b/Data/Server/WebUI/src/Scripting/Script_Editor.jsx deleted file mode 100644 index 151c0e1..0000000 --- a/Data/Server/WebUI/src/Scripting/Script_Editor.jsx +++ /dev/null @@ -1,693 +0,0 @@ -////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: /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 ( - ( -
- )} - sx={{ display: open ? "block" : "none" }} - > -
- - Rename Script - 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 - }} - /> - - - - - -
- - ); -} - -// Local dialog: new script (name + type) -function NewScriptDialog({ open, name, type, onChangeName, onChangeType, onCancel, onCreate, onBlurName }) { - return ( -
} sx={{ display: open ? "block" : "none" }}> -
- - New Script - 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 - }} - /> - - Type - - - - - - - -
- - ); -} - -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) => ( - !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 ? ( - - ) : ( - - )} - {n.label} - - } - > - {n.children && n.children.length > 0 ? renderItems(n.children) : null} - - )); - - return ( - - {/* Left: Tree */} - - - Scripts - - Create, edit, and organize various types of scripts. - - - { if (dragNode) e.preventDefault(); }} - onDrop={(e) => { e.preventDefault(); handleDrop({ path: "", isFolder: true }); }} - > - - {renderItems(tree)} - - - setContextMenu(null)} - anchorReference="anchorPosition" - anchorPosition={contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined} - PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }} - > - {selectedNode?.isFolder && ( - <> - New Script - New Subfolder - {selectedNode.id !== "root" && ( - Rename - )} - {selectedNode.id !== "root" && ( - { setContextMenu(null); setDeleteOpen(true); }}>Delete - )} - - )} - {!selectedNode?.isFolder && ( - <> - { setContextMenu(null); handleNodeSelect(null, selectedNode.id); }}>Edit - Rename - { setContextMenu(null); setDeleteOpen(true); }}>Delete - - )} - - - - {/* Right: Editor */} - - - - Type - - - - - - {fileName && ( - - )} - - - - - {/* Single-pane live-highlight editor */} - - 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" - }} - /> - - - - {/* Dialogs */} - setRenameOpen(false)} - onSave={saveRenameFile} - onBlur={() => setRenameValue((v) => ensureExt(v, type))} - /> - - setRenameFolderOpen(false)} - onSave={saveRenameFolder} - title={folderDialogMode === "rename" ? "Rename Folder" : "New Folder"} - confirmText={folderDialogMode === "rename" ? "Save" : "Create"} - /> - - setNewScriptOpen(false)} - onCreate={createNewScript} - onBlurName={() => setNewScriptName((v) => ensureExt(v, newScriptType))} - /> - - setDeleteOpen(false)} - onConfirm={confirmDelete} - /> - - ); -} diff --git a/Data/Server/WebUI/src/Scripting/Script_List.jsx b/Data/Server/WebUI/src/Scripting/Script_List.jsx deleted file mode 100644 index 7998743..0000000 --- a/Data/Server/WebUI/src/Scripting/Script_List.jsx +++ /dev/null @@ -1,127 +0,0 @@ -////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: /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 ( - - - - - Scripts - - - List of available automation scripts. - - - - - - - - - handleSort("name")} - > - Name - - - - handleSort("description")} - > - Description - - - - handleSort("category")} - > - Category - - - - handleSort("lastEdited")} - > - Last Edited - - - - - - {sorted.map((r, i) => ( - - {r.name} - {r.description} - {r.category} - {r.lastEdited} - - ))} - {sorted.length === 0 && ( - - - No scripts found. - - - )} - -
-
- ); -} diff --git a/Data/Server/WebUI/src/Workflows/Workflow_List.jsx b/Data/Server/WebUI/src/Workflows/Workflow_List.jsx deleted file mode 100644 index fa4d83a..0000000 --- a/Data/Server/WebUI/src/Workflows/Workflow_List.jsx +++ /dev/null @@ -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) => ( - !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 ? ( - - ) : ( - - )} - {n.label} - - } - > - {n.children && n.children.length > 0 ? renderItems(n.children) : null} - - )); - - const rootChildIds = tree[0]?.children?.map((c) => c.id) || []; - - return ( - - - - - Workflows - - - Create, edit, and rearrange workflows within an organized folder structure. - - - - - { - if (dragNode) e.preventDefault(); - }} - onDrop={(e) => { - e.preventDefault(); - handleDrop({ path: "", isFolder: true }); - }} - > - - {renderItems(tree)} - - - setContextMenu(null)} - anchorReference="anchorPosition" - anchorPosition= - {contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined} - PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }} - > - {selectedNode?.isFolder && ( - <> - New Workflow - New Subfolder - {selectedNode.id !== "root" && ( - Rename - )} - {selectedNode.id !== "root" && ( - Delete - )} - - )} - {!selectedNode?.isFolder && ( - <> - Edit - Rename - Delete - - )} - - setRenameOpen(false)} - onSave={saveRenameWorkflow} - /> - setRenameFolderOpen(false)} - onSave={saveRenameFolder} - title={folderDialogMode === "rename" ? "Rename Folder" : "New Folder"} - confirmText={folderDialogMode === "rename" ? "Save" : "Create"} - /> - setNewWorkflowOpen(false)} - onCreate={() => { - setNewWorkflowOpen(false); - onOpenWorkflow && onOpenWorkflow(null, selectedNode.path, newWorkflowName); - }} - /> - setDeleteOpen(false)} - onConfirm={confirmDelete} - /> - - ); -} diff --git a/Data/Server/server.py b/Data/Server/server.py index de8ce66..bfc47c6 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -1016,6 +1016,223 @@ def delete_script_file(): except Exception as e: 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 /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"]) def scripts_create_folder():