ENGINE: Web Asset Fixes

This commit is contained in:
2025-11-01 04:07:15 -06:00
parent 0e13358c7e
commit b815592639
55764 changed files with 22585 additions and 936312 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,777 @@
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 with large icon, stacked title/description, and actions on the right
const Island = ({ title, description, icon, actions, 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: 'flex-start', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{icon ? (
<Box
sx={{
color: '#58a6ff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 48,
mr: 1.0,
}}
>
{icon}
</Box>
) : null}
<Box>
<Typography
variant="caption"
sx={{ color: '#58a6ff', fontWeight: 400, fontSize: '14px', letterSpacing: 0.2 }}
>
{title}
</Typography>
{description ? (
<Typography variant="body2" sx={{ color: '#aaa' }}>
{description}
</Typography>
) : null}
</Box>
</Box>
{actions ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{actions}
</Box>
) : null}
</Box>
{children}
</Paper>
);
// ---------------- Workflows Island -----------------
const sortTree = (node) => {
if (!node || !Array.isArray(node.children)) return;
node.children.sort((a, b) => {
const aFolder = Boolean(a.isFolder);
const bFolder = Boolean(b.isFolder);
if (aFolder !== bFolder) return aFolder ? -1 : 1;
return String(a.label || "").localeCompare(String(b.label || ""), undefined, {
sensitivity: "base"
});
});
node.children.forEach(sortTree);
};
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;
}
});
});
sortTree(rootNode);
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/assembly/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island: 'workflows', kind: 'file', 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/assembly/list?island=workflows`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const { root, map } = buildWorkflowTree(data.items || [], 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/assembly/rename", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island: 'workflows', kind: 'file', 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/assembly/rename", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island: 'workflows', kind: 'folder', path: selectedNode.path, new_name: renameValue })
});
} else {
const basePath = selectedNode ? selectedNode.path : "";
const newPath = basePath ? `${basePath}/${renameValue}` : renameValue;
await fetch("/api/assembly/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island: 'workflows', kind: 'folder', 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/assembly/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island: 'workflows', kind: 'folder', path: selectedNode.path })
});
} else {
await fetch("/api/assembly/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island: 'workflows', kind: 'file', 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 sx={{ fontSize: 40 }} />}
actions={
<Button
size="small"
variant="outlined"
onClick={() => { setSelectedNode({ id: 'root', path: '', isFolder: true }); setNewWorkflowName(''); setNewWorkflowOpen(true); }}
sx={{
color: '#58a6ff',
borderColor: '#2f81f7',
textTransform: 'none',
'&:hover': { borderColor: '#58a6ff' }
}}
>
New Workflow
</Button>
}
>
<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 Islands (used for Scripts and Ansible) -----------------
function buildFileTree(rootLabel, items, folders) {
// Some backends (e.g. /api/scripts) return paths relative to
// the Assemblies root, which prefixes items with a top-level
// folder like "Scripts". Others (e.g. /api/ansible) already
// return paths relative to their specific root. Normalize by
// stripping a matching top-level segment so the UI shows
// "Scripts/<...>" rather than "Scripts/Scripts/<...>".
const normalize = (p) => {
const candidates = [
String(rootLabel || "").trim(),
String(rootLabel || "").replace(/\s+/g, "_")
].filter(Boolean);
const parts = String(p || "").replace(/\\/g, "/").split("/").filter(Boolean);
if (parts.length && candidates.includes(parts[0])) parts.shift();
return parts;
};
const map = {};
const rootNode = { id: "root", label: rootLabel, path: "", isFolder: true, children: [] };
map[rootNode.id] = rootNode;
(folders || []).forEach((f) => {
const parts = normalize(f);
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 = normalize(s?.rel_path);
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.name || s.display_name || 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;
}
});
});
sortTree(rootNode);
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 island = React.useMemo(() => {
const b = String(baseApi || '').toLowerCase();
return b.endsWith('/api/ansible') ? 'ansible' : 'scripts';
}, [baseApi]);
const loadTree = useCallback(async () => {
try {
const resp = await fetch(`/api/assembly/list?island=${encodeURIComponent(island)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const { root, map } = buildFileTree(rootLabel, data.items || [], data.folders || []);
setTree(root);
setNodeMap(map);
} catch (err) {
console.error(`Failed to load ${title}:`, err);
setTree([]);
setNodeMap({});
}
}, [island, 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(`/api/assembly/move`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island, kind: 'file', 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 payload = { island, kind: 'file', path: selectedNode.path, new_name: renameValue };
// preserve extension for scripts when no extension provided
if (selectedNode?.meta?.type) payload.type = selectedNode.meta.type;
const res = await fetch(`/api/assembly/rename`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
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(`/api/assembly/rename`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island, kind: 'folder', path: selectedNode.path, new_name: renameValue })
});
} else {
const basePath = selectedNode ? selectedNode.path : "";
const newPath = basePath ? `${basePath}/${renameValue}` : renameValue;
await fetch(`/api/assembly/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island, kind: 'folder', 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/assembly/delete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island, kind: 'folder', path: selectedNode.path })
});
} else {
await fetch(`/api/assembly/delete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island, kind: 'file', path: selectedNode.path })
});
}
setDeleteOpen(false);
loadTree();
} catch (err) {
console.error("Failed to delete:", err);
setDeleteOpen(false);
}
};
const createNewItem = () => {
const trimmedName = (newItemName || '').trim();
const folder = selectedNode?.isFolder
? selectedNode.path
: (selectedNode?.path?.split("/").slice(0, -1).join("/") || "");
const context = {
folder,
suggestedFileName: trimmedName,
defaultType: island === 'ansible' ? 'ansible' : 'powershell',
type: island === 'ansible' ? 'ansible' : 'powershell',
category: island === 'ansible' ? 'application' : 'script'
};
setNewItemOpen(false);
setNewItemName("");
onEdit && onEdit(null, context);
};
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 sx={{ fontSize: 40 }} /> : <BookIcon sx={{ fontSize: 40 }} />}
actions={
<Button
size="small"
variant="outlined"
onClick={() => { setNewItemName(''); setNewItemOpen(true); }}
sx={{ color: '#58a6ff', borderColor: '#2f81f7', textTransform: 'none', '&:hover': { borderColor: '#58a6ff' } }}
>
{newItemLabel}
</Button>
}
>
<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: 0, bgcolor: '#1e1e1e' }} elevation={2}>
<Box sx={{ p: 2, pb: 1 }}>
<Typography variant="h6" sx={{ color: '#58a6ff', mb: 0 }}>Assemblies</Typography>
<Typography variant="body2" sx={{ color: '#aaa' }}>Collections of various types of components used to perform various automations upon targeted devices.</Typography>
</Box>
<Box sx={{ px: 2, pb: 2 }}>
<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, ctx) => onOpenScript && onOpenScript(rel, 'scripts', ctx)}
/>
{/* Right: Ansible Playbooks */}
<ScriptsLikeIsland
title="Ansible Playbooks"
description="Declarative Instructions for Consistent Automation"
rootLabel="Ansible Playbooks"
baseApi="/api/ansible"
newItemLabel="New Playbook"
onEdit={(rel, ctx) => onOpenScript && onOpenScript(rel, 'ansible', ctx)}
/>
</Box>
</Box>
</Paper>
);
}