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

@@ -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>
);
}