Scaffolding Added for Ansible Playbook Execution on Agents

This commit is contained in:
2025-09-29 03:57:55 -06:00
parent 04f92184c2
commit 91c32fb16f
9 changed files with 1274 additions and 42 deletions

View File

@@ -0,0 +1,11 @@
---
- 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,448 @@
import os
import sys
import asyncio
import tempfile
import uuid
import time
import json
import socket
import subprocess
ROLE_NAME = 'playbook_exec_system'
ROLE_CONTEXTS = ['system']
def _project_root():
try:
return os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
except Exception:
return os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
def _scripts_bin():
# Return the venv Scripts (Windows) or bin (POSIX) path
base = os.path.join(_project_root(), 'Agent', 'Scripts')
if os.path.isdir(base):
return base
# Fallback to PATH
return None
def _ansible_playbook_cmd():
exe = 'ansible-playbook.exe' if os.name == 'nt' else 'ansible-playbook'
sdir = _scripts_bin()
if sdir:
cand = os.path.join(sdir, exe)
if os.path.isfile(cand):
return cand
return exe
class Role:
def __init__(self, ctx):
self.ctx = ctx
self._runs = {} # run_id -> { proc, task, cancel }
def _server_base(self) -> str:
try:
fn = (self.ctx.hooks or {}).get('get_server_url')
if callable(fn):
return (fn() or 'http://localhost:5000').rstrip('/')
except Exception:
pass
return 'http://localhost:5000'
async def _post_recap(self, payload: dict):
try:
import aiohttp
url = self._server_base().rstrip('/') + '/api/ansible/recap/report'
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as sess:
async with sess.post(url, json=payload) as resp:
# best-effort; ignore body
await resp.read()
except Exception:
pass
async def _run_playbook_runner(self, run_id: str, playbook_content: str, playbook_name: str = '', activity_job_id=None, connection: str = 'local'):
try:
import ansible_runner # type: ignore
except Exception:
return False
tmp_dir = os.path.join(_project_root(), 'Temp')
os.makedirs(tmp_dir, exist_ok=True)
pd = tempfile.mkdtemp(prefix='ar_', dir=tmp_dir)
project = os.path.join(pd, 'project')
inventory_dir = os.path.join(pd, 'inventory')
env_dir = os.path.join(pd, 'env')
os.makedirs(project, exist_ok=True)
os.makedirs(inventory_dir, exist_ok=True)
os.makedirs(env_dir, exist_ok=True)
play_rel = 'playbook.yml'
play_abs = os.path.join(project, play_rel)
with open(play_abs, 'w', encoding='utf-8', newline='\n') as fh:
fh.write(playbook_content or '')
with open(os.path.join(inventory_dir, 'hosts'), 'w', encoding='utf-8', newline='\n') as fh:
fh.write('localhost,\n')
# Set connection via envvars
with open(os.path.join(env_dir, 'envvars'), 'w', encoding='utf-8', newline='\n') as fh:
json.dump({ 'ANSIBLE_FORCE_COLOR': '0', 'ANSIBLE_STDOUT_CALLBACK': 'default' }, fh)
hostname = socket.gethostname()
agent_id = self.ctx.agent_id
started = int(time.time())
await self._post_recap({
'run_id': run_id,
'hostname': hostname,
'agent_id': agent_id,
'playbook_path': play_abs,
'playbook_name': playbook_name or os.path.basename(play_abs),
'activity_job_id': activity_job_id,
'status': 'Running',
'started_ts': started,
})
lines = []
recap_json = None
def _on_event(ev):
nonlocal lines, recap_json
try:
if not isinstance(ev, dict):
return
# Capture minimal textual progress
tx = ev.get('stdout') or ''
if tx:
lines.append(str(tx))
if len(lines) > 5000:
lines = lines[-2500:]
# Capture final stats
if (ev.get('event') or '') == 'playbook_on_stats':
d = ev.get('event_data') or {}
# ansible-runner provides per-host stats under 'res'
recap_json = d.get('res') or d.get('stats') or d
except Exception:
pass
cancel_token = self._runs.get(run_id)
def _cancel_cb():
try:
return bool(cancel_token and cancel_token.get('cancel'))
except Exception:
return False
try:
ansible_runner.interface.run(
private_data_dir=pd,
playbook=play_rel,
inventory=os.path.join(inventory_dir, 'hosts'),
quiet=True,
event_handler=_on_event,
cancel_callback=_cancel_cb,
extravars={}
)
status = 'Cancelled' if _cancel_cb() else 'Success'
except Exception:
status = 'Failed'
# Synthesize recap text from recap_json if available
recap_text = ''
try:
if isinstance(recap_json, dict):
# Expect a single host 'localhost'
stats = recap_json.get('localhost') or recap_json
ok = int(stats.get('ok') or 0)
changed = int(stats.get('changed') or 0)
unreachable = int(stats.get('unreachable') or 0)
failed = int(stats.get('failures') or stats.get('failed') or 0)
skipped = int(stats.get('skipped') or 0)
rescued = int(stats.get('rescued') or 0)
ignored = int(stats.get('ignored') or 0)
recap_text = (
'PLAY RECAP *********************************************************************\n'
f"localhost : ok={ok} changed={changed} unreachable={unreachable} failed={failed} skipped={skipped} rescued={rescued} ignored={ignored}"
)
except Exception:
recap_text = ''
await self._post_recap({
'run_id': run_id,
'hostname': hostname,
'agent_id': agent_id,
'status': status,
'recap_text': recap_text,
'recap_json': recap_json,
'finished_ts': int(time.time()),
})
return True
async def _run_playbook(self, run_id: str, playbook_content: str, playbook_name: str = '', activity_job_id=None, connection: str = 'local'):
# Write playbook temp
tmp_dir = os.path.join(_project_root(), 'Temp')
os.makedirs(tmp_dir, exist_ok=True)
fd, path = tempfile.mkstemp(prefix='pb_', suffix='.yml', dir=tmp_dir, text=True)
with os.fdopen(fd, 'w', encoding='utf-8', newline='\n') as fh:
fh.write(playbook_content or '')
hostname = socket.gethostname()
agent_id = self.ctx.agent_id
started = int(time.time())
await self._post_recap({
'run_id': run_id,
'hostname': hostname,
'agent_id': agent_id,
'playbook_path': path,
'playbook_name': playbook_name or os.path.basename(path),
'activity_job_id': activity_job_id,
'status': 'Running',
'started_ts': started,
})
conn = (connection or 'local').strip().lower()
if conn not in ('local', 'winrm', 'psrp'):
conn = 'local'
cmd = [_ansible_playbook_cmd(), path, '-i', 'localhost,', '-c', conn]
# Ensure clean, plain output and correct interpreter for localhost
env = os.environ.copy()
env.setdefault('ANSIBLE_FORCE_COLOR', '0')
env.setdefault('ANSIBLE_NOCOLOR', '1')
env.setdefault('PYTHONIOENCODING', 'utf-8')
env.setdefault('ANSIBLE_STDOUT_CALLBACK', 'default')
# Help Ansible pick the correct python for localhost
env.setdefault('ANSIBLE_LOCALHOST_WARNING', '0')
creationflags = 0
if os.name == 'nt':
# CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW
creationflags = 0x00000200 | 0x08000000
proc = None
try:
# Prefer ansible-runner when available and enabled
try:
if os.environ.get('BOREALIS_USE_ANSIBLE_RUNNER', '0').lower() not in ('0', 'false', 'no'):
used = await self._run_playbook_runner(run_id, playbook_content, playbook_name=playbook_name, activity_job_id=activity_job_id, connection=connection)
if used:
return
except Exception:
pass
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=os.path.dirname(path),
env=env,
creationflags=creationflags,
)
except Exception as e:
await self._post_recap({
'run_id': run_id,
'hostname': hostname,
'agent_id': agent_id,
'status': 'Failed',
'recap_text': f'Failed to launch ansible-playbook: {e}',
'finished_ts': int(time.time()),
})
try:
os.remove(path)
except Exception:
pass
return
# Track run for cancellation
self._runs[run_id]['proc'] = proc
lines = []
recap_buffer = []
seen_recap = False
last_emit = 0
async def emit_update(force=False):
nonlocal last_emit
now = time.time()
if not force and (now - last_emit) < 1.0:
return
last_emit = now
txt = '\n'.join(recap_buffer[-80:]) if recap_buffer else ''
if not txt and lines:
# show tail while running
txt = '\n'.join(lines[-25:])
if txt:
await self._post_recap({
'run_id': run_id,
'hostname': hostname,
'agent_id': agent_id,
'recap_text': txt,
'status': 'Running',
})
try:
# Read combined stdout
while True:
if proc.stdout is None:
break
bs = await proc.stdout.readline()
if not bs:
break
try:
line = bs.decode('utf-8', errors='replace').rstrip('\r\n')
except Exception:
line = str(bs)
lines.append(line)
if len(lines) > 5000:
lines = lines[-2500:]
# Detect recap section
if not seen_recap and line.strip().upper().startswith('PLAY RECAP'):
seen_recap = True
recap_buffer.append(line)
elif seen_recap:
recap_buffer.append(line)
await emit_update(False)
finally:
try:
await proc.wait()
except Exception:
pass
rc = proc.returncode if proc else -1
status = 'Success' if rc == 0 else ('Cancelled' if self._runs.get(run_id, {}).get('cancel') else 'Failed')
# Final recap text
final_txt = '\n'.join(recap_buffer[-120:]) if recap_buffer else ('\n'.join(lines[-60:]) if lines else '')
await self._post_recap({
'run_id': run_id,
'hostname': hostname,
'agent_id': agent_id,
'status': status,
'recap_text': final_txt,
'finished_ts': int(time.time()),
})
# Cleanup
try:
os.remove(path)
except Exception:
pass
self._runs.pop(run_id, None)
def register_events(self):
sio = self.ctx.sio
@sio.on('ansible_playbook_run')
async def _on_ansible_playbook_run(payload):
try:
hostname = socket.gethostname()
target = (payload.get('target_hostname') or '').strip().lower()
if target and target != hostname.lower():
return
# Accept provided run_id or generate one
run_id = (payload.get('run_id') or '').strip() or uuid.uuid4().hex
content = payload.get('playbook_content') or ''
p_name = payload.get('playbook_name') or ''
act_id = payload.get('activity_job_id')
sched_job_id = payload.get('scheduled_job_id')
sched_run_id = payload.get('scheduled_run_id')
conn = (payload.get('connection') or 'local')
# Track run
self._runs[run_id] = {'cancel': False, 'proc': None}
# Include scheduled ids on first recap post
async def run_and_tag():
# First recap (Running) will include activity_job_id and scheduled ids
# by temporarily monkey patching _post_recap for initial call only
first = {'done': False}
orig = self._post_recap
async def _wrapped(payload2: dict):
if not first['done']:
if sched_job_id is not None:
payload2['scheduled_job_id'] = sched_job_id
if sched_run_id is not None:
payload2['scheduled_run_id'] = sched_run_id
first['done'] = True
await orig(payload2)
self._post_recap = _wrapped
try:
await self._run_playbook(run_id, content, playbook_name=p_name, activity_job_id=act_id, connection=conn)
finally:
self._post_recap = orig
asyncio.create_task(run_and_tag())
except Exception:
pass
@sio.on('ansible_playbook_cancel')
async def _on_ansible_playbook_cancel(payload):
try:
run_id = (payload.get('run_id') or '').strip()
if not run_id:
return
obj = self._runs.get(run_id)
if not obj:
return
obj['cancel'] = True
proc = obj.get('proc')
if proc and proc.returncode is None:
try:
if os.name == 'nt':
proc.terminate()
await asyncio.sleep(0.5)
if proc.returncode is None:
proc.kill()
else:
proc.terminate()
await asyncio.sleep(0.5)
if proc.returncode is None:
proc.kill()
except Exception:
pass
except Exception:
pass
@sio.on('quick_job_run')
async def _compat_quick_job_run(payload):
"""Compatibility: allow scheduled jobs to dispatch .yml as ansible playbooks.
Expects payload fields similar to script quick runs but with script_type='ansible'.
"""
try:
stype = (payload.get('script_type') or '').lower()
if stype != 'ansible':
return
hostname = socket.gethostname()
target = (payload.get('target_hostname') or '').strip().lower()
if target and target != hostname.lower():
return
run_id = uuid.uuid4().hex
content = payload.get('script_content') or ''
p_name = payload.get('script_name') or ''
self._runs[run_id] = {'cancel': False, 'proc': None}
asyncio.create_task(self._run_playbook(run_id, content, playbook_name=p_name, activity_job_id=payload.get('job_id'), connection='local'))
except Exception:
pass
def on_config(self, roles):
# No scheduled tasks to manage for now
return
def stop_all(self):
# Attempt to cancel any running playbooks
for rid, obj in list(self._runs.items()):
try:
obj['cancel'] = True
p = obj.get('proc')
if p and p.returncode is None:
try:
if os.name == 'nt':
p.terminate()
time.sleep(0.2)
if p.returncode is None:
p.kill()
else:
p.terminate()
except Exception:
pass
except Exception:
pass
self._runs.clear()

View File

@@ -29,3 +29,9 @@ pywin32; platform_system == "Windows"
# Ansible Libraries
ansible-core
ansible-runner
pywinrm
requests-credssp
requests-ntlm
pypsrp
psutil

View File

@@ -133,8 +133,12 @@ export default function DeviceDetails({ device, onBack }) {
}
}, [device]);
useEffect(() => { loadHistory(); }, [loadHistory]);
// No explicit live recap tab; recaps are recorded into Activity History
const clearHistory = async () => {
if (!device?.hostname) return;
try {
@@ -771,13 +775,10 @@ export default function DeviceDetails({ device, onBack }) {
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Assembly</TableCell>
<TableCell sortDirection={historyOrderBy === "script_name" ? historyOrder : false}>
<TableSortLabel
active={historyOrderBy === "script_name"}
direction={historyOrderBy === "script_name" ? historyOrder : "asc"}
onClick={() => handleHistorySort("script_name")}
>
Script Executed
<TableSortLabel active={historyOrderBy === "script_name"} direction={historyOrderBy === "script_name" ? historyOrder : "asc"} onClick={() => handleHistorySort("script_name")}>
Task
</TableSortLabel>
</TableCell>
<TableCell sortDirection={historyOrderBy === "ran_at" ? historyOrder : false}>
@@ -806,6 +807,7 @@ export default function DeviceDetails({ device, onBack }) {
<TableBody>
{sortedHistory.map((r) => (
<TableRow key={r.id}>
<TableCell>{(r.script_type || '').toLowerCase() === 'ansible' ? 'Ansible Playbook' : 'Script'}</TableCell>
<TableCell>{r.script_name}</TableCell>
<TableCell>{formatTimestamp(r.ran_at)}</TableCell>
<TableCell>
@@ -839,15 +841,15 @@ export default function DeviceDetails({ device, onBack }) {
</TableRow>
))}
{sortedHistory.length === 0 && (
<TableRow>
<TableCell colSpan={4} sx={{ color: "#888" }}>No activity yet.</TableCell>
</TableRow>
<TableRow><TableCell colSpan={5} sx={{ color: "#888" }}>No activity yet.</TableCell></TableRow>
)}
</TableBody>
</Table>
</Box>
);
const tabs = [
{ label: "Summary", content: renderSummary() },
{ label: "Installed Software", content: renderSoftware() },
@@ -959,6 +961,8 @@ export default function DeviceDetails({ device, onBack }) {
</DialogActions>
</Dialog>
{/* Recap dialog removed; recaps flow into Activity History stdout */}
<ClearDeviceActivityDialog
open={clearDialogOpen}
onCancel={() => setClearDialogOpen(false)}

View File

@@ -87,6 +87,11 @@ function buildScriptTree(scripts, folders) {
return { root: [rootNode], map };
}
// --- Ansible tree helpers (reuse scripts tree builder) ---
function buildAnsibleTree(playbooks, folders) {
return buildScriptTree(playbooks, folders);
}
// --- Workflows tree helpers (reuse approach from Workflow_List) ---
function buildWorkflowTree(workflows, folders) {
const map = {};
@@ -177,6 +182,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
const [compTab, setCompTab] = useState("scripts");
const [scriptTree, setScriptTree] = useState([]); const [scriptMap, setScriptMap] = useState({});
const [workflowTree, setWorkflowTree] = useState([]); const [workflowMap, setWorkflowMap] = useState({});
const [ansibleTree, setAnsibleTree] = useState([]); const [ansibleMap, setAnsibleMap] = useState({});
const [selectedNodeId, setSelectedNodeId] = useState("");
const [addTargetOpen, setAddTargetOpen] = useState(false);
@@ -382,10 +388,19 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
setWorkflowTree(root); setWorkflowMap(map);
} else { setWorkflowTree([]); setWorkflowMap({}); }
} catch { setWorkflowTree([]); setWorkflowMap({}); }
try {
// ansible playbooks
const aResp = await fetch("/api/assembly/list?island=ansible");
if (aResp.ok) {
const aData = await aResp.json();
const { root, map } = buildAnsibleTree(aData.items || [], aData.folders || []);
setAnsibleTree(root); setAnsibleMap(map);
} else { setAnsibleTree([]); setAnsibleMap({}); }
} catch { setAnsibleTree([]); setAnsibleMap({}); }
};
const addSelectedComponent = () => {
const map = compTab === "scripts" ? scriptMap : workflowMap;
const map = compTab === "scripts" ? scriptMap : (compTab === "ansible" ? ansibleMap : workflowMap);
const node = map[selectedNodeId];
if (!node || node.isFolder) return false;
if (compTab === "scripts" && node.script) {
@@ -396,6 +411,13 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
]);
setSelectedNodeId("");
return true;
} else if (compTab === "ansible" && node.script) {
setComponents((prev) => [
...prev,
{ type: "ansible", path: node.path, name: node.fileName || node.label, description: node.path }
]);
setSelectedNodeId("");
return true;
} else if (compTab === "workflows" && node.workflow) {
alert("Workflows within Scheduled Jobs are not supported yet");
return false;
@@ -453,7 +475,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
const tabDefs = useMemo(() => {
const base = [
{ key: "name", label: "Job Name" },
{ key: "components", label: "Scripts/Workflows" },
{ key: "components", label: "Assemblies" },
{ key: "targets", label: "Targets" },
{ key: "schedule", label: "Schedule" },
{ key: "context", label: "Execution Context" }
@@ -520,16 +542,16 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
{tab === 1 && (
<Box>
<SectionHeader
title="Components"
title="Assemblies"
action={(
<Button size="small" startIcon={<AddIcon />} onClick={openAddComponent}
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }} variant="outlined">
Add Component
Add Assembly
</Button>
)}
/>
{components.length === 0 && (
<Typography variant="body2" sx={{ color: "#888" }}>No components added yet.</Typography>
<Typography variant="body2" sx={{ color: "#888" }}>No assemblies added yet.</Typography>
)}
{components.map((c, idx) => (
<ComponentCard key={`${c.type}-${c.path}-${idx}`} comp={c}
@@ -537,7 +559,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
/>
))}
{components.length === 0 && (
<Typography variant="caption" sx={{ color: "#ff6666" }}>At least one component is required.</Typography>
<Typography variant="caption" sx={{ color: "#ff6666" }}>At least one assembly is required.</Typography>
)}
</Box>
)}
@@ -731,13 +753,17 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
<Dialog open={addCompOpen} onClose={() => setAddCompOpen(false)} fullWidth maxWidth="md"
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
>
<DialogTitle>Select a Script or Workflow</DialogTitle>
<DialogTitle>Select an Assembly</DialogTitle>
<DialogContent>
<Box sx={{ display: "flex", gap: 2, mb: 1 }}>
<Button size="small" variant={compTab === "scripts" ? "outlined" : "text"} onClick={() => setCompTab("scripts")}
sx={{ textTransform: "none", color: "#58a6ff", borderColor: "#58a6ff" }}>
Scripts
</Button>
<Button size="small" variant={compTab === "ansible" ? "outlined" : "text"} onClick={() => setCompTab("ansible")}
sx={{ textTransform: "none", color: "#58a6ff", borderColor: "#58a6ff" }}>
Ansible
</Button>
<Button size="small" variant={compTab === "workflows" ? "outlined" : "text"} onClick={() => setCompTab("workflows")}
sx={{ textTransform: "none", color: "#58a6ff", borderColor: "#58a6ff" }}>
Workflows
@@ -775,6 +801,22 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
</SimpleTreeView>
</Paper>
)}
{compTab === "ansible" && (
<Paper sx={{ p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
<SimpleTreeView onItemSelectionToggle={(_, id) => {
const n = ansibleMap[id];
if (n && !n.isFolder) setSelectedNodeId(id);
}}>
{ansibleTree.length ? (ansibleTree.map((n) => (
<TreeItem key={n.id} itemId={n.id} label={n.label}>
{n.children && n.children.length ? renderTreeNodes(n.children, ansibleMap) : null}
</TreeItem>
))) : (
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>No playbooks found.</Typography>
)}
</SimpleTreeView>
</Paper>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setAddCompOpen(false)} sx={{ color: "#58a6ff" }}>Close</Button>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState, useCallback } from "react";
import React, { useEffect, useState, useCallback } from "react";
import {
Dialog,
DialogTitle,
@@ -14,11 +14,11 @@ import {
import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material";
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
function buildTree(scripts, folders) {
function buildTree(items, folders, rootLabel = "Scripts") {
const map = {};
const rootNode = {
id: "root",
label: "Scripts",
label: rootLabel,
path: "",
isFolder: true,
children: []
@@ -42,7 +42,7 @@ function buildTree(scripts, folders) {
});
});
(scripts || []).forEach((s) => {
(items || []).forEach((s) => {
const parts = (s.rel_path || "").split("/");
let children = rootNode.children;
let parentPath = "";
@@ -80,13 +80,15 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
const [runAsCurrentUser, setRunAsCurrentUser] = useState(false);
const [mode, setMode] = useState("scripts"); // 'scripts' | 'ansible'
const loadTree = useCallback(async () => {
try {
const resp = await fetch("/api/assembly/list?island=scripts");
const island = mode === 'ansible' ? 'ansible' : 'scripts';
const resp = await fetch(`/api/assembly/list?island=${island}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const { root, map } = buildTree(data.items || [], data.folders || []);
const { root, map } = buildTree(data.items || [], data.folders || [], mode === 'ansible' ? 'Ansible Playbooks' : 'Scripts');
setTree(root);
setNodeMap(map);
} catch (err) {
@@ -94,7 +96,7 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
setTree([]);
setNodeMap({});
}
}, []);
}, [mode]);
useEffect(() => {
if (open) {
@@ -134,19 +136,29 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
const onRun = async () => {
if (!selectedPath) {
setError("Please choose a script to run.");
setError(mode === 'ansible' ? "Please choose a playbook to run." : "Please choose a script to run.");
return;
}
setRunning(true);
setError("");
try {
// quick_run expects a path relative to Assemblies root with 'Scripts/' prefix
const script_path = selectedPath.startsWith('Scripts/') ? selectedPath : `Scripts/${selectedPath}`;
const resp = await fetch("/api/scripts/quick_run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ script_path, hostnames, run_mode: runAsCurrentUser ? "current_user" : "system" })
});
let resp;
if (mode === 'ansible') {
const playbook_path = selectedPath; // relative to ansible island
resp = await fetch("/api/ansible/quick_run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ playbook_path, hostnames })
});
} else {
// quick_run expects a path relative to Assemblies root with 'Scripts/' prefix
const script_path = selectedPath.startsWith('Scripts/') ? selectedPath : `Scripts/${selectedPath}`;
resp = await fetch("/api/scripts/quick_run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ script_path, hostnames, run_mode: runAsCurrentUser ? "current_user" : "system" })
});
}
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
onClose && onClose();
@@ -163,15 +175,19 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
>
<DialogTitle>Quick Job</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Button size="small" variant={mode === 'scripts' ? 'outlined' : 'text'} onClick={() => setMode('scripts')} sx={{ textTransform: 'none', color: '#58a6ff', borderColor: '#58a6ff' }}>Scripts</Button>
<Button size="small" variant={mode === 'ansible' ? 'outlined' : 'text'} onClick={() => setMode('ansible')} sx={{ textTransform: 'none', color: '#58a6ff', borderColor: '#58a6ff' }}>Ansible</Button>
</Box>
<Typography variant="body2" sx={{ color: "#aaa", mb: 1 }}>
Select a script to run on {hostnames.length} device{hostnames.length !== 1 ? "s" : ""}.
Select a {mode === 'ansible' ? 'playbook' : 'script'} to run on {hostnames.length} device{hostnames.length !== 1 ? "s" : ""}.
</Typography>
<Box sx={{ display: "flex", gap: 2 }}>
<Paper sx={{ flex: 1, p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
<SimpleTreeView sx={{ color: "#e6edf3" }} onItemSelectionToggle={onItemSelect}>
{tree.length ? renderNodes(tree) : (
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>
No scripts found.
{mode === 'ansible' ? 'No playbooks found.' : 'No scripts found.'}
</Typography>
)}
</SimpleTreeView>
@@ -179,16 +195,20 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
<Box sx={{ width: 320 }}>
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Selection</Typography>
<Typography variant="body2" sx={{ color: selectedPath ? "#e6edf3" : "#888" }}>
{selectedPath || "No script selected"}
{selectedPath || (mode === 'ansible' ? 'No playbook selected' : 'No script selected')}
</Typography>
<Box sx={{ mt: 2 }}>
<FormControlLabel
control={<Checkbox size="small" checked={runAsCurrentUser} onChange={(e) => setRunAsCurrentUser(e.target.checked)} />}
label={<Typography variant="body2">Run as currently logged-in user</Typography>}
/>
<Typography variant="caption" sx={{ color: "#888" }}>
Unchecked = Run-As BUILTIN\SYSTEM
</Typography>
{mode !== 'ansible' && (
<>
<FormControlLabel
control={<Checkbox size="small" checked={runAsCurrentUser} onChange={(e) => setRunAsCurrentUser(e.target.checked)} />}
label={<Typography variant="body2">Run as currently logged-in user</Typography>}
/>
<Typography variant="caption" sx={{ color: "#888" }}>
Unchecked = Run-As BUILTIN\SYSTEM
</Typography>
</>
)}
</Box>
{error && (
<Typography variant="body2" sx={{ color: "#ff4f4f", mt: 1 }}>{error}</Typography>
@@ -207,3 +227,4 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
</Dialog>
);
}

View File

@@ -161,6 +161,70 @@ class JobScheduler:
return "bash"
return "unknown"
def _ansible_root(self) -> str:
import os
return os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..", "Assemblies", "Ansible_Playbooks")
)
def _dispatch_ansible(self, hostname: str, rel_path: str, scheduled_job_id: int, scheduled_run_id: int) -> None:
try:
import os, json, uuid
ans_root = self._ansible_root()
rel_norm = (rel_path or "").replace("\\", "/").lstrip("/")
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
# Record in activity_history for UI parity
now = _now_ts()
act_id = None
conn = self._conn()
cur = conn.cursor()
try:
cur.execute(
"""
INSERT INTO activity_history(hostname, script_path, script_name, script_type, ran_at, status, stdout, stderr)
VALUES(?,?,?,?,?,?,?,?)
""",
(
str(hostname),
rel_norm,
os.path.basename(abs_path),
"ansible",
now,
"Running",
"",
"",
),
)
act_id = cur.lastrowid
conn.commit()
finally:
conn.close()
payload = {
"run_id": uuid.uuid4().hex,
"target_hostname": str(hostname),
"playbook_name": os.path.basename(abs_path),
"playbook_content": content,
"activity_job_id": act_id,
"scheduled_job_id": int(scheduled_job_id),
"scheduled_run_id": int(scheduled_run_id),
"connection": "local",
}
try:
self.socketio.emit("ansible_playbook_run", payload)
except Exception:
pass
except Exception:
pass
def _dispatch_script(self, hostname: str, rel_path: str, run_mode: str) -> None:
"""Emit a quick_job_run event to agents for the given script/host.
Mirrors /api/scripts/quick_run behavior for scheduled jobs.
@@ -457,12 +521,18 @@ class JobScheduler:
except Exception:
comps = []
script_paths = []
ansible_paths = []
for c in comps:
try:
if (c or {}).get("type") == "script":
ctype = (c or {}).get("type")
if ctype == "script":
p = (c.get("path") or c.get("script_path") or "").strip()
if p:
script_paths.append(p)
elif ctype == "ansible":
p = (c.get("path") or "").strip()
if p:
ansible_paths.append(p)
except Exception:
continue
run_mode = (execution_context or "system").strip().lower()
@@ -549,6 +619,7 @@ class JobScheduler:
"INSERT INTO scheduled_job_runs (job_id, target_hostname, scheduled_ts, started_ts, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?)",
(job_id, host, occ, ts_now, "Running", ts_now, ts_now),
)
run_row_id = c2.lastrowid or 0
conn2.commit()
# Dispatch all script components for this job to the target host
for sp in script_paths:
@@ -556,6 +627,12 @@ class JobScheduler:
self._dispatch_script(host, sp, run_mode)
except Exception:
continue
# Dispatch ansible playbooks for this job to the target host
for ap in ansible_paths:
try:
self._dispatch_ansible(host, ap, job_id, run_row_id)
except Exception:
continue
except Exception:
pass
finally:

View File

@@ -1458,6 +1458,36 @@ def init_db():
"""
)
# Ansible play recap storage (one row per playbook run/session)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS ansible_play_recaps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT UNIQUE NOT NULL,
hostname TEXT,
agent_id TEXT,
playbook_path TEXT,
playbook_name TEXT,
scheduled_job_id INTEGER,
scheduled_run_id INTEGER,
activity_job_id INTEGER,
status TEXT,
recap_text TEXT,
recap_json TEXT,
started_ts INTEGER,
finished_ts INTEGER,
created_at INTEGER,
updated_at INTEGER
)
"""
)
try:
# Helpful lookups for device views and run correlation
cur.execute("CREATE INDEX IF NOT EXISTS idx_ansible_recaps_host_created ON ansible_play_recaps(hostname, created_at)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_ansible_recaps_status ON ansible_play_recaps(status)")
except Exception:
pass
conn.commit()
# Scheduled jobs table
cur.execute(
@@ -2512,6 +2542,55 @@ def scripts_quick_run():
return jsonify({"results": results})
@app.route("/api/ansible/quick_run", methods=["POST"])
def ansible_quick_run():
"""Queue an Ansible Playbook Quick Job via WebSocket to targeted agents.
Payload: { playbook_path: str, hostnames: [str] }
The playbook_path is relative to the Ansible island (e.g., "folder/play.yml").
"""
data = request.get_json(silent=True) or {}
rel_path = (data.get("playbook_path") or "").strip()
hostnames = data.get("hostnames") or []
if not rel_path or not isinstance(hostnames, list) or not hostnames:
return jsonify({"error": "Missing playbook_path or hostnames[]"}), 400
try:
root, abs_path, _ = _resolve_assembly_path('ansible', rel_path)
if not os.path.isfile(abs_path):
return jsonify({"error": "Playbook not found"}), 404
try:
with open(abs_path, 'r', encoding='utf-8', errors='replace') as fh:
content = fh.read()
except Exception as e:
return jsonify({"error": f"Failed to read playbook: {e}"}), 500
results = []
for host in hostnames:
run_id = None
try:
import uuid as _uuid
run_id = _uuid.uuid4().hex
except Exception:
run_id = str(int(time.time() * 1000))
payload = {
"run_id": run_id,
"target_hostname": str(host),
"playbook_name": os.path.basename(abs_path),
"playbook_content": content,
"connection": "local",
}
try:
socketio.emit("ansible_playbook_run", payload)
except Exception:
pass
results.append({"hostname": host, "run_id": run_id, "status": "Queued"})
return jsonify({"results": results})
except ValueError as ve:
return jsonify({"error": str(ve)}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/device/activity/<hostname>", methods=["GET", "DELETE"])
def device_activity(hostname: str):
try:
@@ -2598,6 +2677,309 @@ def handle_quick_job_result(data):
print(f"[ERROR] quick_job_result DB update failed for job {job_id}: {e}")
# ---------------------------------------------
# Ansible Runtime API (Play Recaps)
# ---------------------------------------------
def _json_dump_safe(obj) -> str:
try:
if isinstance(obj, str):
# Accept pre-serialized JSON strings as-is
json.loads(obj)
return obj
return json.dumps(obj or {})
except Exception:
return json.dumps({})
@app.route("/api/ansible/recap/report", methods=["POST"])
def api_ansible_recap_report():
"""Create or update an Ansible recap row for a running/finished playbook.
Expects JSON body with fields:
run_id: str (required) unique id for this playbook run (uuid recommended)
hostname: str (optional)
agent_id: str (optional)
playbook_path: str (optional)
playbook_name: str (optional)
scheduled_job_id: int (optional)
scheduled_run_id: int (optional)
activity_job_id: int (optional)
status: str (Running|Success|Failed|Cancelled) (optional)
recap_text: str (optional)
recap_json: object or str (optional)
started_ts: int (optional)
finished_ts: int (optional)
"""
data = request.get_json(silent=True) or {}
run_id = (data.get("run_id") or "").strip()
if not run_id:
return jsonify({"error": "run_id is required"}), 400
now = _now_ts()
hostname = (data.get("hostname") or "").strip()
agent_id = (data.get("agent_id") or "").strip()
playbook_path = (data.get("playbook_path") or "").strip()
playbook_name = (data.get("playbook_name") or "").strip() or (os.path.basename(playbook_path) if playbook_path else "")
status = (data.get("status") or "").strip()
recap_text = data.get("recap_text")
recap_json = data.get("recap_json")
# IDs to correlate with other subsystems (optional)
try:
scheduled_job_id = int(data.get("scheduled_job_id")) if data.get("scheduled_job_id") is not None else None
except Exception:
scheduled_job_id = None
try:
scheduled_run_id = int(data.get("scheduled_run_id")) if data.get("scheduled_run_id") is not None else None
except Exception:
scheduled_run_id = None
try:
activity_job_id = int(data.get("activity_job_id")) if data.get("activity_job_id") is not None else None
except Exception:
activity_job_id = None
try:
started_ts = int(data.get("started_ts")) if data.get("started_ts") is not None else None
except Exception:
started_ts = None
try:
finished_ts = int(data.get("finished_ts")) if data.get("finished_ts") is not None else None
except Exception:
finished_ts = None
recap_json_str = _json_dump_safe(recap_json) if recap_json is not None else None
try:
conn = _db_conn()
cur = conn.cursor()
# Attempt update by run_id first
cur.execute(
"SELECT id FROM ansible_play_recaps WHERE run_id = ?",
(run_id,)
)
row = cur.fetchone()
if row:
recap_id = int(row[0])
cur.execute(
"""
UPDATE ansible_play_recaps
SET hostname = COALESCE(?, hostname),
agent_id = COALESCE(?, agent_id),
playbook_path = COALESCE(?, playbook_path),
playbook_name = COALESCE(?, playbook_name),
scheduled_job_id = COALESCE(?, scheduled_job_id),
scheduled_run_id = COALESCE(?, scheduled_run_id),
activity_job_id = COALESCE(?, activity_job_id),
status = COALESCE(?, status),
recap_text = CASE WHEN ? IS NOT NULL THEN ? ELSE recap_text END,
recap_json = CASE WHEN ? IS NOT NULL THEN ? ELSE recap_json END,
started_ts = COALESCE(?, started_ts),
finished_ts = COALESCE(?, finished_ts),
updated_at = ?
WHERE run_id = ?
""",
(
hostname or None,
agent_id or None,
playbook_path or None,
playbook_name or None,
scheduled_job_id,
scheduled_run_id,
activity_job_id,
status or None,
recap_text, recap_text,
recap_json_str, recap_json_str,
started_ts,
finished_ts,
now,
run_id,
)
)
conn.commit()
else:
cur.execute(
"""
INSERT INTO ansible_play_recaps (
run_id, hostname, agent_id, playbook_path, playbook_name,
scheduled_job_id, scheduled_run_id, activity_job_id,
status, recap_text, recap_json, started_ts, finished_ts,
created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""",
(
run_id,
hostname or None,
agent_id or None,
playbook_path or None,
playbook_name or None,
scheduled_job_id,
scheduled_run_id,
activity_job_id,
status or None,
recap_text if recap_text is not None else None,
recap_json_str,
started_ts,
finished_ts,
now,
now,
)
)
recap_id = cur.lastrowid
conn.commit()
# If linked to an activity_history row, mirror status/stdout for Activity tab UX
try:
if activity_job_id:
cur.execute(
"UPDATE activity_history SET status = COALESCE(?, status), stdout = CASE WHEN ? IS NOT NULL THEN ? ELSE stdout END WHERE id = ?",
(status or None, recap_text, recap_text, activity_job_id)
)
conn.commit()
except Exception:
pass
# Return the latest row
cur.execute(
"SELECT id, run_id, hostname, agent_id, playbook_path, playbook_name, scheduled_job_id, scheduled_run_id, activity_job_id, status, recap_text, recap_json, started_ts, finished_ts, created_at, updated_at FROM ansible_play_recaps WHERE id=?",
(recap_id,)
)
row = cur.fetchone()
conn.close()
# Broadcast to connected clients for live updates
try:
payload = {
"id": row[0],
"run_id": row[1],
"hostname": row[2] or "",
"agent_id": row[3] or "",
"playbook_path": row[4] or "",
"playbook_name": row[5] or "",
"scheduled_job_id": row[6],
"scheduled_run_id": row[7],
"activity_job_id": row[8],
"status": row[9] or "",
"recap_text": row[10] or "",
"recap_json": json.loads(row[11]) if (row[11] or "").strip() else None,
"started_ts": row[12],
"finished_ts": row[13],
"created_at": row[14],
"updated_at": row[15],
}
socketio.emit("ansible_recap_update", payload)
except Exception:
pass
return jsonify({
"id": row[0],
"run_id": row[1],
"hostname": row[2] or "",
"agent_id": row[3] or "",
"playbook_path": row[4] or "",
"playbook_name": row[5] or "",
"scheduled_job_id": row[6],
"scheduled_run_id": row[7],
"activity_job_id": row[8],
"status": row[9] or "",
"recap_text": row[10] or "",
"recap_json": json.loads(row[11]) if (row[11] or "").strip() else None,
"started_ts": row[12],
"finished_ts": row[13],
"created_at": row[14],
"updated_at": row[15],
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/ansible/recaps", methods=["GET"])
def api_ansible_recaps_list():
"""List Ansible play recaps. Optional query params: hostname, limit (default 50)"""
hostname = (request.args.get("hostname") or "").strip()
try:
limit = int(request.args.get("limit") or 50)
except Exception:
limit = 50
try:
conn = _db_conn()
cur = conn.cursor()
if hostname:
cur.execute(
"""
SELECT id, run_id, hostname, playbook_name, status, created_at, updated_at, started_ts, finished_ts
FROM ansible_play_recaps
WHERE hostname = ?
ORDER BY COALESCE(updated_at, created_at) DESC, id DESC
LIMIT ?
""",
(hostname, limit)
)
else:
cur.execute(
"""
SELECT id, run_id, hostname, playbook_name, status, created_at, updated_at, started_ts, finished_ts
FROM ansible_play_recaps
ORDER BY COALESCE(updated_at, created_at) DESC, id DESC
LIMIT ?
""",
(limit,)
)
rows = cur.fetchall()
conn.close()
out = []
for r in rows:
out.append({
"id": r[0],
"run_id": r[1],
"hostname": r[2] or "",
"playbook_name": r[3] or "",
"status": r[4] or "",
"created_at": r[5],
"updated_at": r[6],
"started_ts": r[7],
"finished_ts": r[8],
})
return jsonify({"recaps": out})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/ansible/recap/<int:recap_id>", methods=["GET"])
def api_ansible_recap_get(recap_id: int):
try:
conn = _db_conn()
cur = conn.cursor()
cur.execute(
"SELECT id, run_id, hostname, agent_id, playbook_path, playbook_name, scheduled_job_id, scheduled_run_id, activity_job_id, status, recap_text, recap_json, started_ts, finished_ts, created_at, updated_at FROM ansible_play_recaps WHERE id=?",
(recap_id,)
)
row = cur.fetchone()
conn.close()
if not row:
return jsonify({"error": "Not found"}), 404
return jsonify({
"id": row[0],
"run_id": row[1],
"hostname": row[2] or "",
"agent_id": row[3] or "",
"playbook_path": row[4] or "",
"playbook_name": row[5] or "",
"scheduled_job_id": row[6],
"scheduled_run_id": row[7],
"activity_job_id": row[8],
"status": row[9] or "",
"recap_text": row[10] or "",
"recap_json": json.loads(row[11]) if (row[11] or "").strip() else None,
"started_ts": row[12],
"finished_ts": row[13],
"created_at": row[14],
"updated_at": row[15],
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@socketio.on("collector_status")
def handle_collector_status(data):
"""Collector agent reports activity and optional last_user.
@@ -2992,6 +3374,21 @@ def handle_agent_window_list(data):
# Relay the list to all interested clients
socketio.emit("agent_window_list", data)
# Relay Ansible control messages from UI to agents
@socketio.on("ansible_playbook_cancel")
def relay_ansible_cancel(data):
try:
socketio.emit("ansible_playbook_cancel", data)
except Exception:
pass
@socketio.on("ansible_playbook_run")
def relay_ansible_run(data):
try:
socketio.emit("ansible_playbook_run", data)
except Exception:
pass
# ---------------------------------------------
# Server Launch
# ---------------------------------------------

226
qj_old.txt Normal file
View File

@@ -0,0 +1,226 @@
import React, { useEffect, useMemo, useState, useCallback } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
Paper,
FormControlLabel,
Checkbox
} from "@mui/material";
import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material";
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
function buildTree(items, folders, rootLabel = "Scripts") {
const map = {};
const rootNode = {
id: "root",
label: rootLabel,
path: "",
isFolder: true,
children: []
};
map[rootNode.id] = rootNode;
(folders || []).forEach((f) => {
const parts = (f || "").split("/");
let children = rootNode.children;
let parentPath = "";
parts.forEach((part) => {
const path = parentPath ? `${parentPath}/${part}` : part;
let node = children.find((n) => n.id === path);
if (!node) {
node = { id: path, label: part, path, isFolder: true, children: [] };
children.push(node);
map[path] = node;
}
children = node.children;
parentPath = path;
});
});
(items || []).forEach((s) => {
const parts = (s.rel_path || "").split("/");
let children = rootNode.children;
let parentPath = "";
parts.forEach((part, idx) => {
const path = parentPath ? `${parentPath}/${part}` : part;
const isFile = idx === parts.length - 1;
let node = children.find((n) => n.id === path);
if (!node) {
node = {
id: path,
label: isFile ? s.file_name : part,
path,
isFolder: !isFile,
fileName: s.file_name,
script: isFile ? s : null,
children: []
};
children.push(node);
map[path] = node;
}
if (!isFile) {
children = node.children;
parentPath = path;
}
});
});
return { root: [rootNode], map };
}
export default function QuickJob({ open, onClose, hostnames = [] }) {
const [tree, setTree] = useState([]);
const [nodeMap, setNodeMap] = useState({});
const [selectedPath, setSelectedPath] = useState("");
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
const [runAsCurrentUser, setRunAsCurrentUser] = useState(false);
const [mode, setMode] = useState("scripts"); // 'scripts' | 'ansible'
const loadTree = useCallback(async () => {
try {
const island = mode === 'ansible' ? 'ansible' : 'scripts';
const resp = await fetch(`/api/assembly/list?island=${island}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const { root, map } = buildTree(data.items || [], data.folders || [], mode === 'ansible' ? 'Ansible Playbooks' : 'Scripts');
setTree(root);
setNodeMap(map);
} catch (err) {
console.error("Failed to load scripts:", err);
setTree([]);
setNodeMap({});
}
}, [mode]);
useEffect(() => {
if (open) {
setSelectedPath("");
setError("");
loadTree();
}
}, [open, loadTree]);
const renderNodes = (nodes = []) =>
nodes.map((n) => (
<TreeItem
key={n.id}
itemId={n.id}
label={
<Box sx={{ display: "flex", alignItems: "center" }}>
{n.isFolder ? (
<FolderIcon fontSize="small" sx={{ mr: 1, color: "#ccc" }} />
) : (
<DescriptionIcon fontSize="small" sx={{ mr: 1, color: "#ccc" }} />
)}
<Typography variant="body2" sx={{ color: "#e6edf3" }}>{n.label}</Typography>
</Box>
}
>
{n.children && n.children.length ? renderNodes(n.children) : null}
</TreeItem>
));
const onItemSelect = (_e, itemId) => {
const node = nodeMap[itemId];
if (node && !node.isFolder) {
setSelectedPath(node.path);
setError("");
}
};
const onRun = async () => {
if (!selectedPath) {
setError(mode === 'ansible' ? "Please choose a playbook to run." : "Please choose a script to run.");
return;
}
setRunning(true);
setError("");
try {
let resp;
if (mode === 'ansible') {
const playbook_path = selectedPath; // relative to ansible island
resp = await fetch("/api/ansible/quick_run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ playbook_path, hostnames })
});
} else {
// quick_run expects a path relative to Assemblies root with 'Scripts/' prefix
const script_path = selectedPath.startsWith('Scripts/') ? selectedPath : `Scripts/${selectedPath}`;
resp = await fetch("/api/scripts/quick_run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ script_path, hostnames, run_mode: runAsCurrentUser ? "current_user" : "system" })
});
}
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
onClose && onClose();
} catch (err) {
setError(String(err.message || err));
} finally {
setRunning(false);
}
};
return (
<Dialog open={open} onClose={running ? undefined : onClose} fullWidth maxWidth="md"
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
>
<DialogTitle>Quick Job</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Button size="small" variant={mode === 'scripts' ? 'outlined' : 'text'} onClick={() => setMode('scripts')} sx={{ textTransform: 'none', color: '#58a6ff', borderColor: '#58a6ff' }}>Scripts</Button>
<Button size="small" variant={mode === 'ansible' ? 'outlined' : 'text'} onClick={() => setMode('ansible')} sx={{ textTransform: 'none', color: '#58a6ff', borderColor: '#58a6ff' }}>Ansible</Button>
</Box>
<Typography variant="body2" sx={{ color: "#aaa", mb: 1 }}>
Select a {mode === 'ansible' ? 'playbook' : 'script'} to run on {hostnames.length} device{hostnames.length !== 1 ? "s" : ""}.
</Typography>
<Box sx={{ display: "flex", gap: 2 }}>
<Paper sx={{ flex: 1, p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
<SimpleTreeView sx={{ color: "#e6edf3" }} onItemSelectionToggle={onItemSelect}>
{tree.length ? renderNodes(tree) : (
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>
{mode === 'ansible' ? 'No playbooks found.' : 'No scripts found.'}
</Typography>
)}
</SimpleTreeView>
</Paper>
<Box sx={{ width: 320 }}>
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Selection</Typography>
<Typography variant="body2" sx={{ color: selectedPath ? "#e6edf3" : "#888" }}>
{selectedPath || (mode === 'ansible' ? 'No playbook selected' : 'No script selected')}
</Typography>
<Box sx={{ mt: 2 }}>
<FormControlLabel
control={<Checkbox size="small" checked={runAsCurrentUser} onChange={(e) => setRunAsCurrentUser(e.target.checked)} />}
label={<Typography variant="body2">Run as currently logged-in user</Typography>}
/>
<Typography variant="caption" sx={{ color: "#888" }}>
Unchecked = Run-As BUILTIN\SYSTEM
</Typography>
</Box>
{error && (
<Typography variant="body2" sx={{ color: "#ff4f4f", mt: 1 }}>{error}</Typography>
)}
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={running} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onRun} disabled={running || !selectedPath}
sx={{ color: running || !selectedPath ? "#666" : "#58a6ff" }}
>
Run
</Button>
</DialogActions>
</Dialog>
);
}