Merge pull request #45 from bunny-lab-io:codex/update-script_editor-to-assembly_editor

feat: add JSON assembly editor
This commit is contained in:
2025-10-03 05:31:03 -06:00
committed by GitHub
13 changed files with 1743 additions and 364 deletions

View File

@@ -0,0 +1,15 @@
{
"version": 1,
"name": "Write Canary to C Drive Root",
"description": "Write a basic canary file to the C:\\ drive to determine if SYSTEM-level access is functional within the script engine.",
"category": "script",
"type": "ansible",
"script": "---\n- name: Create Canary.txt on local Windows machine\n hosts: localhost\n connection: local\n gather_facts: no\n\n tasks:\n - name: Write Canary.txt to C:\\\n ansible.windows.win_copy:\n content: \"This is a canary file created by Ansible.\"\n dest: C:\\Canary.txt\n",
"timeout_seconds": 3600,
"sites": {
"mode": "all",
"values": []
},
"variables": [],
"files": []
}

View File

@@ -1,11 +0,0 @@
---
- name: Create Canary.txt on local Windows machine
hosts: localhost
connection: local
gather_facts: no
tasks:
- name: Write Canary.txt to C:\
ansible.windows.win_copy:
content: "This is a canary file created by Ansible."
dest: C:\Canary.txt

View File

@@ -0,0 +1,34 @@
{
"version": 1,
"name": "Hello World",
"description": "Outputs a dynamic hello world message based on the $env:Message variable.",
"category": "script",
"type": "powershell",
"script": "Write-Host $env:Message\n\n\n\n\n",
"script_lines": [
"Write-Host $env:Message",
"",
"",
"",
"",
""
],
"timeout_seconds": 3600,
"sites": {
"mode": "specific",
"values": [
"1"
]
},
"variables": [
{
"name": "Message",
"label": "Variable Example",
"type": "string",
"default": "Hello World",
"required": false,
"description": "Message to Output"
}
],
"files": []
}

View File

@@ -1,8 +1,10 @@
import os
import sys
import re
import asyncio
import tempfile
import uuid
from typing import Dict, List
from PyQt5 import QtWidgets, QtGui
@@ -13,12 +15,62 @@ ROLE_CONTEXTS = ['interactive']
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")
os.makedirs(temp_dir, exist_ok=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:
fh.write(content or "")
fh.write(final_content)
return path
@@ -45,7 +97,7 @@ async def _run_powershell_local(path: str):
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:
return -999, '', 'Windows only'
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'))
os.makedirs(temp_dir, exist_ok=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:
f.write(content or '')
f.write(final_content)
out_path = os.path.join(temp_dir, f'out_{uuid.uuid4().hex}.txt')
name = f"Borealis Agent - Task - {uuid.uuid4().hex} @ CurrentUser"
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')
# Wait a short time for output file; best-effort
import time as _t
deadline = _t.time() + 30
deadline = _t.time() + (timeout_seconds if timeout_seconds > 0 else 30)
out_data = ''
while _t.time() < deadline:
try:
@@ -139,6 +192,29 @@ class Role:
script_type = (payload.get('script_type') or '').lower()
run_mode = (payload.get('run_mode') or 'current_user').lower()
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':
return
if script_type != 'powershell':
@@ -147,10 +223,17 @@ class Role:
if run_mode == 'admin':
rc, out, err = -1, '', 'Admin credentialed runs are disabled; use SYSTEM or Current User.'
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:
path = _write_temp_script(content, '.ps1')
rc, out, err = await _run_powershell_local(path)
path = _write_temp_script(content, '.ps1', env_map, timeout_seconds)
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'
await sio.emit('quick_job_result', {
'job_id': job_id,

View File

@@ -1,9 +1,11 @@
import os
import re
import asyncio
import tempfile
import uuid
import time
import subprocess
from typing import Dict, List
ROLE_NAME = 'script_exec_system'
@@ -14,23 +16,74 @@ def _project_root():
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")
os.makedirs(temp_dir, exist_ok=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:
fh.write(content or "")
fh.write(final_content)
ps = os.path.expandvars(r"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
if not os.path.isfile(ps):
ps = "powershell.exe"
try:
flags = 0x08000000 if os.name == 'nt' else 0
proc_timeout = timeout_seconds + 30 if timeout_seconds else 60 * 60
proc = subprocess.run(
[ps, "-ExecutionPolicy", "Bypass", "-NoProfile", "-File", path],
capture_output=True,
text=True,
timeout=60*60,
timeout=proc_timeout,
creationflags=flags,
)
return proc.returncode, proc.stdout or "", proc.stderr or ""
@@ -44,15 +97,16 @@ def _run_powershell_script_content(content: str):
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")
if not os.path.isfile(ps_exe):
ps_exe = 'powershell.exe'
try:
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)
final_content = _build_wrapped_script(content or '', env_map, timeout_seconds)
with os.fdopen(script_fd, 'w', encoding='utf-8', newline='\n') as f:
f.write(content or '')
f.write(final_content)
try:
log_dir = os.path.join(_project_root(), 'Logs', 'Agent')
os.makedirs(log_dir, exist_ok=True)
@@ -131,6 +185,29 @@ class Role:
job_id = payload.get('job_id')
script_type = (payload.get('script_type') or '').lower()
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':
await sio.emit('quick_job_result', {
'job_id': job_id,
@@ -139,9 +216,9 @@ class Role:
'stderr': f"Unsupported type: {script_type}"
})
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:
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'
await sio.emit('quick_job_result', {
'job_id': job_id,

View File

@@ -36,7 +36,7 @@ import SiteList from "./Sites/Site_List";
import DeviceList from "./Devices/Device_List";
import DeviceDetails from "./Devices/Device_Details";
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 CreateJob from "./Scheduling/Create_Job.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 [editingJob, setEditingJob] = useState(null);
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);
// Top-bar search state
@@ -631,8 +631,14 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
setActiveTabId(newId);
setCurrentPage("workflow-editor");
}}
onOpenScript={(rel, mode) => {
setScriptToEdit({ path: rel, mode });
onOpenScript={(rel, mode, context) => {
const nonce = Date.now();
setAssemblyEditorState({
path: rel || '',
mode,
context: context ? { ...context, nonce } : null,
nonce
});
setCurrentPage(mode === 'ansible' ? 'ansible_editor' : 'scripts');
}}
/>
@@ -660,8 +666,14 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
setActiveTabId(newId);
setCurrentPage("workflow-editor");
}}
onOpenScript={(rel, mode) => {
setScriptToEdit({ path: rel, mode });
onOpenScript={(rel, mode, context) => {
const nonce = Date.now();
setAssemblyEditorState({
path: rel || '',
mode,
context: context ? { ...context, nonce } : null,
nonce
});
setCurrentPage(mode === 'ansible' ? 'ansible_editor' : 'scripts');
}}
/>
@@ -669,20 +681,26 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
case "scripts":
return (
<ScriptEditor
<AssemblyEditor
mode="scripts"
initialPath={scriptToEdit?.mode === 'scripts' ? (scriptToEdit?.path || '') : ''}
onConsumedInitialPath={() => setScriptToEdit(null)}
initialPath={assemblyEditorState?.mode === 'scripts' ? (assemblyEditorState?.path || '') : ''}
initialContext={assemblyEditorState?.mode === 'scripts' ? assemblyEditorState?.context : null}
onConsumeInitialData={() =>
setAssemblyEditorState((prev) => (prev && prev.mode === 'scripts' ? null : prev))
}
onSaved={() => setCurrentPage('assemblies')}
/>
);
case "ansible_editor":
return (
<ScriptEditor
<AssemblyEditor
mode="ansible"
initialPath={scriptToEdit?.mode === 'ansible' ? (scriptToEdit?.path || '') : ''}
onConsumedInitialPath={() => setScriptToEdit(null)}
initialPath={assemblyEditorState?.mode === 'ansible' ? (assemblyEditorState?.path || '') : ''}
initialContext={assemblyEditorState?.mode === 'ansible' ? assemblyEditorState?.context : null}
onConsumeInitialData={() =>
setAssemblyEditorState((prev) => (prev && prev.mode === 'ansible' ? null : prev))
}
onSaved={() => setCurrentPage('assemblies')}
/>
);

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
import os
import time
import json
import os
import re
import sqlite3
from typing import Any, Dict, List, Optional, Tuple, Callable
@@ -150,17 +152,128 @@ class JobScheduler:
return False
def _detect_script_type(self, filename: str) -> str:
fn = (filename or "").lower()
if fn.endswith(".yml"):
return "ansible"
if fn.endswith(".ps1"):
fn_lower = (filename or "").lower()
if fn_lower.endswith(".json") and os.path.isfile(filename):
try:
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"
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"
if fn.endswith(".sh"):
if fn_lower.endswith(".sh"):
return "bash"
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": "",
"script_lines": [],
"variables": [],
"files": [],
"timeout_seconds": 3600,
}
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")
script_lines = data.get("script_lines")
if isinstance(script_lines, list):
try:
doc["script"] = "\n".join(str(line) for line in script_lines)
except Exception:
doc["script"] = ""
elif isinstance(script_val, str):
doc["script"] = script_val
else:
content_val = data.get("content")
if isinstance(content_val, str):
doc["script"] = content_val
normalized_script = (doc["script"] or "").replace("\r\n", "\n")
doc["script"] = normalized_script
doc["script_lines"] = normalized_script.split("\n") if normalized_script else []
try:
timeout_raw = data.get("timeout_seconds", data.get("timeout"))
if timeout_raw is None:
doc["timeout_seconds"] = 3600
else:
doc["timeout_seconds"] = max(0, int(timeout_raw))
except Exception:
doc["timeout_seconds"] = 3600
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:
content = fh.read()
except Exception:
content = ""
normalized_script = (content or "").replace("\r\n", "\n")
doc["script"] = normalized_script
doc["script_lines"] = normalized_script.split("\n") if normalized_script else []
return doc
def _ansible_root(self) -> str:
import os
return os.path.abspath(
@@ -175,11 +288,10 @@ class JobScheduler:
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)):
return
try:
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
content = fh.read()
except Exception:
return
doc = self._load_assembly_document(abs_path, "ansible")
content = doc.get("script") or ""
variables = doc.get("variables") or []
files = doc.get("files") or []
# Record in activity_history for UI parity
now = _now_ts()
@@ -217,6 +329,8 @@ class JobScheduler:
"scheduled_job_id": int(scheduled_job_id),
"scheduled_run_id": int(scheduled_run_id),
"connection": "winrm",
"variables": variables,
"files": files,
}
try:
self.socketio.emit("ansible_playbook_run", payload)
@@ -236,15 +350,33 @@ class JobScheduler:
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)):
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
if stype != "powershell":
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:
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
content = fh.read()
timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0))
except Exception:
return
timeout_seconds = 0
# Insert into activity_history for device for parity with Quick Job
import sqlite3
@@ -281,6 +413,10 @@ class JobScheduler:
"script_name": os.path.basename(abs_path),
"script_path": path_norm,
"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(),
"admin_user": "",
"admin_pass": "",

View File

@@ -5,6 +5,7 @@ import eventlet
eventlet.monkey_patch()
import requests
import re
import base64
from flask import Flask, request, jsonify, Response, send_from_directory, make_response, session
from flask_socketio import SocketIO, emit, join_room
@@ -16,7 +17,7 @@ import time
import os # To Read Production ReactJS Server Folder
import json # For reading workflow JSON files
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 io
from datetime import datetime, timezone
@@ -650,16 +651,157 @@ def _default_ext_for_island(island: str, item_type: str = "") -> str:
if isl in ("workflows", "workflow"):
return ".json"
if isl in ("ansible", "ansible_playbooks", "ansible-playbooks", "playbooks"):
return ".yml"
# scripts: use hint or default to .ps1
return ".json"
if isl in ("scripts", "script"):
return ".json"
t = (item_type or "").lower().strip()
if t == "bash":
return ".sh"
return ".json"
if t == "batch":
return ".bat"
return ".json"
if t == "powershell":
return ".ps1"
return ".ps1"
return ".json"
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": "",
"script_lines": [],
"timeout_seconds": 3600,
"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")
script_lines = obj.get("script_lines")
if isinstance(script_lines, list):
try:
doc["script"] = "\n".join(str(line) for line in script_lines)
except Exception:
doc["script"] = ""
elif isinstance(script_val, str):
doc["script"] = script_val
else:
content_val = obj.get("content")
if isinstance(content_val, str):
doc["script"] = content_val
normalized_script = (doc["script"] or "").replace("\r\n", "\n")
doc["script"] = normalized_script
doc["script_lines"] = normalized_script.split("\n") if normalized_script else []
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
normalized_script = (content or "").replace("\r\n", "\n")
doc["script"] = normalized_script
doc["script_lines"] = normalized_script.split("\n") if normalized_script else []
if default_type == "ansible":
doc["category"] = "application"
return doc
@app.route("/api/assembly/create", methods=["POST"])
@@ -682,7 +824,7 @@ def assembly_create():
if not ext:
abs_path = base + _default_ext_for_island(island, item_type)
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"):
obj = content
if isinstance(obj, str):
@@ -699,8 +841,22 @@ def assembly_create():
with open(abs_path, "w", encoding="utf-8") as fh:
json.dump(obj, fh, indent=2)
else:
with open(abs_path, "w", encoding="utf-8", newline="\n") as fh:
fh.write(str(content or ""))
obj = content
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, "/")
return jsonify({"status": "ok", "rel_path": rel_new})
else:
@@ -721,18 +877,42 @@ def assembly_edit():
root, abs_path, _ = _resolve_assembly_path(island, path)
if not os.path.isfile(abs_path):
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"):
obj = content
if isinstance(obj, str):
obj = json.loads(obj)
if not isinstance(obj, dict):
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)
else:
with open(abs_path, "w", encoding="utf-8", newline="\n") as fh:
fh.write(str(content or ""))
return jsonify({"status": "ok"})
obj = content
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(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:
return jsonify({"error": str(ve)}), 400
except Exception as e:
@@ -885,7 +1065,7 @@ def assembly_list():
"last_edited_epoch": mtime
})
elif isl in ("scripts", "script"):
exts = (".ps1", ".bat", ".sh")
exts = (".json", ".ps1", ".bat", ".sh")
for r, dirs, files in os.walk(root):
rel_root = os.path.relpath(r, root)
if rel_root != ".":
@@ -899,15 +1079,20 @@ def assembly_list():
mtime = os.path.getmtime(fp)
except Exception:
mtime = 0.0
stype = _detect_script_type(fp)
doc = _load_assembly_document(fp, "scripts", stype)
items.append({
"file_name": fname,
"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_epoch": mtime
})
else: # ansible
exts = (".yml",)
exts = (".json", ".yml")
for r, dirs, files in os.walk(root):
rel_root = os.path.relpath(r, root)
if rel_root != ".":
@@ -921,10 +1106,15 @@ def assembly_list():
mtime = os.path.getmtime(fp)
except Exception:
mtime = 0.0
stype = _detect_script_type(fp)
doc = _load_assembly_document(fp, "ansible", stype)
items.append({
"file_name": fname,
"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_epoch": mtime
})
@@ -951,14 +1141,16 @@ def assembly_load():
obj = _safe_read_json(abs_path)
return jsonify(obj)
else:
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
content = fh.read()
return jsonify({
doc = _load_assembly_document(abs_path, island)
rel = os.path.relpath(abs_path, root).replace(os.sep, "/")
result = {
"file_name": os.path.basename(abs_path),
"rel_path": os.path.relpath(abs_path, root).replace(os.sep, "/"),
"type": ("ansible" if isl.startswith("ansible") else _detect_script_type(abs_path)),
"content": content
})
"rel_path": rel,
"type": doc.get("type"),
"assembly": doc,
"content": doc.get("script")
}
return jsonify(result)
except ValueError as ve:
return jsonify({"error": str(ve)}), 400
except Exception as e:
@@ -991,29 +1183,33 @@ def _is_valid_scripts_relpath(rel_path: str) -> bool:
def _detect_script_type(filename: str) -> str:
fn = (filename or "").lower()
if fn.endswith(".yml"):
return "ansible"
if fn.endswith(".ps1"):
fn_lower = (filename or "").lower()
if fn_lower.endswith(".json") and os.path.isfile(filename):
try:
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"
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"
if fn.endswith(".sh"):
if fn_lower.endswith(".sh"):
return "bash"
return "unknown"
def _ext_for_type(script_type: str) -> str:
t = (script_type or "").lower()
if t == "ansible":
return ".yml"
if t == "powershell":
return ".ps1"
if t == "batch":
return ".bat"
if t == "bash":
return ".sh"
return ""
if t in ("ansible", "powershell", "batch", "bash"):
return ".json"
return ".json"
"""
@@ -2594,14 +2790,24 @@ def set_device_description(hostname: str):
# Quick Job Execution + Activity History
# ---------------------------------------------
def _detect_script_type(fn: str) -> str:
fn = (fn or "").lower()
if fn.endswith(".yml"):
return "ansible"
if fn.endswith(".ps1"):
fn_lower = (fn or "").lower()
if fn_lower.endswith(".json") and os.path.isfile(fn):
try:
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"
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"
if fn.endswith(".sh"):
if fn_lower.endswith(".sh"):
return "bash"
return "unknown"
@@ -2634,15 +2840,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)):
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":
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:
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
content = fh.read()
except Exception as e:
return jsonify({"error": f"Failed to read script: {e}"}), 500
timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0))
except Exception:
timeout_seconds = 0
now = int(time.time())
results = []
@@ -2680,6 +2905,10 @@ def scripts_quick_run():
"script_name": _safe_filename(rel_path),
"script_path": rel_path.replace(os.sep, "/"),
"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,
"admin_user": admin_user,
"admin_pass": admin_pass,
@@ -2709,12 +2938,10 @@ def ansible_quick_run():
if not os.path.isfile(abs_path):
_ansible_log_server(f"[quick_run] playbook not found path={abs_path}")
return jsonify({"error": "Playbook not found"}), 404
try:
with open(abs_path, 'r', encoding='utf-8', errors='replace') as fh:
content = fh.read()
except Exception as e:
_ansible_log_server(f"[quick_run] read error: {e}")
return jsonify({"error": f"Failed to read playbook: {e}"}), 500
doc = _load_assembly_document(abs_path, 'ansible')
content = doc.get('script') or ''
variables = doc.get('variables') if isinstance(doc.get('variables'), list) else []
files = doc.get('files') if isinstance(doc.get('files'), list) else []
results = []
for host in hostnames:
@@ -2757,6 +2984,8 @@ def ansible_quick_run():
"playbook_name": os.path.basename(abs_path),
"playbook_content": content,
"connection": "winrm",
"variables": variables,
"files": files,
"activity_job_id": job_id,
}
try: