feat: add JSON assembly editor

This commit is contained in:
2025-10-03 03:10:26 -06:00
parent 211b4262aa
commit 0e3304ef95
10 changed files with 1541 additions and 353 deletions

View File

@@ -1,8 +1,10 @@
import os import os
import sys import sys
import re
import asyncio import asyncio
import tempfile import tempfile
import uuid import uuid
from typing import Dict, List
from PyQt5 import QtWidgets, QtGui from PyQt5 import QtWidgets, QtGui
@@ -13,12 +15,62 @@ ROLE_CONTEXTS = ['interactive']
IS_WINDOWS = os.name == 'nt' IS_WINDOWS = os.name == 'nt'
def _write_temp_script(content: str, suffix: str): def _sanitize_env_map(raw) -> Dict[str, str]:
env: Dict[str, str] = {}
if isinstance(raw, dict):
for key, value in raw.items():
if key is None:
continue
name = str(key).strip()
if not name:
continue
env_key = re.sub(r"[^A-Za-z0-9_]", "_", name).upper()
if not env_key:
continue
if isinstance(value, bool):
env_val = "True" if value else "False"
elif value is None:
env_val = ""
else:
env_val = str(value)
env[env_key] = env_val
return env
def _ps_literal(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def _build_wrapped_script(content: str, env_map: Dict[str, str], timeout_seconds: int) -> str:
inner_lines: List[str] = []
for key, value in (env_map or {}).items():
if not key:
continue
inner_lines.append(f"$Env:{key} = {_ps_literal(value)}")
inner_lines.append(content or "")
inner = "\n".join(line for line in inner_lines if line is not None)
script_block = "$__BorealisScript = {\n" + inner + "\n}\n"
if timeout_seconds and timeout_seconds > 0:
block = (
"$job = Start-Job -ScriptBlock $__BorealisScript\n"
f"if (Wait-Job -Job $job -Timeout {timeout_seconds}) {{\n"
" Receive-Job $job\n"
"} else {\n"
" Stop-Job $job -Force\n"
f" throw \"Script timed out after {timeout_seconds} seconds\"\n"
"}\n"
)
return script_block + block
return script_block + "& $__BorealisScript\n"
def _write_temp_script(content: str, suffix: str, env_map: Dict[str, str], timeout_seconds: int):
temp_dir = os.path.join(tempfile.gettempdir(), "Borealis", "quick_jobs") temp_dir = os.path.join(tempfile.gettempdir(), "Borealis", "quick_jobs")
os.makedirs(temp_dir, exist_ok=True) os.makedirs(temp_dir, exist_ok=True)
fd, path = tempfile.mkstemp(prefix="bj_", suffix=suffix, dir=temp_dir, text=True) fd, path = tempfile.mkstemp(prefix="bj_", suffix=suffix, dir=temp_dir, text=True)
final_content = _build_wrapped_script(content or "", env_map, timeout_seconds)
with os.fdopen(fd, 'w', encoding='utf-8', newline='\n') as fh: with os.fdopen(fd, 'w', encoding='utf-8', newline='\n') as fh:
fh.write(content or "") fh.write(final_content)
return path return path
@@ -45,7 +97,7 @@ async def _run_powershell_local(path: str):
return -1, "", str(e) return -1, "", str(e)
async def _run_powershell_via_user_task(content: str): async def _run_powershell_via_user_task(content: str, env_map: Dict[str, str], timeout_seconds: int):
if not IS_WINDOWS: if not IS_WINDOWS:
return -999, '', 'Windows only' return -999, '', 'Windows only'
ps = os.path.expandvars(r"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") ps = os.path.expandvars(r"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
@@ -58,8 +110,9 @@ async def _run_powershell_via_user_task(content: str):
temp_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'Temp')) temp_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'Temp'))
os.makedirs(temp_dir, exist_ok=True) os.makedirs(temp_dir, exist_ok=True)
fd, path = _tf.mkstemp(prefix='usr_task_', suffix='.ps1', dir=temp_dir, text=True) fd, path = _tf.mkstemp(prefix='usr_task_', suffix='.ps1', dir=temp_dir, text=True)
final_content = _build_wrapped_script(content or '', env_map, timeout_seconds)
with os.fdopen(fd, 'w', encoding='utf-8', newline='\n') as f: with os.fdopen(fd, 'w', encoding='utf-8', newline='\n') as f:
f.write(content or '') f.write(final_content)
out_path = os.path.join(temp_dir, f'out_{uuid.uuid4().hex}.txt') out_path = os.path.join(temp_dir, f'out_{uuid.uuid4().hex}.txt')
name = f"Borealis Agent - Task - {uuid.uuid4().hex} @ CurrentUser" name = f"Borealis Agent - Task - {uuid.uuid4().hex} @ CurrentUser"
task_ps = f""" task_ps = f"""
@@ -84,7 +137,7 @@ Get-ScheduledTask -TaskName $task | Out-Null
return -999, '', (err_b or out_b or b'').decode(errors='replace') return -999, '', (err_b or out_b or b'').decode(errors='replace')
# Wait a short time for output file; best-effort # Wait a short time for output file; best-effort
import time as _t import time as _t
deadline = _t.time() + 30 deadline = _t.time() + (timeout_seconds if timeout_seconds > 0 else 30)
out_data = '' out_data = ''
while _t.time() < deadline: while _t.time() < deadline:
try: try:
@@ -139,6 +192,29 @@ class Role:
script_type = (payload.get('script_type') or '').lower() script_type = (payload.get('script_type') or '').lower()
run_mode = (payload.get('run_mode') or 'current_user').lower() run_mode = (payload.get('run_mode') or 'current_user').lower()
content = payload.get('script_content') or '' content = payload.get('script_content') or ''
raw_env = payload.get('environment')
env_map = _sanitize_env_map(raw_env)
variables = payload.get('variables') if isinstance(payload.get('variables'), list) else []
for var in variables:
if not isinstance(var, dict):
continue
name = str(var.get('name') or '').strip()
if not name:
continue
key = re.sub(r"[^A-Za-z0-9_]", "_", name).upper()
if key in env_map:
continue
default_val = var.get('default')
if isinstance(default_val, bool):
env_map[key] = "True" if default_val else "False"
elif default_val is None:
env_map[key] = ""
else:
env_map[key] = str(default_val)
try:
timeout_seconds = max(0, int(payload.get('timeout_seconds') or 0))
except Exception:
timeout_seconds = 0
if run_mode == 'system': if run_mode == 'system':
return return
if script_type != 'powershell': if script_type != 'powershell':
@@ -147,10 +223,17 @@ class Role:
if run_mode == 'admin': if run_mode == 'admin':
rc, out, err = -1, '', 'Admin credentialed runs are disabled; use SYSTEM or Current User.' rc, out, err = -1, '', 'Admin credentialed runs are disabled; use SYSTEM or Current User.'
else: else:
rc, out, err = await _run_powershell_via_user_task(content) rc, out, err = await _run_powershell_via_user_task(content, env_map, timeout_seconds)
if rc == -999: if rc == -999:
path = _write_temp_script(content, '.ps1') path = _write_temp_script(content, '.ps1', env_map, timeout_seconds)
rc, out, err = await _run_powershell_local(path) try:
rc, out, err = await _run_powershell_local(path)
finally:
try:
if path and os.path.isfile(path):
os.remove(path)
except Exception:
pass
status = 'Success' if rc == 0 else 'Failed' status = 'Success' if rc == 0 else 'Failed'
await sio.emit('quick_job_result', { await sio.emit('quick_job_result', {
'job_id': job_id, 'job_id': job_id,

View File

@@ -1,9 +1,11 @@
import os import os
import re
import asyncio import asyncio
import tempfile import tempfile
import uuid import uuid
import time import time
import subprocess import subprocess
from typing import Dict, List
ROLE_NAME = 'script_exec_system' ROLE_NAME = 'script_exec_system'
@@ -14,23 +16,74 @@ def _project_root():
return os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) return os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
def _run_powershell_script_content(content: str): def _sanitize_env_map(raw) -> Dict[str, str]:
env: Dict[str, str] = {}
if isinstance(raw, dict):
for key, value in raw.items():
if key is None:
continue
name = str(key).strip()
if not name:
continue
env_key = re.sub(r"[^A-Za-z0-9_]", "_", name).upper()
if not env_key:
continue
if isinstance(value, bool):
env_val = "True" if value else "False"
elif value is None:
env_val = ""
else:
env_val = str(value)
env[env_key] = env_val
return env
def _ps_literal(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def _build_wrapped_script(content: str, env_map: Dict[str, str], timeout_seconds: int) -> str:
inner_lines: List[str] = []
for key, value in (env_map or {}).items():
if not key:
continue
inner_lines.append(f"$Env:{key} = {_ps_literal(value)}")
inner_lines.append(content or "")
inner = "\n".join(line for line in inner_lines if line is not None)
script_block = "$__BorealisScript = {\n" + inner + "\n}\n"
if timeout_seconds and timeout_seconds > 0:
block = (
"$job = Start-Job -ScriptBlock $__BorealisScript\n"
f"if (Wait-Job -Job $job -Timeout {timeout_seconds}) {{\n"
" Receive-Job $job\n"
"} else {\n"
" Stop-Job $job -Force\n"
f" throw \"Script timed out after {timeout_seconds} seconds\"\n"
"}\n"
)
return script_block + block
return script_block + "& $__BorealisScript\n"
def _run_powershell_script_content(content: str, env_map: Dict[str, str], timeout_seconds: int):
temp_dir = os.path.join(_project_root(), "Temp") temp_dir = os.path.join(_project_root(), "Temp")
os.makedirs(temp_dir, exist_ok=True) os.makedirs(temp_dir, exist_ok=True)
fd, path = tempfile.mkstemp(prefix="sj_", suffix=".ps1", dir=temp_dir, text=True) fd, path = tempfile.mkstemp(prefix="sj_", suffix=".ps1", dir=temp_dir, text=True)
final_content = _build_wrapped_script(content or "", env_map, timeout_seconds)
with os.fdopen(fd, 'w', encoding='utf-8', newline='\n') as fh: with os.fdopen(fd, 'w', encoding='utf-8', newline='\n') as fh:
fh.write(content or "") fh.write(final_content)
ps = os.path.expandvars(r"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") ps = os.path.expandvars(r"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
if not os.path.isfile(ps): if not os.path.isfile(ps):
ps = "powershell.exe" ps = "powershell.exe"
try: try:
flags = 0x08000000 if os.name == 'nt' else 0 flags = 0x08000000 if os.name == 'nt' else 0
proc_timeout = timeout_seconds + 30 if timeout_seconds else 60 * 60
proc = subprocess.run( proc = subprocess.run(
[ps, "-ExecutionPolicy", "Bypass", "-NoProfile", "-File", path], [ps, "-ExecutionPolicy", "Bypass", "-NoProfile", "-File", path],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=60*60, timeout=proc_timeout,
creationflags=flags, creationflags=flags,
) )
return proc.returncode, proc.stdout or "", proc.stderr or "" return proc.returncode, proc.stdout or "", proc.stderr or ""
@@ -44,15 +97,16 @@ def _run_powershell_script_content(content: str):
pass pass
def _run_powershell_via_system_task(content: str): def _run_powershell_via_system_task(content: str, env_map: Dict[str, str], timeout_seconds: int):
ps_exe = os.path.expandvars(r"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe") ps_exe = os.path.expandvars(r"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe")
if not os.path.isfile(ps_exe): if not os.path.isfile(ps_exe):
ps_exe = 'powershell.exe' ps_exe = 'powershell.exe'
try: try:
os.makedirs(os.path.join(_project_root(), 'Temp'), exist_ok=True) os.makedirs(os.path.join(_project_root(), 'Temp'), exist_ok=True)
script_fd, script_path = tempfile.mkstemp(prefix='sys_task_', suffix='.ps1', dir=os.path.join(_project_root(), 'Temp'), text=True) script_fd, script_path = tempfile.mkstemp(prefix='sys_task_', suffix='.ps1', dir=os.path.join(_project_root(), 'Temp'), text=True)
final_content = _build_wrapped_script(content or '', env_map, timeout_seconds)
with os.fdopen(script_fd, 'w', encoding='utf-8', newline='\n') as f: with os.fdopen(script_fd, 'w', encoding='utf-8', newline='\n') as f:
f.write(content or '') f.write(final_content)
try: try:
log_dir = os.path.join(_project_root(), 'Logs', 'Agent') log_dir = os.path.join(_project_root(), 'Logs', 'Agent')
os.makedirs(log_dir, exist_ok=True) os.makedirs(log_dir, exist_ok=True)
@@ -131,6 +185,29 @@ class Role:
job_id = payload.get('job_id') job_id = payload.get('job_id')
script_type = (payload.get('script_type') or '').lower() script_type = (payload.get('script_type') or '').lower()
content = payload.get('script_content') or '' content = payload.get('script_content') or ''
raw_env = payload.get('environment')
env_map = _sanitize_env_map(raw_env)
variables = payload.get('variables') if isinstance(payload.get('variables'), list) else []
for var in variables:
if not isinstance(var, dict):
continue
name = str(var.get('name') or '').strip()
if not name:
continue
key = re.sub(r"[^A-Za-z0-9_]", "_", name).upper()
if key in env_map:
continue
default_val = var.get('default')
if isinstance(default_val, bool):
env_map[key] = "True" if default_val else "False"
elif default_val is None:
env_map[key] = ""
else:
env_map[key] = str(default_val)
try:
timeout_seconds = max(0, int(payload.get('timeout_seconds') or 0))
except Exception:
timeout_seconds = 0
if script_type != 'powershell': if script_type != 'powershell':
await sio.emit('quick_job_result', { await sio.emit('quick_job_result', {
'job_id': job_id, 'job_id': job_id,
@@ -139,9 +216,9 @@ class Role:
'stderr': f"Unsupported type: {script_type}" 'stderr': f"Unsupported type: {script_type}"
}) })
return return
rc, out, err = _run_powershell_via_system_task(content) rc, out, err = _run_powershell_via_system_task(content, env_map, timeout_seconds)
if rc == -999: if rc == -999:
rc, out, err = _run_powershell_script_content(content) rc, out, err = _run_powershell_script_content(content, env_map, timeout_seconds)
status = 'Success' if rc == 0 else 'Failed' status = 'Success' if rc == 0 else 'Failed'
await sio.emit('quick_job_result', { await sio.emit('quick_job_result', {
'job_id': job_id, 'job_id': job_id,

View File

@@ -36,7 +36,7 @@ import SiteList from "./Sites/Site_List";
import DeviceList from "./Devices/Device_List"; import DeviceList from "./Devices/Device_List";
import DeviceDetails from "./Devices/Device_Details"; import DeviceDetails from "./Devices/Device_Details";
import AssemblyList from "./Assemblies/Assembly_List"; import AssemblyList from "./Assemblies/Assembly_List";
import ScriptEditor from "./Assemblies/Script_Editor"; import AssemblyEditor from "./Assemblies/Assembly_Editor";
import ScheduledJobsList from "./Scheduling/Scheduled_Jobs_List"; import ScheduledJobsList from "./Scheduling/Scheduled_Jobs_List";
import CreateJob from "./Scheduling/Create_Job.jsx"; import CreateJob from "./Scheduling/Create_Job.jsx";
import UserManagement from "./Admin/User_Management.jsx"; import UserManagement from "./Admin/User_Management.jsx";
@@ -106,7 +106,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
const [userDisplayName, setUserDisplayName] = useState(null); const [userDisplayName, setUserDisplayName] = useState(null);
const [editingJob, setEditingJob] = useState(null); const [editingJob, setEditingJob] = useState(null);
const [jobsRefreshToken, setJobsRefreshToken] = useState(0); const [jobsRefreshToken, setJobsRefreshToken] = useState(0);
const [scriptToEdit, setScriptToEdit] = useState(null); // { path, mode: 'scripts'|'ansible' } const [assemblyEditorState, setAssemblyEditorState] = useState(null); // { path, mode, context, nonce }
const [notAuthorizedOpen, setNotAuthorizedOpen] = useState(false); const [notAuthorizedOpen, setNotAuthorizedOpen] = useState(false);
// Top-bar search state // Top-bar search state
@@ -631,8 +631,14 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
setActiveTabId(newId); setActiveTabId(newId);
setCurrentPage("workflow-editor"); setCurrentPage("workflow-editor");
}} }}
onOpenScript={(rel, mode) => { onOpenScript={(rel, mode, context) => {
setScriptToEdit({ path: rel, mode }); const nonce = Date.now();
setAssemblyEditorState({
path: rel || '',
mode,
context: context ? { ...context, nonce } : null,
nonce
});
setCurrentPage(mode === 'ansible' ? 'ansible_editor' : 'scripts'); setCurrentPage(mode === 'ansible' ? 'ansible_editor' : 'scripts');
}} }}
/> />
@@ -660,8 +666,14 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
setActiveTabId(newId); setActiveTabId(newId);
setCurrentPage("workflow-editor"); setCurrentPage("workflow-editor");
}} }}
onOpenScript={(rel, mode) => { onOpenScript={(rel, mode, context) => {
setScriptToEdit({ path: rel, mode }); const nonce = Date.now();
setAssemblyEditorState({
path: rel || '',
mode,
context: context ? { ...context, nonce } : null,
nonce
});
setCurrentPage(mode === 'ansible' ? 'ansible_editor' : 'scripts'); setCurrentPage(mode === 'ansible' ? 'ansible_editor' : 'scripts');
}} }}
/> />
@@ -669,20 +681,26 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
case "scripts": case "scripts":
return ( return (
<ScriptEditor <AssemblyEditor
mode="scripts" mode="scripts"
initialPath={scriptToEdit?.mode === 'scripts' ? (scriptToEdit?.path || '') : ''} initialPath={assemblyEditorState?.mode === 'scripts' ? (assemblyEditorState?.path || '') : ''}
onConsumedInitialPath={() => setScriptToEdit(null)} initialContext={assemblyEditorState?.mode === 'scripts' ? assemblyEditorState?.context : null}
onConsumeInitialData={() =>
setAssemblyEditorState((prev) => (prev && prev.mode === 'scripts' ? null : prev))
}
onSaved={() => setCurrentPage('assemblies')} onSaved={() => setCurrentPage('assemblies')}
/> />
); );
case "ansible_editor": case "ansible_editor":
return ( return (
<ScriptEditor <AssemblyEditor
mode="ansible" mode="ansible"
initialPath={scriptToEdit?.mode === 'ansible' ? (scriptToEdit?.path || '') : ''} initialPath={assemblyEditorState?.mode === 'ansible' ? (assemblyEditorState?.path || '') : ''}
onConsumedInitialPath={() => setScriptToEdit(null)} initialContext={assemblyEditorState?.mode === 'ansible' ? assemblyEditorState?.context : null}
onConsumeInitialData={() =>
setAssemblyEditorState((prev) => (prev && prev.mode === 'ansible' ? null : prev))
}
onSaved={() => setCurrentPage('assemblies')} onSaved={() => setCurrentPage('assemblies')}
/> />
); );

View File

@@ -0,0 +1,908 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
Box,
Paper,
Typography,
Button,
Select,
FormControl,
InputLabel,
TextField,
MenuItem,
Grid,
RadioGroup,
FormControlLabel,
Radio,
Checkbox,
IconButton,
Tooltip,
Dialog,
DialogTitle,
DialogContent,
DialogActions
} 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" }
];
function keyBy(arr) {
return Object.fromEntries(arr.map((o) => [o.key, o]));
}
const TYPE_MAP = keyBy(TYPE_OPTIONS_ALL);
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[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: 0,
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;
assembly.script = doc.script ?? doc.content ?? "";
const timeout = doc.timeout_seconds ?? doc.timeout ?? 0;
assembly.timeoutSeconds = Number.isFinite(Number(timeout)) ? Number(timeout) : 0;
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) {
return {
version: 1,
name: assembly.name?.trim() || "",
description: assembly.description || "",
category: assembly.category || "script",
type: assembly.type || "powershell",
script: assembly.script ?? "",
timeout_seconds: Number.isFinite(Number(assembly.timeoutSeconds)) ? Number(assembly.timeoutSeconds) : 0,
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 (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Rename Assembly File</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="File Name"
fullWidth
variant="outlined"
value={value}
onChange={(e) => onChange(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1e1e1e",
color: "#e6edf3",
"& fieldset": { borderColor: "#333" },
"&:hover fieldset": { borderColor: "#555" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
</DialogActions>
</Dialog>
);
}
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 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 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]);
const prismLanguage = TYPE_MAP[assembly.type]?.prism || "powershell";
const updateAssembly = (partial) => {
setAssembly((prev) => ({ ...prev, ...partial }));
};
const handleSitesChange = (modeValue, values) => {
setAssembly((prev) => ({
...prev,
sites: {
mode: modeValue,
values: Array.isArray(values)
? values
: ((values || "").split(/\r?\n/).map((v) => v.trim()).filter(Boolean))
}
}));
};
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 siteValuesText = (assembly.sites?.values || []).join("\n");
return (
<Box sx={{ display: "flex", flexDirection: "column", flex: 1, height: "100%", overflow: "hidden" }}>
<Box sx={{ px: 2, pt: 2 }}>
<Typography variant="h5" sx={{ color: "#58a6ff", fontWeight: 500, mb: 0.5 }}>
Assembly Editor
</Typography>
<Typography variant="body2" sx={{ color: "#9ba3b4", mb: 2 }}>
Create and edit variables, scripts, and other fields related to assemblies.
</Typography>
</Box>
<Box sx={{ flex: 1, overflow: "auto", p: 2, pt: 0 }}>
<Paper sx={{ p: 2, bgcolor: "#1e1e1e", borderRadius: 2, border: "1px solid #2a2a2a" }} elevation={2}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 3 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle2" sx={{ color: "#58a6ff" }}>
Assembly Details
</Typography>
</Box>
{currentPath ? (
<Tooltip title="Rename File">
<Button
size="small"
onClick={() => { setRenameValue(fileName); setRenameOpen(true); }}
sx={{ color: "#58a6ff", textTransform: "none" }}
>
Rename
</Button>
</Tooltip>
) : null}
{currentPath ? (
<Tooltip title="Delete Assembly">
<Button
size="small"
onClick={() => setDeleteOpen(true)}
sx={{ color: "#ff6b6b", textTransform: "none" }}
>
Delete
</Button>
</Tooltip>
) : null}
<Button
variant="outlined"
onClick={saveAssembly}
disabled={saving}
sx={{
color: "#58a6ff",
borderColor: "#58a6ff",
textTransform: "none",
backgroundColor: saving ? "rgba(88,166,255,0.08)" : "#1e1e1e",
"&:hover": { borderColor: "#7db7ff" }
}}
>
{saving ? "Saving..." : "Save Assembly"}
</Button>
</Box>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
label="Assembly Name"
value={assembly.name}
onChange={(e) => updateAssembly({ name: e.target.value })}
fullWidth
variant="outlined"
sx={{
mb: 2,
"& .MuiOutlinedInput-root": {
bgcolor: "#121212",
color: "#e6edf3",
"& fieldset": { borderColor: "#333" },
"&:hover fieldset": { borderColor: "#555" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
/>
<TextField
label="Description"
value={assembly.description}
onChange={(e) => updateAssembly({ description: e.target.value })}
multiline
minRows={3}
fullWidth
variant="outlined"
sx={{
"& .MuiOutlinedInput-root": {
bgcolor: "#121212",
color: "#e6edf3",
"& fieldset": { borderColor: "#333" },
"&:hover fieldset": { borderColor: "#555" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel sx={{ color: "#aaa" }}>Category</InputLabel>
<Select
value={assembly.category}
label="Category"
onChange={(e) => updateAssembly({ category: e.target.value })}
sx={{
bgcolor: "#121212",
color: "#e6edf3",
"& .MuiOutlinedInput-notchedOutline": { borderColor: "#333" },
"&:hover .MuiOutlinedInput-notchedOutline": { borderColor: "#555" }
}}
>
{CATEGORY_OPTIONS.map((o) => (
<MenuItem key={o.key} value={o.key}>{o.label}</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel sx={{ color: "#aaa" }}>Type</InputLabel>
<Select
value={assembly.type}
label="Type"
onChange={(e) => updateAssembly({ type: e.target.value })}
sx={{
bgcolor: "#121212",
color: "#e6edf3",
"& .MuiOutlinedInput-notchedOutline": { borderColor: "#333" },
"&:hover .MuiOutlinedInput-notchedOutline": { borderColor: "#555" }
}}
>
{TYPE_OPTIONS.map((o) => (
<MenuItem key={o.key} value={o.key}>{o.label}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</Grid>
<Box sx={{ mt: 3 }}>
<Typography variant="subtitle2" sx={{ color: "#58a6ff", mb: 1 }}>
Script Content
</Typography>
<Box sx={{ border: "1px solid #333", borderRadius: 1, background: "#121212" }}>
<Editor
value={assembly.script}
onValueChange={(value) => 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: "#121212",
outline: "none",
minHeight: 320,
lineHeight: 1.45,
caretColor: "#58a6ff"
}}
/>
</Box>
</Box>
<Grid container spacing={2} sx={{ mt: 3 }}>
<Grid item xs={12} md={6}>
<TextField
label="Timeout (seconds)"
type="number"
value={assembly.timeoutSeconds}
onChange={(e) => {
const val = Number(e.target.value);
updateAssembly({ timeoutSeconds: Number.isNaN(val) ? 0 : val });
}}
fullWidth
variant="outlined"
sx={{
"& .MuiOutlinedInput-root": {
bgcolor: "#121212",
color: "#e6edf3",
"& fieldset": { borderColor: "#333" },
"&:hover fieldset": { borderColor: "#555" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
helperText="Timeout this script if not completed within X seconds"
/>
</Grid>
<Grid item xs={12} md={6}>
<Typography variant="subtitle2" sx={{ color: "#58a6ff", mb: 1 }}>
Sites
</Typography>
<RadioGroup
row
value={assembly.sites.mode === "specific" ? "specific" : "all"}
onChange={(e) => handleSitesChange(e.target.value, assembly.sites.values)}
sx={{ color: "#e6edf3" }}
>
<FormControlLabel value="all" control={<Radio sx={{ color: "#58a6ff" }} />} label="All Sites" />
<FormControlLabel value="specific" control={<Radio sx={{ color: "#58a6ff" }} />} label="Specific Sites" />
</RadioGroup>
{assembly.sites.mode === "specific" ? (
<TextField
label="Allowed Sites (one per line)"
value={siteValuesText}
onChange={(e) => handleSitesChange("specific", e.target.value)}
multiline
minRows={3}
fullWidth
variant="outlined"
sx={{
mt: 1,
"& .MuiOutlinedInput-root": {
bgcolor: "#121212",
color: "#e6edf3",
"& fieldset": { borderColor: "#333" },
"&:hover fieldset": { borderColor: "#555" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
/>
) : null}
</Grid>
</Grid>
<Box sx={{ mt: 4 }}>
<Typography variant="subtitle2" sx={{ color: "#58a6ff", mb: 1 }}>
Variables
</Typography>
<Typography variant="body2" sx={{ color: "#9ba3b4", mb: 2 }}>
Variables are passed into the execution environment as environment variables at runtime.
</Typography>
{(assembly.variables || []).length ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{assembly.variables.map((variable) => (
<Paper
key={variable.id}
sx={{ p: 2, bgcolor: "#171717", border: "1px solid #2a2a2a", borderRadius: 1 }}
>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} md={3}>
<TextField
label="Variable Name"
value={variable.name}
onChange={(e) => updateVariable(variable.id, { name: e.target.value })}
fullWidth
variant="outlined"
sx={{
"& .MuiOutlinedInput-root": {
bgcolor: "#121212",
color: "#e6edf3",
"& fieldset": { borderColor: "#333" },
"&:hover fieldset": { borderColor: "#555" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
/>
</Grid>
<Grid item xs={12} md={3}>
<TextField
label="Display Label"
value={variable.label}
onChange={(e) => updateVariable(variable.id, { label: e.target.value })}
fullWidth
variant="outlined"
sx={{
"& .MuiOutlinedInput-root": {
bgcolor: "#121212",
color: "#e6edf3",
"& fieldset": { borderColor: "#333" },
"&:hover fieldset": { borderColor: "#555" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
/>
</Grid>
<Grid item xs={12} md={2}>
<FormControl fullWidth>
<InputLabel sx={{ color: "#aaa" }}>Type</InputLabel>
<Select
value={variable.type}
label="Type"
onChange={(e) => updateVariable(variable.id, { type: e.target.value })}
sx={{
bgcolor: "#121212",
color: "#e6edf3",
"& .MuiOutlinedInput-notchedOutline": { borderColor: "#333" },
"&:hover .MuiOutlinedInput-notchedOutline": { borderColor: "#555" }
}}
>
{VARIABLE_TYPE_OPTIONS.map((opt) => (
<MenuItem key={opt.key} value={opt.key}>{opt.label}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={3}>
{variable.type === "boolean" ? (
<FormControlLabel
control={
<Checkbox
checked={Boolean(variable.defaultValue)}
onChange={(e) => updateVariable(variable.id, { defaultValue: e.target.checked })}
sx={{ color: "#58a6ff" }}
/>
}
label="Default Value"
/>
) : (
<TextField
label="Default Value"
value={variable.defaultValue ?? ""}
onChange={(e) => updateVariable(variable.id, { defaultValue: e.target.value })}
fullWidth
variant="outlined"
sx={{
"& .MuiOutlinedInput-root": {
bgcolor: "#121212",
color: "#e6edf3",
"& fieldset": { borderColor: "#333" },
"&:hover fieldset": { borderColor: "#555" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
/>
)}
</Grid>
<Grid item xs={12} md={1} sx={{ display: "flex", justifyContent: "center" }}>
<Tooltip title="Required">
<Checkbox
checked={Boolean(variable.required)}
onChange={(e) => updateVariable(variable.id, { required: e.target.checked })}
sx={{ color: "#58a6ff" }}
/>
</Tooltip>
</Grid>
<Grid item xs={12}>
<TextField
label="Description"
value={variable.description}
onChange={(e) => updateVariable(variable.id, { description: e.target.value })}
fullWidth
multiline
minRows={2}
variant="outlined"
sx={{
"& .MuiOutlinedInput-root": {
bgcolor: "#121212",
color: "#e6edf3",
"& fieldset": { borderColor: "#333" },
"&:hover fieldset": { borderColor: "#555" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
/>
</Grid>
<Grid item xs={12} sx={{ display: "flex", justifyContent: "flex-end" }}>
<IconButton onClick={() => removeVariable(variable.id)} sx={{ color: "#ff6b6b" }}>
<DeleteIcon />
</IconButton>
</Grid>
</Grid>
</Paper>
))}
</Box>
) : (
<Typography variant="body2" sx={{ color: "#787f8b", mb: 1 }}>
No variables have been defined.
</Typography>
)}
<Button
startIcon={<AddIcon />}
onClick={addVariable}
sx={{ mt: 2, color: "#58a6ff", textTransform: "none" }}
>
Add Variable
</Button>
</Box>
<Box sx={{ mt: 4 }}>
<Typography variant="subtitle2" sx={{ color: "#58a6ff", mb: 1 }}>
Files
</Typography>
<Typography variant="body2" sx={{ color: "#9ba3b4", mb: 2 }}>
Upload supporting files. They will be embedded as Base64 and available to the assembly at runtime.
</Typography>
{(assembly.files || []).length ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{assembly.files.map((file) => (
<Paper key={file.id} sx={{ p: 1.5, bgcolor: "#171717", border: "1px solid #2a2a2a", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<Box>
<Typography variant="body2" sx={{ color: "#e6edf3" }}>{file.fileName}</Typography>
<Typography variant="caption" sx={{ color: "#888" }}>{formatBytes(file.size)}{file.mimeType ? `${file.mimeType}` : ""}</Typography>
</Box>
<IconButton onClick={() => removeFile(file.id)} sx={{ color: "#ff6b6b" }}>
<DeleteIcon />
</IconButton>
</Paper>
))}
</Box>
) : (
<Typography variant="body2" sx={{ color: "#787f8b", mb: 1 }}>
No files uploaded yet.
</Typography>
)}
<Button
component="label"
startIcon={<UploadFileIcon />}
sx={{ mt: 2, color: "#58a6ff", textTransform: "none" }}
>
Upload File
<input type="file" hidden multiple onChange={handleFileUpload} />
</Button>
</Box>
</Paper>
</Box>
<RenameFileDialog
open={renameOpen}
value={renameValue}
onChange={setRenameValue}
onCancel={() => setRenameOpen(false)}
onSave={saveRename}
/>
<ConfirmDeleteDialog
open={deleteOpen}
message="Deleting this assembly cannot be undone. Continue?"
onCancel={() => setDeleteOpen(false)}
onConfirm={deleteAssembly}
/>
</Box>
);
}

View File

@@ -423,7 +423,7 @@ function buildFileTree(rootLabel, items, folders) {
if (!node) { if (!node) {
node = { node = {
id: path, id: path,
label: isFile ? (s.file_name || part) : part, label: isFile ? (s.name || s.display_name || s.file_name || part) : part,
path, path,
isFolder: !isFile, isFolder: !isFile,
fileName: s.file_name, fileName: s.file_name,
@@ -591,32 +591,21 @@ function ScriptsLikeIsland({
} }
}; };
const createNewItem = async () => { const createNewItem = () => {
try { const trimmedName = (newItemName || '').trim();
const folder = selectedNode?.isFolder ? selectedNode.path : (selectedNode?.path?.split("/").slice(0, -1).join("/") || ""); const folder = selectedNode?.isFolder
let name = newItemName || "new"; ? selectedNode.path
const hasExt = /\.[^./\\]+$/i.test(name); : (selectedNode?.path?.split("/").slice(0, -1).join("/") || "");
if (!hasExt) { const context = {
if (String(baseApi || '').endsWith('/api/ansible')) name += '.yml'; folder,
else name += '.ps1'; suggestedFileName: trimmedName,
} defaultType: island === 'ansible' ? 'ansible' : 'powershell',
const newPath = folder ? `${folder}/${name}` : name; type: island === 'ansible' ? 'ansible' : 'powershell',
// create empty file via unified API category: island === 'ansible' ? 'application' : 'script'
const res = await fetch(`/api/assembly/create`, { };
method: "POST", setNewItemOpen(false);
headers: { "Content-Type": "application/json" }, setNewItemName("");
body: JSON.stringify({ island, kind: 'file', path: newPath, content: "", type: island === 'ansible' ? 'ansible' : 'powershell' }) onEdit && onEdit(null, context);
});
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) => const renderItems = (nodes) =>
@@ -754,7 +743,7 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
rootLabel="Scripts" rootLabel="Scripts"
baseApi="/api/scripts" baseApi="/api/scripts"
newItemLabel="New Script" newItemLabel="New Script"
onEdit={(rel) => onOpenScript && onOpenScript(rel, 'scripts')} onEdit={(rel, ctx) => onOpenScript && onOpenScript(rel, 'scripts', ctx)}
/> />
{/* Right: Ansible Playbooks */} {/* Right: Ansible Playbooks */}
@@ -764,7 +753,7 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
rootLabel="Ansible Playbooks" rootLabel="Ansible Playbooks"
baseApi="/api/ansible" baseApi="/api/ansible"
newItemLabel="New Playbook" newItemLabel="New Playbook"
onEdit={(rel) => onOpenScript && onOpenScript(rel, 'ansible')} onEdit={(rel, ctx) => onOpenScript && onOpenScript(rel, 'ansible', ctx)}
/> />
</Box> </Box>
</Box> </Box>

View File

@@ -1,223 +0,0 @@
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
}
}
function RenameFileDialog({ open, value, onChange, onCancel, onSave }) {
if (!open) return null;
return (
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 9999 }}>
<Paper sx={{ bgcolor: "#121212", color: "#fff", p: 2, minWidth: 360 }}>
<Typography variant="h6" sx={{ mb: 1 }}>Rename</Typography>
<TextField autoFocus margin="dense" label="Name" fullWidth variant="outlined" value={value} onChange={(e) => onChange(e.target.value)}
sx={{ "& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } }, label: { color: "#aaa" }, mt: 1 }} />
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 2 }}>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
</Box>
</Paper>
</div>
);
}
function NewItemDialog({ open, name, type, typeOptions, onChangeName, onChangeType, onCancel, onCreate }) {
if (!open) return null;
return (
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 9999 }}>
<Paper sx={{ bgcolor: "#121212", color: "#fff", p: 2, minWidth: 360 }}>
<Typography variant="h6" sx={{ mb: 1 }}>New</Typography>
<TextField autoFocus margin="dense" label="Name" fullWidth variant="outlined" value={name} onChange={(e) => onChangeName(e.target.value)}
sx={{ "& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } }, label: { color: "#aaa" }, mt: 1 }} />
<FormControl fullWidth sx={{ mt: 2 }}>
<InputLabel sx={{ color: "#aaa" }}>Type</InputLabel>
<Select value={type} label="Type" onChange={(e) => onChangeType(e.target.value)}
sx={{ color: "#e6edf3", bgcolor: "#1e1e1e", "& .MuiOutlinedInput-notchedOutline": { borderColor: "#444" }, "&:hover .MuiOutlinedInput-notchedOutline": { borderColor: "#666" } }}>
{typeOptions.map((o) => (<MenuItem key={o.key} value={o.key}>{o.label}</MenuItem>))}
</Select>
</FormControl>
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 2 }}>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onCreate} sx={{ color: "#58a6ff" }}>Create</Button>
</Box>
</Paper>
</div>
);
}
export default function ScriptEditor({ mode = "scripts", initialPath = "", onConsumedInitialPath, onSaved }) {
const isAnsible = mode === "ansible";
const 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);
const island = useMemo(() => (isAnsible ? 'ansible' : 'scripts'), [isAnsible]);
useEffect(() => {
(async () => {
if (!initialPath) return;
try {
const resp = await fetch(`/api/assembly/load?island=${encodeURIComponent(island)}&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, island]);
const saveFile = async () => {
if (!currentPath && !fileName) {
setNewName("");
setNewType(isAnsible ? "ansible" : type);
setNewOpen(true);
return;
}
const island = isAnsible ? 'ansible' : 'scripts';
const normalizedName = currentPath ? currentPath : ensureExt(fileName, type);
try {
// If we already have a path, edit; otherwise create
if (currentPath) {
const resp = await fetch(`/api/assembly/edit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island, path: currentPath, content: code })
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data?.error || `HTTP ${resp.status}`);
}
onSaved && onSaved();
} else {
const resp = await fetch(`/api/assembly/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island, kind: 'file', path: normalizedName, content: code, 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);
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 island = isAnsible ? 'ansible' : 'scripts';
const finalName = ensureExt(renameValue, type);
const res = await fetch(`/api/assembly/rename`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ island, kind: 'file', path: currentPath, new_name: finalName, type }) });
const data = await res.json();
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
setCurrentPath(data.rel_path || currentPath);
const fname = (data.rel_path || currentPath).split('/').pop();
setFileName(fname);
setType(typeFromFilename(fname));
setRenameOpen(false);
} catch (err) {
console.error("Failed to rename file:", err);
setRenameOpen(false);
}
};
const createNew = () => {
const finalName = ensureExt(newName || (isAnsible ? "playbook" : "script"), newType);
setCurrentPath(finalName);
setFileName(finalName);
setType(newType);
setCode("");
setNewOpen(false);
};
return (
<Box sx={{ display: "flex", flex: 1, height: "100%", overflow: "hidden" }}>
<Paper sx={{ my: 2, mx: 2, p: 1.5, bgcolor: "#1e1e1e", display: "flex", flexDirection: "column", flex: 1 }} elevation={2}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2 }}>
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel sx={{ color: "#aaa" }}>Type</InputLabel>
<Select value={type} label="Type" onChange={(e) => setType(e.target.value)} sx={{ color: "#e6edf3", bgcolor: "#1e1e1e", "& .MuiOutlinedInput-notchedOutline": { borderColor: "#444" }, "&:hover .MuiOutlinedInput-notchedOutline": { borderColor: "#666" } }}>
{TYPE_OPTIONS.map((o) => (<MenuItem key={o.key} value={o.key}>{o.label}</MenuItem>))}
</Select>
</FormControl>
<Box sx={{ flex: 1 }} />
{fileName && (
<Button onClick={() => { setRenameValue(fileName); setRenameOpen(true); }} sx={{ color: "#58a6ff", textTransform: "none" }}>Rename: {fileName}</Button>
)}
<Button onClick={saveFile} sx={{ color: "#58a6ff", borderColor: "#58a6ff", textTransform: "none", border: "1px solid #58a6ff", backgroundColor: "#1e1e1e", "&:hover": { backgroundColor: "#1b1b1b" } }}>Save</Button>
</Box>
<Box sx={{ flex: 1, minHeight: 300, border: "1px solid #444", borderRadius: 1, background: "#121212", overflow: "auto" }}>
<Editor value={code} onValueChange={setCode} highlight={(src) => highlightedHtml(src, (keyBy(TYPE_OPTIONS_ALL)[type]?.prism || 'yaml'))} padding={12} placeholder={currentPath ? `Editing: ${currentPath}` : (isAnsible ? "New Playbook..." : "New Script...")}
style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', fontSize: 14, color: "#e6edf3", background: "#121212", outline: "none", minHeight: 300, lineHeight: 1.4, caretColor: "#58a6ff" }} />
</Box>
</Paper>
{/* Dialogs */}
<RenameFileDialog open={renameOpen} value={renameValue} onChange={setRenameValue} onCancel={() => setRenameOpen(false)} onSave={saveRenameFile} />
<NewItemDialog open={newOpen} name={newName} type={newType} typeOptions={TYPE_OPTIONS} onChangeName={setNewName} onChangeType={setNewType} onCancel={() => setNewOpen(false)} onCreate={createNew} />
<ConfirmDeleteDialog open={deleteOpen} message="If you delete this, there is no undo button, are you sure you want to proceed?" onCancel={() => setDeleteOpen(false)} onConfirm={() => { setDeleteOpen(false); onSaved && onSaved(); }} />
</Box>
);
}

View File

@@ -78,7 +78,7 @@ function buildScriptTree(scripts, folders) {
const isFile = idx === parts.length - 1; const isFile = idx === parts.length - 1;
let node = children.find((n) => n.id === path); let node = children.find((n) => n.id === path);
if (!node) { if (!node) {
node = { id: path, label: isFile ? s.file_name : part, path, isFolder: !isFile, fileName: s.file_name, script: isFile ? s : null, children: [] }; node = { id: path, label: isFile ? (s.name || s.file_name || part) : part, path, isFolder: !isFile, fileName: s.file_name, script: isFile ? s : null, children: [] };
children.push(node); map[path] = node; children.push(node); map[path] = node;
} }
if (!isFile) { children = node.children; parentPath = path; } if (!isFile) { children = node.children; parentPath = path; }

View File

@@ -53,7 +53,7 @@ function buildTree(items, folders, rootLabel = "Scripts") {
if (!node) { if (!node) {
node = { node = {
id: path, id: path,
label: isFile ? s.file_name : part, label: isFile ? (s.name || s.file_name || part) : part,
path, path,
isFolder: !isFile, isFolder: !isFile,
fileName: s.file_name, fileName: s.file_name,

View File

@@ -1,6 +1,8 @@
import os import os
import time import time
import json import json
import os
import re
import sqlite3 import sqlite3
from typing import Any, Dict, List, Optional, Tuple, Callable from typing import Any, Dict, List, Optional, Tuple, Callable
@@ -150,17 +152,111 @@ class JobScheduler:
return False return False
def _detect_script_type(self, filename: str) -> str: def _detect_script_type(self, filename: str) -> str:
fn = (filename or "").lower() fn_lower = (filename or "").lower()
if fn.endswith(".yml"): if fn_lower.endswith(".json") and os.path.isfile(filename):
return "ansible" try:
if fn.endswith(".ps1"): with open(filename, "r", encoding="utf-8") as fh:
data = json.load(fh)
if isinstance(data, dict):
typ = str(data.get("type") or data.get("script_type") or "").strip().lower()
if typ in ("powershell", "batch", "bash", "ansible"):
return typ
except Exception:
pass
return "powershell" return "powershell"
if fn.endswith(".bat"): if fn_lower.endswith(".yml"):
return "ansible"
if fn_lower.endswith(".ps1"):
return "powershell"
if fn_lower.endswith(".bat"):
return "batch" return "batch"
if fn.endswith(".sh"): if fn_lower.endswith(".sh"):
return "bash" return "bash"
return "unknown" return "unknown"
def _load_assembly_document(self, abs_path: str, default_type: str) -> Dict[str, Any]:
base_name = os.path.splitext(os.path.basename(abs_path))[0]
doc: Dict[str, Any] = {
"name": base_name,
"description": "",
"category": "application" if default_type == "ansible" else "script",
"type": default_type,
"script": "",
"variables": [],
"files": [],
"timeout_seconds": 0,
}
if abs_path.lower().endswith(".json") and os.path.isfile(abs_path):
try:
with open(abs_path, "r", encoding="utf-8") as fh:
data = json.load(fh)
except Exception:
data = {}
if isinstance(data, dict):
doc["name"] = str(data.get("name") or doc["name"])
doc["description"] = str(data.get("description") or "")
cat = str(data.get("category") or doc["category"]).strip().lower()
if cat in ("application", "script"):
doc["category"] = cat
typ = str(data.get("type") or data.get("script_type") or default_type).strip().lower()
if typ in ("powershell", "batch", "bash", "ansible"):
doc["type"] = typ
script_val = data.get("script")
if isinstance(script_val, str):
doc["script"] = script_val
else:
content_val = data.get("content")
if isinstance(content_val, str):
doc["script"] = content_val
try:
doc["timeout_seconds"] = max(0, int(data.get("timeout_seconds") or 0))
except Exception:
doc["timeout_seconds"] = 0
vars_in = data.get("variables") if isinstance(data.get("variables"), list) else []
doc["variables"] = []
for v in vars_in:
if not isinstance(v, dict):
continue
name = str(v.get("name") or v.get("key") or "").strip()
if not name:
continue
vtype = str(v.get("type") or "string").strip().lower()
if vtype not in ("string", "number", "boolean", "credential"):
vtype = "string"
doc["variables"].append({
"name": name,
"label": str(v.get("label") or ""),
"type": vtype,
"default": v.get("default", v.get("default_value")),
"required": bool(v.get("required")),
"description": str(v.get("description") or ""),
})
files_in = data.get("files") if isinstance(data.get("files"), list) else []
doc["files"] = []
for f in files_in:
if not isinstance(f, dict):
continue
fname = f.get("file_name") or f.get("name")
if not fname or not isinstance(f.get("data"), str):
continue
try:
size_val = int(f.get("size") or 0)
except Exception:
size_val = 0
doc["files"].append({
"file_name": str(fname),
"size": size_val,
"mime_type": str(f.get("mime_type") or f.get("mimeType") or ""),
"data": f.get("data"),
})
return doc
try:
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
doc["script"] = fh.read()
except Exception:
doc["script"] = ""
return doc
def _ansible_root(self) -> str: def _ansible_root(self) -> str:
import os import os
return os.path.abspath( return os.path.abspath(
@@ -175,11 +271,10 @@ class JobScheduler:
abs_path = os.path.abspath(os.path.join(ans_root, rel_norm)) abs_path = os.path.abspath(os.path.join(ans_root, rel_norm))
if (not abs_path.startswith(ans_root)) or (not os.path.isfile(abs_path)): if (not abs_path.startswith(ans_root)) or (not os.path.isfile(abs_path)):
return return
try: doc = self._load_assembly_document(abs_path, "ansible")
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh: content = doc.get("script") or ""
content = fh.read() variables = doc.get("variables") or []
except Exception: files = doc.get("files") or []
return
# Record in activity_history for UI parity # Record in activity_history for UI parity
now = _now_ts() now = _now_ts()
@@ -217,6 +312,8 @@ class JobScheduler:
"scheduled_job_id": int(scheduled_job_id), "scheduled_job_id": int(scheduled_job_id),
"scheduled_run_id": int(scheduled_run_id), "scheduled_run_id": int(scheduled_run_id),
"connection": "winrm", "connection": "winrm",
"variables": variables,
"files": files,
} }
try: try:
self.socketio.emit("ansible_playbook_run", payload) self.socketio.emit("ansible_playbook_run", payload)
@@ -236,15 +333,33 @@ class JobScheduler:
abs_path = os.path.abspath(os.path.join(scripts_root, path_norm)) abs_path = os.path.abspath(os.path.join(scripts_root, path_norm))
if (not abs_path.startswith(scripts_root)) or (not self._is_valid_scripts_relpath(path_norm)) or (not os.path.isfile(abs_path)): if (not abs_path.startswith(scripts_root)) or (not self._is_valid_scripts_relpath(path_norm)) or (not os.path.isfile(abs_path)):
return return
stype = self._detect_script_type(abs_path) doc = self._load_assembly_document(abs_path, "powershell")
stype = (doc.get("type") or "powershell").lower()
# For now, only PowerShell is supported by agents for scheduled jobs # For now, only PowerShell is supported by agents for scheduled jobs
if stype != "powershell": if stype != "powershell":
return return
content = doc.get("script") or ""
env_map: Dict[str, str] = {}
for var in doc.get("variables") or []:
if not isinstance(var, dict):
continue
name = str(var.get("name") or "").strip()
if not name:
continue
env_key = re.sub(r"[^A-Za-z0-9_]", "_", name.upper())
default_val = var.get("default")
if isinstance(default_val, bool):
env_val = "True" if default_val else "False"
elif default_val is None:
env_val = ""
else:
env_val = str(default_val)
env_map[env_key] = env_val
timeout_seconds = 0
try: try:
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh: timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0))
content = fh.read()
except Exception: except Exception:
return timeout_seconds = 0
# Insert into activity_history for device for parity with Quick Job # Insert into activity_history for device for parity with Quick Job
import sqlite3 import sqlite3
@@ -281,6 +396,10 @@ class JobScheduler:
"script_name": os.path.basename(abs_path), "script_name": os.path.basename(abs_path),
"script_path": path_norm, "script_path": path_norm,
"script_content": content, "script_content": content,
"environment": env_map,
"variables": doc.get("variables") or [],
"timeout_seconds": timeout_seconds,
"files": doc.get("files") or [],
"run_mode": (run_mode or "system").strip().lower(), "run_mode": (run_mode or "system").strip().lower(),
"admin_user": "", "admin_user": "",
"admin_pass": "", "admin_pass": "",

View File

@@ -5,6 +5,7 @@ import eventlet
eventlet.monkey_patch() eventlet.monkey_patch()
import requests import requests
import re
import base64 import base64
from flask import Flask, request, jsonify, Response, send_from_directory, make_response, session from flask import Flask, request, jsonify, Response, send_from_directory, make_response, session
from flask_socketio import SocketIO, emit, join_room from flask_socketio import SocketIO, emit, join_room
@@ -16,7 +17,7 @@ import time
import os # To Read Production ReactJS Server Folder import os # To Read Production ReactJS Server Folder
import json # For reading workflow JSON files import json # For reading workflow JSON files
import shutil # For moving workflow files and folders import shutil # For moving workflow files and folders
from typing import List, Dict, Tuple, Optional from typing import List, Dict, Tuple, Optional, Any
import sqlite3 import sqlite3
import io import io
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -650,16 +651,145 @@ def _default_ext_for_island(island: str, item_type: str = "") -> str:
if isl in ("workflows", "workflow"): if isl in ("workflows", "workflow"):
return ".json" return ".json"
if isl in ("ansible", "ansible_playbooks", "ansible-playbooks", "playbooks"): if isl in ("ansible", "ansible_playbooks", "ansible-playbooks", "playbooks"):
return ".yml" return ".json"
# scripts: use hint or default to .ps1 if isl in ("scripts", "script"):
return ".json"
t = (item_type or "").lower().strip() t = (item_type or "").lower().strip()
if t == "bash": if t == "bash":
return ".sh" return ".json"
if t == "batch": if t == "batch":
return ".bat" return ".json"
if t == "powershell": if t == "powershell":
return ".ps1" return ".json"
return ".ps1" return ".json"
def _default_type_for_island(island: str, item_type: str = "") -> str:
isl = (island or "").lower().strip()
if isl in ("ansible", "ansible_playbooks", "ansible-playbooks", "playbooks"):
return "ansible"
t = (item_type or "").lower().strip()
if t in ("powershell", "batch", "bash", "ansible"):
return t
return "powershell"
def _empty_assembly_document(default_type: str = "powershell") -> Dict[str, Any]:
return {
"version": 1,
"name": "",
"description": "",
"category": "application" if (default_type or "").lower() == "ansible" else "script",
"type": default_type or "powershell",
"script": "",
"timeout_seconds": 0,
"sites": {"mode": "all", "values": []},
"variables": [],
"files": []
}
def _normalize_assembly_document(obj: Any, default_type: str, base_name: str) -> Dict[str, Any]:
doc = _empty_assembly_document(default_type)
if not isinstance(obj, dict):
obj = {}
base = (base_name or "assembly").strip()
doc["name"] = str(obj.get("name") or obj.get("display_name") or base)
doc["description"] = str(obj.get("description") or "")
category = str(obj.get("category") or doc["category"]).strip().lower()
if category in ("script", "application"):
doc["category"] = category
typ = str(obj.get("type") or obj.get("script_type") or default_type or "powershell").strip().lower()
if typ in ("powershell", "batch", "bash", "ansible"):
doc["type"] = typ
script_val = obj.get("script")
if isinstance(script_val, str):
doc["script"] = script_val
else:
content_val = obj.get("content")
if isinstance(content_val, str):
doc["script"] = content_val
timeout_val = obj.get("timeout_seconds", obj.get("timeout"))
if timeout_val is not None:
try:
doc["timeout_seconds"] = max(0, int(timeout_val))
except Exception:
pass
sites = obj.get("sites") if isinstance(obj.get("sites"), dict) else {}
values = sites.get("values") if isinstance(sites.get("values"), list) else []
mode = str(sites.get("mode") or ("specific" if values else "all")).strip().lower()
if mode not in ("all", "specific"):
mode = "all"
doc["sites"] = {
"mode": mode,
"values": [str(v).strip() for v in values if isinstance(v, (str, int, float)) and str(v).strip()]
}
vars_in = obj.get("variables") if isinstance(obj.get("variables"), list) else []
doc_vars: List[Dict[str, Any]] = []
for v in vars_in:
if not isinstance(v, dict):
continue
name = str(v.get("name") or v.get("key") or "").strip()
if not name:
continue
vtype = str(v.get("type") or "string").strip().lower()
if vtype not in ("string", "number", "boolean", "credential"):
vtype = "string"
default_val = v.get("default", v.get("default_value"))
doc_vars.append({
"name": name,
"label": str(v.get("label") or ""),
"type": vtype,
"default": default_val,
"required": bool(v.get("required")),
"description": str(v.get("description") or "")
})
doc["variables"] = doc_vars
files_in = obj.get("files") if isinstance(obj.get("files"), list) else []
doc_files: List[Dict[str, Any]] = []
for f in files_in:
if not isinstance(f, dict):
continue
fname = f.get("file_name") or f.get("name")
data = f.get("data")
if not fname or not isinstance(data, str):
continue
size_val = f.get("size")
try:
size_int = int(size_val)
except Exception:
size_int = 0
doc_files.append({
"file_name": str(fname),
"size": size_int,
"mime_type": str(f.get("mime_type") or f.get("mimeType") or ""),
"data": data
})
doc["files"] = doc_files
try:
doc["version"] = int(obj.get("version") or doc["version"])
except Exception:
pass
return doc
def _load_assembly_document(abs_path: str, island: str, type_hint: str = "") -> Dict[str, Any]:
base_name = os.path.splitext(os.path.basename(abs_path))[0]
default_type = _default_type_for_island(island, type_hint)
if abs_path.lower().endswith(".json"):
data = _safe_read_json(abs_path)
return _normalize_assembly_document(data, default_type, base_name)
try:
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
content = fh.read()
except Exception:
content = ""
doc = _empty_assembly_document(default_type)
doc["name"] = base_name
doc["script"] = content
if default_type == "ansible":
doc["category"] = "application"
return doc
@app.route("/api/assembly/create", methods=["POST"]) @app.route("/api/assembly/create", methods=["POST"])
@@ -682,7 +812,7 @@ def assembly_create():
if not ext: if not ext:
abs_path = base + _default_ext_for_island(island, item_type) abs_path = base + _default_ext_for_island(island, item_type)
os.makedirs(os.path.dirname(abs_path), exist_ok=True) os.makedirs(os.path.dirname(abs_path), exist_ok=True)
# Workflows expect JSON; others raw text # Workflows expect JSON; scripts/ansible use assembly documents
if (island or "").lower() in ("workflows", "workflow"): if (island or "").lower() in ("workflows", "workflow"):
obj = content obj = content
if isinstance(obj, str): if isinstance(obj, str):
@@ -699,8 +829,22 @@ def assembly_create():
with open(abs_path, "w", encoding="utf-8") as fh: with open(abs_path, "w", encoding="utf-8") as fh:
json.dump(obj, fh, indent=2) json.dump(obj, fh, indent=2)
else: else:
with open(abs_path, "w", encoding="utf-8", newline="\n") as fh: obj = content
fh.write(str(content or "")) if isinstance(obj, str):
try:
obj = json.loads(obj)
except Exception:
obj = {}
if not isinstance(obj, dict):
obj = {}
base_name = os.path.splitext(os.path.basename(abs_path))[0]
normalized = _normalize_assembly_document(
obj,
_default_type_for_island(island, item_type),
base_name,
)
with open(abs_path, "w", encoding="utf-8") as fh:
json.dump(normalized, fh, indent=2)
rel_new = os.path.relpath(abs_path, root).replace(os.sep, "/") rel_new = os.path.relpath(abs_path, root).replace(os.sep, "/")
return jsonify({"status": "ok", "rel_path": rel_new}) return jsonify({"status": "ok", "rel_path": rel_new})
else: else:
@@ -721,18 +865,42 @@ def assembly_edit():
root, abs_path, _ = _resolve_assembly_path(island, path) root, abs_path, _ = _resolve_assembly_path(island, path)
if not os.path.isfile(abs_path): if not os.path.isfile(abs_path):
return jsonify({"error": "file not found"}), 404 return jsonify({"error": "file not found"}), 404
target_abs = abs_path
if not abs_path.lower().endswith(".json"):
base, _ = os.path.splitext(abs_path)
target_abs = base + _default_ext_for_island(island, data.get("type"))
if (island or "").lower() in ("workflows", "workflow"): if (island or "").lower() in ("workflows", "workflow"):
obj = content obj = content
if isinstance(obj, str): if isinstance(obj, str):
obj = json.loads(obj) obj = json.loads(obj)
if not isinstance(obj, dict): if not isinstance(obj, dict):
return jsonify({"error": "invalid content for workflow"}), 400 return jsonify({"error": "invalid content for workflow"}), 400
with open(abs_path, "w", encoding="utf-8") as fh: with open(target_abs, "w", encoding="utf-8") as fh:
json.dump(obj, fh, indent=2) json.dump(obj, fh, indent=2)
else: else:
with open(abs_path, "w", encoding="utf-8", newline="\n") as fh: obj = content
fh.write(str(content or "")) if isinstance(obj, str):
return jsonify({"status": "ok"}) try:
obj = json.loads(obj)
except Exception:
obj = {}
if not isinstance(obj, dict):
obj = {}
base_name = os.path.splitext(os.path.basename(target_abs))[0]
normalized = _normalize_assembly_document(
obj,
_default_type_for_island(island, obj.get("type") if isinstance(obj, dict) else ""),
base_name,
)
with open(target_abs, "w", encoding="utf-8") as fh:
json.dump(normalized, fh, indent=2)
if target_abs != abs_path:
try:
os.remove(abs_path)
except Exception:
pass
rel_new = os.path.relpath(target_abs, root).replace(os.sep, "/")
return jsonify({"status": "ok", "rel_path": rel_new})
except ValueError as ve: except ValueError as ve:
return jsonify({"error": str(ve)}), 400 return jsonify({"error": str(ve)}), 400
except Exception as e: except Exception as e:
@@ -885,7 +1053,7 @@ def assembly_list():
"last_edited_epoch": mtime "last_edited_epoch": mtime
}) })
elif isl in ("scripts", "script"): elif isl in ("scripts", "script"):
exts = (".ps1", ".bat", ".sh") exts = (".json", ".ps1", ".bat", ".sh")
for r, dirs, files in os.walk(root): for r, dirs, files in os.walk(root):
rel_root = os.path.relpath(r, root) rel_root = os.path.relpath(r, root)
if rel_root != ".": if rel_root != ".":
@@ -899,15 +1067,20 @@ def assembly_list():
mtime = os.path.getmtime(fp) mtime = os.path.getmtime(fp)
except Exception: except Exception:
mtime = 0.0 mtime = 0.0
stype = _detect_script_type(fp)
doc = _load_assembly_document(fp, "scripts", stype)
items.append({ items.append({
"file_name": fname, "file_name": fname,
"rel_path": rel_path, "rel_path": rel_path,
"type": _detect_script_type(fname), "type": doc.get("type", stype),
"name": doc.get("name"),
"category": doc.get("category"),
"description": doc.get("description"),
"last_edited": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(mtime)), "last_edited": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(mtime)),
"last_edited_epoch": mtime "last_edited_epoch": mtime
}) })
else: # ansible else: # ansible
exts = (".yml",) exts = (".json", ".yml")
for r, dirs, files in os.walk(root): for r, dirs, files in os.walk(root):
rel_root = os.path.relpath(r, root) rel_root = os.path.relpath(r, root)
if rel_root != ".": if rel_root != ".":
@@ -921,10 +1094,15 @@ def assembly_list():
mtime = os.path.getmtime(fp) mtime = os.path.getmtime(fp)
except Exception: except Exception:
mtime = 0.0 mtime = 0.0
stype = _detect_script_type(fp)
doc = _load_assembly_document(fp, "ansible", stype)
items.append({ items.append({
"file_name": fname, "file_name": fname,
"rel_path": rel_path, "rel_path": rel_path,
"type": "ansible", "type": doc.get("type", "ansible"),
"name": doc.get("name"),
"category": doc.get("category"),
"description": doc.get("description"),
"last_edited": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(mtime)), "last_edited": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(mtime)),
"last_edited_epoch": mtime "last_edited_epoch": mtime
}) })
@@ -951,14 +1129,16 @@ def assembly_load():
obj = _safe_read_json(abs_path) obj = _safe_read_json(abs_path)
return jsonify(obj) return jsonify(obj)
else: else:
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh: doc = _load_assembly_document(abs_path, island)
content = fh.read() rel = os.path.relpath(abs_path, root).replace(os.sep, "/")
return jsonify({ result = {
"file_name": os.path.basename(abs_path), "file_name": os.path.basename(abs_path),
"rel_path": os.path.relpath(abs_path, root).replace(os.sep, "/"), "rel_path": rel,
"type": ("ansible" if isl.startswith("ansible") else _detect_script_type(abs_path)), "type": doc.get("type"),
"content": content "assembly": doc,
}) "content": doc.get("script")
}
return jsonify(result)
except ValueError as ve: except ValueError as ve:
return jsonify({"error": str(ve)}), 400 return jsonify({"error": str(ve)}), 400
except Exception as e: except Exception as e:
@@ -991,29 +1171,33 @@ def _is_valid_scripts_relpath(rel_path: str) -> bool:
def _detect_script_type(filename: str) -> str: def _detect_script_type(filename: str) -> str:
fn = (filename or "").lower() fn_lower = (filename or "").lower()
if fn.endswith(".yml"): if fn_lower.endswith(".json") and os.path.isfile(filename):
return "ansible" try:
if fn.endswith(".ps1"): obj = _safe_read_json(filename)
if isinstance(obj, dict):
typ = str(obj.get("type") or obj.get("script_type") or "").strip().lower()
if typ in ("powershell", "batch", "bash", "ansible"):
return typ
except Exception:
pass
return "powershell" return "powershell"
if fn.endswith(".bat"): if fn_lower.endswith(".yml"):
return "ansible"
if fn_lower.endswith(".ps1"):
return "powershell"
if fn_lower.endswith(".bat"):
return "batch" return "batch"
if fn.endswith(".sh"): if fn_lower.endswith(".sh"):
return "bash" return "bash"
return "unknown" return "unknown"
def _ext_for_type(script_type: str) -> str: def _ext_for_type(script_type: str) -> str:
t = (script_type or "").lower() t = (script_type or "").lower()
if t == "ansible": if t in ("ansible", "powershell", "batch", "bash"):
return ".yml" return ".json"
if t == "powershell": return ".json"
return ".ps1"
if t == "batch":
return ".bat"
if t == "bash":
return ".sh"
return ""
""" """
@@ -2594,14 +2778,24 @@ def set_device_description(hostname: str):
# Quick Job Execution + Activity History # Quick Job Execution + Activity History
# --------------------------------------------- # ---------------------------------------------
def _detect_script_type(fn: str) -> str: def _detect_script_type(fn: str) -> str:
fn = (fn or "").lower() fn_lower = (fn or "").lower()
if fn.endswith(".yml"): if fn_lower.endswith(".json") and os.path.isfile(fn):
return "ansible" try:
if fn.endswith(".ps1"): obj = _safe_read_json(fn)
if isinstance(obj, dict):
typ = str(obj.get("type") or obj.get("script_type") or "").strip().lower()
if typ in ("powershell", "batch", "bash", "ansible"):
return typ
except Exception:
pass
return "powershell" return "powershell"
if fn.endswith(".bat"): if fn_lower.endswith(".yml"):
return "ansible"
if fn_lower.endswith(".ps1"):
return "powershell"
if fn_lower.endswith(".bat"):
return "batch" return "batch"
if fn.endswith(".sh"): if fn_lower.endswith(".sh"):
return "bash" return "bash"
return "unknown" return "unknown"
@@ -2634,15 +2828,34 @@ def scripts_quick_run():
if (not abs_path.startswith(scripts_root)) or (not _is_valid_scripts_relpath(rel_path)) or (not os.path.isfile(abs_path)): if (not abs_path.startswith(scripts_root)) or (not _is_valid_scripts_relpath(rel_path)) or (not os.path.isfile(abs_path)):
return jsonify({"error": "Script not found"}), 404 return jsonify({"error": "Script not found"}), 404
script_type = _detect_script_type(abs_path) doc = _load_assembly_document(abs_path, "scripts")
script_type = (doc.get("type") or "powershell").lower()
if script_type != "powershell": if script_type != "powershell":
return jsonify({"error": f"Unsupported script type '{script_type}'. Only powershell is supported for Quick Job currently."}), 400 return jsonify({"error": f"Unsupported script type '{script_type}'. Only powershell is supported for Quick Job currently."}), 400
content = doc.get("script") or ""
variables = doc.get("variables") if isinstance(doc.get("variables"), list) else []
env_map: Dict[str, str] = {}
for var in variables:
if not isinstance(var, dict):
continue
name = str(var.get("name") or "").strip()
if not name:
continue
env_key = re.sub(r"[^A-Za-z0-9_]", "_", name.upper())
default_val = var.get("default")
if isinstance(default_val, bool):
env_val = "True" if default_val else "False"
elif default_val is None:
env_val = ""
else:
env_val = str(default_val)
env_map[env_key] = env_val
timeout_seconds = 0
try: try:
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh: timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0))
content = fh.read() except Exception:
except Exception as e: timeout_seconds = 0
return jsonify({"error": f"Failed to read script: {e}"}), 500
now = int(time.time()) now = int(time.time())
results = [] results = []
@@ -2680,6 +2893,10 @@ def scripts_quick_run():
"script_name": _safe_filename(rel_path), "script_name": _safe_filename(rel_path),
"script_path": rel_path.replace(os.sep, "/"), "script_path": rel_path.replace(os.sep, "/"),
"script_content": content, "script_content": content,
"environment": env_map,
"variables": variables,
"timeout_seconds": timeout_seconds,
"files": doc.get("files") if isinstance(doc.get("files"), list) else [],
"run_mode": run_mode, "run_mode": run_mode,
"admin_user": admin_user, "admin_user": admin_user,
"admin_pass": admin_pass, "admin_pass": admin_pass,
@@ -2709,12 +2926,10 @@ def ansible_quick_run():
if not os.path.isfile(abs_path): if not os.path.isfile(abs_path):
_ansible_log_server(f"[quick_run] playbook not found path={abs_path}") _ansible_log_server(f"[quick_run] playbook not found path={abs_path}")
return jsonify({"error": "Playbook not found"}), 404 return jsonify({"error": "Playbook not found"}), 404
try: doc = _load_assembly_document(abs_path, 'ansible')
with open(abs_path, 'r', encoding='utf-8', errors='replace') as fh: content = doc.get('script') or ''
content = fh.read() variables = doc.get('variables') if isinstance(doc.get('variables'), list) else []
except Exception as e: files = doc.get('files') if isinstance(doc.get('files'), list) else []
_ansible_log_server(f"[quick_run] read error: {e}")
return jsonify({"error": f"Failed to read playbook: {e}"}), 500
results = [] results = []
for host in hostnames: for host in hostnames:
@@ -2757,6 +2972,8 @@ def ansible_quick_run():
"playbook_name": os.path.basename(abs_path), "playbook_name": os.path.basename(abs_path),
"playbook_content": content, "playbook_content": content,
"connection": "winrm", "connection": "winrm",
"variables": variables,
"files": files,
"activity_job_id": job_id, "activity_job_id": job_id,
} }
try: try: