import React, { useEffect, useMemo, useRef, useState } from "react"; import { Box, Paper, Typography, Button, TextField, MenuItem, Grid, FormControlLabel, Checkbox, IconButton, Tooltip, Dialog, DialogTitle, DialogContent, DialogActions, ListItemText } from "@mui/material"; import { Add as AddIcon, Delete as DeleteIcon, UploadFile as UploadFileIcon } from "@mui/icons-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", prism: "yaml" }, { key: "powershell", label: "PowerShell Script", prism: "powershell" }, { key: "batch", label: "Batch Script", prism: "batch" }, { key: "bash", label: "Bash Script", prism: "bash" } ]; const CATEGORY_OPTIONS = [ { key: "script", label: "Script" }, { key: "application", label: "Application" } ]; const VARIABLE_TYPE_OPTIONS = [ { key: "string", label: "String" }, { key: "number", label: "Number" }, { key: "boolean", label: "Boolean" }, { key: "credential", label: "Credential" } ]; const BACKGROUND_COLORS = { field: "#1C1C1C", /* Shared surface color for text fields, dropdown inputs, and script editors */ sectionCard: "#2E2E2E", /* Background for section container cards */ menuSelected: "rgba(88,166,255,0.16)", /* Background for selected dropdown items */ menuSelectedHover: "rgba(88,166,255,0.24)", /* Background for hovered selected dropdown items */ primaryActionSaving: "rgba(88,166,255,0.12)", /* Background for primary action button while saving */ primaryActionHover: "rgba(88,166,255,0.18)", /* Background for primary action button hover state */ dialog: "#1a1f27" /* Background for modal dialogs */ }; const INPUT_BASE_SX = { "& .MuiOutlinedInput-root": { bgcolor: BACKGROUND_COLORS.field, color: "#e6edf3", /* Text Color */ borderRadius: 1, /* Roundness of UI Elements */ minHeight: 40, "& fieldset": { borderColor: "#2b3544" }, "&:hover fieldset": { borderColor: "#3a4657" }, "&.Mui-focused fieldset": { borderColor: "#58a6ff" } }, "& .MuiOutlinedInput-input": { padding: "9px 12px", fontSize: "0.95rem" }, "& .MuiOutlinedInput-inputMultiline": { padding: "9px 12px" }, "& .MuiInputLabel-root": { color: "#9ba3b4" }, "& .MuiInputLabel-root.Mui-focused": { color: "#58a6ff" }, "& input[type=number]": { MozAppearance: "textfield" }, "& input[type=number]::-webkit-outer-spin-button": { WebkitAppearance: "none", margin: 0 }, "& input[type=number]::-webkit-inner-spin-button": { WebkitAppearance: "none", margin: 0 } }; const SELECT_BASE_SX = { ...INPUT_BASE_SX, "& .MuiSelect-select": { padding: "10px 12px !important", display: "flex", alignItems: "center" } }; const SECTION_TITLE_SX = { color: "#58a6ff", fontWeight: 400, fontSize: "14px", letterSpacing: 0.2 }; const SECTION_CARD_SX = { bgcolor: BACKGROUND_COLORS.sectionCard, borderRadius: 2, border: "1px solid #262f3d", }; const MENU_PROPS = { PaperProps: { sx: { bgcolor: BACKGROUND_COLORS.field, color: "#e6edf3", border: "1px solid #2b3544", "& .MuiMenuItem-root.Mui-selected": { bgcolor: BACKGROUND_COLORS.menuSelected }, "& .MuiMenuItem-root.Mui-selected:hover": { bgcolor: BACKGROUND_COLORS.menuSelectedHover } } } }; function keyBy(arr) { return Object.fromEntries(arr.map((o) => [o.key, o])); } const TYPE_MAP = keyBy(TYPE_OPTIONS_ALL); const PAGE_BACKGROUND = "#0d1117"; /* Color of Void Space Between Sidebar and Page */ 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 sanitizeFileName(name = "") { const base = name.trim().replace(/[^a-zA-Z0-9._-]+/g, "_") || "assembly"; return base.endsWith(".json") ? base : `${base}.json`; } function normalizeFolderPath(path = "") { if (!path) return ""; return path .replace(/\\/g, "/") .replace(/^\/+|\/+$/g, "") .replace(/\/+/g, "/"); } function formatBytes(size) { if (!size || Number.isNaN(size)) return "0 B"; if (size < 1024) return `${size} B`; const units = ["KB", "MB", "GB", "TB"]; let idx = -1; let s = size; while (s >= 1024 && idx < units.length - 1) { s /= 1024; idx += 1; } return `${s.toFixed(1)} ${units[idx]}`; } function defaultAssembly(defaultType = "powershell") { return { name: "", description: "", category: defaultType === "ansible" ? "application" : "script", type: defaultType, script: "", timeoutSeconds: 3600, sites: { mode: "all", values: [] }, variables: [], files: [] }; } function normalizeVariablesFromServer(vars = []) { return (Array.isArray(vars) ? vars : []).map((v, idx) => ({ id: `${Date.now()}_${idx}_${Math.random().toString(36).slice(2, 8)}`, name: v?.name || v?.key || "", label: v?.label || "", type: v?.type || "string", defaultValue: v?.default ?? v?.default_value ?? "", required: Boolean(v?.required), description: v?.description || "" })); } function normalizeFilesFromServer(files = []) { return (Array.isArray(files) ? files : []).map((f, idx) => ({ id: `${Date.now()}_${idx}_${Math.random().toString(36).slice(2, 8)}`, fileName: f?.file_name || f?.name || "file.bin", size: f?.size || 0, mimeType: f?.mime_type || f?.mimeType || "", data: f?.data || "" })); } function fromServerDocument(doc = {}, defaultType = "powershell") { const assembly = defaultAssembly(defaultType); if (doc && typeof doc === "object") { assembly.name = doc.name || doc.display_name || assembly.name; assembly.description = doc.description || ""; assembly.category = doc.category || assembly.category; assembly.type = doc.type || assembly.type; if (Array.isArray(doc.script_lines)) { assembly.script = doc.script_lines .map((line) => (line == null ? "" : String(line))) .join("\n"); } else { assembly.script = doc.script ?? doc.content ?? ""; } const timeout = doc.timeout_seconds ?? doc.timeout ?? assembly.timeoutSeconds; assembly.timeoutSeconds = Number.isFinite(Number(timeout)) ? Number(timeout) : assembly.timeoutSeconds; const sites = doc.sites || {}; assembly.sites = { mode: sites.mode || (Array.isArray(sites.values) && sites.values.length ? "specific" : "all"), values: Array.isArray(sites.values) ? sites.values : [] }; assembly.variables = normalizeVariablesFromServer(doc.variables); assembly.files = normalizeFilesFromServer(doc.files); } return assembly; } function toServerDocument(assembly) { const normalizedScript = typeof assembly.script === "string" ? assembly.script.replace(/\r\n/g, "\n") : ""; const scriptLines = normalizedScript ? normalizedScript.split("\n") : []; const timeoutNumeric = Number(assembly.timeoutSeconds); const timeoutSeconds = Number.isFinite(timeoutNumeric) ? Math.max(0, Math.round(timeoutNumeric)) : 3600; return { version: 1, name: assembly.name?.trim() || "", description: assembly.description || "", category: assembly.category || "script", type: assembly.type || "powershell", script: normalizedScript, script_lines: scriptLines, timeout_seconds: timeoutSeconds, sites: { mode: assembly.sites?.mode === "specific" ? "specific" : "all", values: Array.isArray(assembly.sites?.values) ? assembly.sites.values.filter((v) => v && v.trim()).map((v) => v.trim()) : [] }, variables: (assembly.variables || []).map((v) => ({ name: v.name?.trim() || "", label: v.label || "", type: v.type || "string", default: v.defaultValue ?? "", required: Boolean(v.required), description: v.description || "" })), files: (assembly.files || []).map((f) => ({ file_name: f.fileName || "file.bin", size: f.size || 0, mime_type: f.mimeType || "", data: f.data || "" })) }; } function RenameFileDialog({ open, value, onChange, onCancel, onSave }) { return ( Rename Assembly File onChange(e.target.value)} sx={INPUT_BASE_SX} /> ); } export default function AssemblyEditor({ mode = "scripts", initialPath = "", initialContext = null, onConsumeInitialData, onSaved }) { const isAnsible = mode === "ansible"; const defaultType = isAnsible ? "ansible" : "powershell"; const [assembly, setAssembly] = useState(() => defaultAssembly(defaultType)); const [currentPath, setCurrentPath] = useState(""); const [fileName, setFileName] = useState(""); const [folderPath, setFolderPath] = useState(() => normalizeFolderPath(initialContext?.folder || "")); const [renameOpen, setRenameOpen] = useState(false); const [renameValue, setRenameValue] = useState(""); const [deleteOpen, setDeleteOpen] = useState(false); const [saving, setSaving] = useState(false); const [siteOptions, setSiteOptions] = useState([]); const [siteLoading, setSiteLoading] = useState(false); const contextNonceRef = useRef(null); const TYPE_OPTIONS = useMemo( () => (isAnsible ? TYPE_OPTIONS_ALL.filter((o) => o.key === "ansible") : TYPE_OPTIONS_ALL.filter((o) => o.key !== "ansible")), [isAnsible] ); const siteOptionMap = useMemo(() => { const map = new Map(); siteOptions.forEach((site) => { if (!site) return; const id = site.id != null ? String(site.id) : ""; if (!id) return; map.set(id, site); }); return map; }, [siteOptions]); const island = isAnsible ? "ansible" : "scripts"; useEffect(() => { if (!initialPath) return; let canceled = false; (async () => { try { const resp = await fetch(`/api/assembly/load?island=${encodeURIComponent(island)}&path=${encodeURIComponent(initialPath)}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); if (canceled) return; const rel = data.rel_path || initialPath; setCurrentPath(rel); setFolderPath(normalizeFolderPath(rel.split("/").slice(0, -1).join("/"))); setFileName(data.file_name || rel.split("/").pop() || ""); const doc = fromServerDocument(data.assembly || data, defaultType); setAssembly(doc); } catch (err) { console.error("Failed to load assembly:", err); } finally { if (!canceled && onConsumeInitialData) onConsumeInitialData(); } })(); return () => { canceled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialPath, island]); useEffect(() => { const ctx = initialContext; if (!ctx || !ctx.nonce) return; if (contextNonceRef.current === ctx.nonce) return; contextNonceRef.current = ctx.nonce; const doc = defaultAssembly(ctx.defaultType || defaultType); if (ctx.name) doc.name = ctx.name; if (ctx.description) doc.description = ctx.description; if (ctx.category) doc.category = ctx.category; if (ctx.type) doc.type = ctx.type; setAssembly(doc); setCurrentPath(""); const suggested = ctx.suggestedFileName || ctx.name || ""; setFileName(suggested ? sanitizeFileName(suggested) : ""); setFolderPath(normalizeFolderPath(ctx.folder || "")); if (onConsumeInitialData) onConsumeInitialData(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialContext?.nonce]); useEffect(() => { let canceled = false; const loadSites = async () => { try { setSiteLoading(true); const resp = await fetch("/api/sites"); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); if (canceled) return; const items = Array.isArray(data?.sites) ? data.sites : []; setSiteOptions(items.map((s) => ({ ...s, id: s?.id != null ? String(s.id) : "" })).filter((s) => s.id)); } catch (err) { if (!canceled) { console.error("Failed to load sites:", err); setSiteOptions([]); } } finally { if (!canceled) setSiteLoading(false); } }; loadSites(); return () => { canceled = true; }; }, []); const prismLanguage = TYPE_MAP[assembly.type]?.prism || "powershell"; const updateAssembly = (partial) => { setAssembly((prev) => ({ ...prev, ...partial })); }; const updateSitesMode = (modeValue) => { setAssembly((prev) => ({ ...prev, sites: { mode: modeValue, values: modeValue === "specific" ? prev.sites.values || [] : [] } })); }; const updateSelectedSites = (values) => { const arr = Array.isArray(values) ? values : typeof values === "string" ? values.split(",").map((v) => v.trim()).filter(Boolean) : []; setAssembly((prev) => ({ ...prev, sites: { mode: "specific", values: arr.map((v) => String(v)) } })); }; const addVariable = () => { setAssembly((prev) => ({ ...prev, variables: [ ...prev.variables, { id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, name: "", label: "", type: "string", defaultValue: "", required: false, description: "" } ] })); }; const updateVariable = (id, partial) => { setAssembly((prev) => ({ ...prev, variables: prev.variables.map((v) => (v.id === id ? { ...v, ...partial } : v)) })); }; const removeVariable = (id) => { setAssembly((prev) => ({ ...prev, variables: prev.variables.filter((v) => v.id !== id) })); }; const handleFileUpload = async (event) => { const files = Array.from(event.target.files || []); if (!files.length) return; const reads = files.map((file) => new Promise((resolve) => { const reader = new FileReader(); reader.onload = () => { const result = reader.result || ""; const base64 = typeof result === "string" && result.includes(",") ? result.split(",", 2)[1] : result; resolve({ id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, fileName: file.name, size: file.size, mimeType: file.type, data: base64 }); }; reader.onerror = () => resolve(null); reader.readAsDataURL(file); })); const uploaded = (await Promise.all(reads)).filter(Boolean); if (uploaded.length) { setAssembly((prev) => ({ ...prev, files: [...prev.files, ...uploaded] })); } event.target.value = ""; }; const removeFile = (id) => { setAssembly((prev) => ({ ...prev, files: prev.files.filter((f) => f.id !== id) })); }; const computeTargetPath = () => { if (currentPath) return currentPath; const baseName = sanitizeFileName(fileName || assembly.name || (isAnsible ? "playbook" : "assembly")); const folder = normalizeFolderPath(folderPath); return folder ? `${folder}/${baseName}` : baseName; }; const saveAssembly = async () => { if (!assembly.name.trim()) { alert("Assembly Name is required."); return; } const payload = toServerDocument(assembly); payload.type = assembly.type; const targetPath = computeTargetPath(); if (!targetPath) { alert("Unable to determine file path."); return; } setSaving(true); try { if (currentPath) { const resp = await fetch(`/api/assembly/edit`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ island, path: currentPath, content: payload }) }); const data = await resp.json().catch(() => ({})); if (!resp.ok) { throw new Error(data?.error || `HTTP ${resp.status}`); } if (data?.rel_path) { setCurrentPath(data.rel_path); setFolderPath(normalizeFolderPath(data.rel_path.split("/").slice(0, -1).join("/"))); setFileName(data.rel_path.split("/").pop() || fileName); } } else { const resp = await fetch(`/api/assembly/create`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ island, kind: "file", path: targetPath, content: payload, type: assembly.type }) }); const data = await resp.json(); if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`); if (data.rel_path) { setCurrentPath(data.rel_path); setFolderPath(data.rel_path.split("/").slice(0, -1).join("/")); setFileName(data.rel_path.split("/").pop() || ""); } else { setCurrentPath(targetPath); setFileName(targetPath.split("/").pop() || ""); } } onSaved && onSaved(); } catch (err) { console.error("Failed to save assembly:", err); alert(err.message || "Failed to save assembly"); } finally { setSaving(false); } }; const saveRename = async () => { try { const nextName = sanitizeFileName(renameValue || fileName || assembly.name); const resp = await fetch(`/api/assembly/rename`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ island, kind: "file", path: currentPath, new_name: nextName, type: assembly.type }) }); const data = await resp.json(); if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`); const rel = data.rel_path || currentPath; setCurrentPath(rel); setFolderPath(rel.split("/").slice(0, -1).join("/")); setFileName(rel.split("/").pop() || nextName); setRenameOpen(false); } catch (err) { console.error("Failed to rename assembly:", err); alert(err.message || "Failed to rename"); setRenameOpen(false); } }; const deleteAssembly = async () => { if (!currentPath) { setDeleteOpen(false); return; } try { const resp = await fetch(`/api/assembly/delete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ island, kind: "file", path: currentPath }) }); if (!resp.ok) { const data = await resp.json().catch(() => ({})); throw new Error(data?.error || `HTTP ${resp.status}`); } setDeleteOpen(false); setAssembly(defaultAssembly(defaultType)); setCurrentPath(""); setFileName(""); onSaved && onSaved(); } catch (err) { console.error("Failed to delete assembly:", err); alert(err.message || "Failed to delete assembly"); setDeleteOpen(false); } }; const siteScopeValue = assembly.sites?.mode === "specific" ? "specific" : "all"; const selectedSiteValues = Array.isArray(assembly.sites?.values) ? assembly.sites.values.map((v) => String(v)) : []; return ( Assembly Editor Create and edit variables, scripts, and other fields related to assemblies. Overview {currentPath ? ( ) : null} {currentPath ? ( ) : null} updateAssembly({ name: e.target.value })} fullWidth variant="outlined" sx={{ ...INPUT_BASE_SX, mb: 2 }} /> updateAssembly({ description: e.target.value })} multiline minRows={3} fullWidth variant="outlined" sx={{ ...INPUT_BASE_SX, "& .MuiOutlinedInput-inputMultiline": { padding: "4px 8px" } }} /> updateAssembly({ category: e.target.value })} sx={{ ...SELECT_BASE_SX, mb: 2 }} SelectProps={{ MenuProps: MENU_PROPS }} > {CATEGORY_OPTIONS.map((o) => ( {o.label} ))} updateAssembly({ type: e.target.value })} sx={SELECT_BASE_SX} SelectProps={{ MenuProps: MENU_PROPS }} > {TYPE_OPTIONS.map((o) => ( {o.label} ))} Script Content updateAssembly({ script: value })} highlight={(src) => highlightedHtml(src, prismLanguage)} padding={12} placeholder={currentPath ? `Editing: ${currentPath}` : "Start typing your script..."} style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', fontSize: 14, color: "#e6edf3", background: BACKGROUND_COLORS.field, /* Color of Script Box */ outline: "none", minHeight: 320, lineHeight: 1.45, caretColor: "#58a6ff" }} /> { const nextValue = e.target.value.replace(/[^0-9]/g, ""); updateAssembly({ timeoutSeconds: nextValue ? Number(nextValue) : 0 }); }} fullWidth variant="outlined" sx={INPUT_BASE_SX} helperText="Timeout this script if not completed within X seconds" /> updateSitesMode(e.target.value)} sx={{ ...SELECT_BASE_SX, width: { xs: "100%", sm: 320, lg: 360 } }} SelectProps={{ MenuProps: MENU_PROPS }} > All Sites Specific Sites {siteScopeValue === "specific" ? ( updateSelectedSites(Array.isArray(e.target.value) ? e.target.value : [])} sx={{ ...SELECT_BASE_SX, width: { xs: "100%", sm: 360, lg: 420 } }} SelectProps={{ multiple: true, renderValue: (selected) => { if (!selected || selected.length === 0) { return Select sites; } const names = selected.map((val) => siteOptionMap.get(String(val))?.name || String(val)); return names.join(", "); }, MenuProps: MENU_PROPS }} > {siteLoading ? ( ) : siteOptions.length ? ( siteOptions.map((site) => { const value = String(site.id); const checked = selectedSiteValues.includes(value); return ( ); }) ) : ( )} ) : null} Variables Variables are passed into the execution environment as environment variables at runtime. {(assembly.variables || []).length ? ( {assembly.variables.map((variable) => ( updateVariable(variable.id, { name: e.target.value })} fullWidth variant="outlined" sx={INPUT_BASE_SX} /> updateVariable(variable.id, { label: e.target.value })} fullWidth variant="outlined" sx={INPUT_BASE_SX} /> updateVariable(variable.id, { type: e.target.value })} sx={SELECT_BASE_SX} SelectProps={{ MenuProps: MENU_PROPS }} > {VARIABLE_TYPE_OPTIONS.map((opt) => ( {opt.label} ))} {variable.type === "boolean" ? ( updateVariable(variable.id, { defaultValue: e.target.checked })} sx={{ color: "#58a6ff" }} /> } label="Default Value" /> ) : ( updateVariable(variable.id, { defaultValue: e.target.value })} fullWidth variant="outlined" sx={INPUT_BASE_SX} /> )} updateVariable(variable.id, { required: e.target.checked })} sx={{ color: "#58a6ff" }} /> updateVariable(variable.id, { description: e.target.value })} fullWidth multiline minRows={2} variant="outlined" sx={INPUT_BASE_SX} /> removeVariable(variable.id)} sx={{ color: "#ff6b6b" }}> ))} ) : ( No variables have been defined. )} Files Upload supporting files. They will be embedded as Base64 and available to the assembly at runtime. {(assembly.files || []).length ? ( {assembly.files.map((file) => ( {file.fileName} {formatBytes(file.size)}{file.mimeType ? ` • ${file.mimeType}` : ""} removeFile(file.id)} sx={{ color: "#ff6b6b" }}> ))} ) : ( No files uploaded yet. )} setRenameOpen(false)} onSave={saveRename} /> setDeleteOpen(false)} onConfirm={deleteAssembly} /> ); }