Skip to content

Commit

Permalink
added file-search auto-completion for job-form
Browse files Browse the repository at this point in the history
  • Loading branch information
ansibleguy committed Feb 9, 2024
1 parent 46ac735 commit 3523812
Show file tree
Hide file tree
Showing 15 changed files with 192 additions and 30 deletions.
2 changes: 1 addition & 1 deletion docs/source/usage/2_config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Environmental variables
If defined - the built-in static-file serving is disabled.
Use this if in production and a `proxy like nginx <https://docs.nginx.com/nginx/admin-guide/web-server/serving-static-content/>`_ is in front of the Ansible-WebUI webservice.

Path to serve: :code:`${PATH_VENV}/lib/python${PY_VERSION}/site-packages/ansible-webui/aw/static/`
Path to serve: :code:`/static/ => ${PATH_VENV}/lib/python${PY_VERSION}/site-packages/ansible-webui/aw/static/`


* **AW_DB**
Expand Down
2 changes: 1 addition & 1 deletion docs/source/usage/security.rst
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,6 @@ Setup

* serve static files using the proxy

:code:`${PATH_VENV}/lib/python${PY_VERSION}/site-packages/ansible-webui/aw/static/`
:code:`/static/ => ${PATH_VENV}/lib/python${PY_VERSION}/site-packages/ansible-webui/aw/static/`

* Make sure the Account passwords and API keys are kept/used safe
2 changes: 2 additions & 0 deletions src/ansible-webui/aw/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from aw.api_endpoints.key import APIKey, APIKeyItem
from aw.api_endpoints.job import APIJob, APIJobItem, APIJobExecutionItem, APIJobExecutionLogs
from aw.api_endpoints.permission import APIPermission, APIPermissionItem
from aw.api_endpoints.filesystem import APIFsBrowse

urlpatterns_api = [
path('api/key/<str:token>', APIKeyItem.as_view()),
Expand All @@ -14,6 +15,7 @@
path('api/job', APIJob.as_view()),
path('api/permission/<int:perm_id>', APIPermissionItem.as_view()),
path('api/permission', APIPermission.as_view()),
path('api/fs/browse/<str:selector>', APIFsBrowse.as_view()),
path('api/_schema/', SpectacularAPIView.as_view(), name='_schema'),
path('api/_docs', SpectacularSwaggerView.as_view(url_name='_schema'), name='swagger-ui'),
]
70 changes: 70 additions & 0 deletions src/ansible-webui/aw/api_endpoints/filesystem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from os import listdir
from os import path as os_path
from pathlib import Path

from rest_framework.views import APIView
from rest_framework import serializers
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema, OpenApiResponse

from aw.config.main import config
from aw.api_endpoints.base import API_PERMISSION, BaseResponse, GenericResponse


class FileSystemReadResponse(BaseResponse):
files = serializers.ListSerializer(child=serializers.CharField())
directories = serializers.ListSerializer(child=serializers.CharField())


class APIFsBrowse(APIView):
http_method_names = ['get']
serializer_class = FileSystemReadResponse
permission_classes = API_PERMISSION
BROWSE_SELECTORS = {
'playbook_file': config['path_play'],
'inventory_file': config['path_play'],
}

@classmethod
@extend_schema(
request=None,
responses={
200: FileSystemReadResponse,
400: OpenApiResponse(GenericResponse, description='Invalid browse-selector provided'),
403: OpenApiResponse(GenericResponse, description='Traversal not allowed'),
404: OpenApiResponse(GenericResponse, description='Base directory does not exist'),
},
summary='Return list of existing files and directories.',
description="This endpoint is mainly used for form auto-completion when selecting job-files. "
f"Available selectors are: {BROWSE_SELECTORS}"
)
def get(cls, request, selector: str):
if selector not in cls.BROWSE_SELECTORS:
return Response(data={'msg': 'Invalid browse-selector provided'}, status=400)

if 'base' not in request.GET:
base = '/'
else:
base = str(request.GET['base'])

if not base.startswith('/'):
base = f'/{base}'

if base.find('..') != -1:
return Response(data={'msg': 'Traversal not allowed'}, status=403)

path_check = cls.BROWSE_SELECTORS[selector] + base
if not Path(path_check).is_dir():
return Response(data={'msg': 'Base directory does not exist'}, status=404)

items = {'files': [], 'directories': []}
raw_items = listdir(path_check)

for item in raw_items:
item_path = Path(os_path.join(path_check, item))
if item_path.is_file():
items['files'].append(item)
elif item_path.is_dir():
items['directories'].append(item)

return Response(items)
21 changes: 14 additions & 7 deletions src/ansible-webui/aw/config/form_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,22 @@
'tags_skip': 'Skip Tags',
}
}
}
},
'settings': {
'permissions': {},
},
}

FORM_HELP = {
'jobs': {
'manage': {
'job': {
'playbook': f"Playbook to execute. Search path: '{config['path_play']}'",
'inventory': 'One or multiple inventory files/directories to include for the execution. '
'Comma-separated list. For details see: '
'<a href="https://docs.ansible.com/ansible/latest/inventory_guide/intro_inventory.html">'
'Ansible Docs - Inventory</a>',
'playbook_file': f"Playbook to execute. Search path: '{config['path_play']}'",
'inventory_file': 'One or multiple inventory files/directories to include for the execution. '
'Comma-separated list. For details see: '
'<a href="https://docs.ansible.com/ansible/latest/inventory_guide/'
'intro_inventory.html">'
'Ansible Docs - Inventory</a>',
'limit': 'Ansible inventory hosts or groups to limit the execution to.'
'For details see: '
'<a href="https://docs.ansible.com/ansible/latest/inventory_guide/intro_patterns.html">'
Expand All @@ -55,5 +59,8 @@
'Ansible Docs - Check Mode</a>',
}
}
}
},
'settings': {
'permissions': {},
},
}
4 changes: 2 additions & 2 deletions src/ansible-webui/aw/execute/play_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,8 @@ def runner_prep(job: Job, execution: JobExecution, path_run: Path) -> dict:
update_execution_status(execution, status='Starting')

opts = _runner_options(job=job, execution=execution, path_run=path_run)
opts['playbook'] = job.playbook
opts['inventory'] = job.inventory.split(',')
opts['playbook'] = job.playbook_file
opts['inventory'] = job.inventory_file.split(',')

# https://docs.ansible.com/ansible/2.8/user_guide/playbooks_best_practices.html#directory-layout
project_dir = opts['project_dir']
Expand Down
8 changes: 4 additions & 4 deletions src/ansible-webui/aw/model/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ def validate_cronjob(value):

class Job(BaseJob):
CHANGE_FIELDS = [
'name', 'inventory', 'playbook', 'schedule', 'enabled', 'limit', 'verbosity', 'mode_diff',
'name', 'inventory_file', 'playbook_file', 'schedule', 'enabled', 'limit', 'verbosity', 'mode_diff',
'mode_check', 'tags', 'tags_skip', 'verbosity', 'comment', 'environment_vars', 'cmd_args',
'vault_id', 'vault_file', 'connect_user', 'become_user',
]
Expand All @@ -166,15 +166,15 @@ class Job(BaseJob):
api_fields_write.extend(['vault_pass', 'become_pass', 'connect_pass'])

name = models.CharField(max_length=150)
inventory = models.CharField(max_length=300) # NOTE: one or multiple comma-separated inventories
playbook = models.CharField(max_length=100)
inventory_file = models.CharField(max_length=300) # NOTE: one or multiple comma-separated inventories
playbook_file = models.CharField(max_length=100)
schedule_max_len = 50
schedule = models.CharField(max_length=schedule_max_len, validators=[validate_cronjob], blank=True, default=None)
enabled = models.BooleanField(choices=CHOICES_BOOL, default=True)

def __str__(self) -> str:
limit = '' if self.limit is None else f' [{self.limit}]'
return f"Job '{self.name}' ({self.playbook} => {self.inventory}{limit})"
return f"Job '{self.name}' ({self.playbook_file} => {self.inventory_file}{limit})"

class Meta:
constraints = [
Expand Down
4 changes: 4 additions & 0 deletions src/ansible-webui/aw/static/css/aw.css
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,10 @@ form .form-control {
width: 20vw;
}

input:invalid {
border-color: red;
}

.mb-3 {
margin-bottom: 0.1rem !important;
}
Expand Down
74 changes: 71 additions & 3 deletions src/ansible-webui/aw/static/js/aw.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function apiActionSuccess(result) {
// todo: fix success message not showing after refresh
reloadAwData();

resultDiv = document.getElementById('aw-api-result');
resultDiv = document.getElementById('aw-api-result');
if (result.msg) {
resultDiv.innerHTML = 'Success: ' + result.msg;
} else {
Expand All @@ -96,7 +96,7 @@ function apiActionError(result, exception) {
console.log(exception);

// write full/verbose error message to hidden iframe (could be full html response) that can be toggled by user
errorFullIframe = document.getElementById('aw-api-error-full').contentWindow.document;
errorFullIframe = document.getElementById('aw-api-error-full').contentWindow.document;
errorFullIframe.open();
errorFullIframe.write('<h1><b>FULL ERROR:</b></h1><br>' + result.responseText);
errorFullIframe.close();
Expand All @@ -112,7 +112,7 @@ function apiActionError(result, exception) {
}

function apiActionErrorClear() {
errorFullIframe = document.getElementById('aw-api-error-full').contentWindow.document;
errorFullIframe = document.getElementById('aw-api-error-full').contentWindow.document;
errorFullIframe.open();
errorFullIframe.write('');
errorFullIframe.close();
Expand All @@ -128,6 +128,52 @@ function apiActionFullError() {
document.getElementById("aw-api-error-full").scrollIntoView();
}

function apiBrowseDirUpdateChoices(inputElement, choicesElement, searchType, result) {
console.log(result);
choices = result[searchType];
inputElement.attr("pattern", '(.*\\/|^)(' + choices.join('|') + ')$');
let title = "";
if (choices.length == 0) {
title += "No available " + searchType + " found."
} else if (choices.length > 0) {
title += "You might choose one of the existing " + searchType + ": '" + choices.join(', ') + "'";
}
dirs = result['directories'];
if (searchType != 'directories' && dirs.length > 0) {
title += " Available directories: '" + dirs.join(', ') + "'";
}

inputElement.attr("title", title);

choicesElement.innerHTML = ""
for (i = 0, len = choices.length; i < len; i++) {
let choice = choices[i];
choicesElement.innerHTML += "<li>" + choice + "</li>";
}
if (searchType != 'directories') {
for (i = 0, len = dirs.length; i < len; i++) {
let dir = dirs[i];
choicesElement.innerHTML += '<li><i class="fa fa-folder" aria-hidden="true"></i> ' + dir + "</li>";
}
}
}

function apiBrowseDirRemoveChoices(inputElement, choicesElement, searchType, exception) {
console.log(exception);
inputElement.attr("pattern", '^\\b$');
inputElement.attr("title", "You need to choose one of the existing " + searchType);
}

function apiBrowseDir(inputElement, choicesElement, selector, base, searchType) {
console.log("/api/fs/browse/" + selector + "?base=" + base + ' | searching for ' + searchType);
$.ajax({
url: "/api/fs/browse/" + selector + "?base=" + base,
type: "GET",
success: function (result) { apiBrowseDirUpdateChoices(inputElement, choicesElement, searchType, result); },
error: function (exception) { apiBrowseDirRemoveChoices(inputElement, choicesElement, searchType, exception); },
});
}

const csrf_token = getCookie('csrftoken');

// EVENTHANDLER
Expand Down Expand Up @@ -196,4 +242,26 @@ $( document ).ready(function() {

return false;
});
$(".aw-main").on("input", ".aw-fs-browse", function(){
let searchType = $(this).attr("aw-fs-type");
let apiSelector = $(this).attr("aw-fs-selector");
let apiChoices = $(this).attr("aw-fs-choices");

// check if highest level of user-input is in choices; go to next depth
let userInput = $(this).val();
if (typeof(userInput) == 'undefined' || userInput == null) {
userInput = "";
}

userInputLevels = userInput.split('/');
selected = userInputLevels.pop();
apiBase = userInputLevels.join('/');

apiChoicesElement = document.getElementById(apiChoices);
if (this.checkValidity() == false) {
apiBrowseDir(jQuery(this), apiChoicesElement, apiSelector, apiBase, searchType);
} else {
apiChoicesElement.innerHTML = ""
}
});
});
3 changes: 0 additions & 3 deletions src/ansible-webui/aw/templates/head.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<meta name="color-scheme" content="light dark" />
{% if request.user_agent.is_mobile %}
<meta name="viewport" content="width=640"/>
{% endif %}
<link rel="icon" type="image/svg" href="{% static 'img/ansible.svg' %}">

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/css/bootstrap.min.css" integrity="sha512-b2QcS5SsA8tZodcDtGRELiGv5SaKSk1vDHDaQRda0htPYWZ6046lr3kJ5bAAQdpV2mmA/4v0wQF9MyU6/pDIAg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
Expand Down
1 change: 0 additions & 1 deletion src/ansible-webui/aw/templates/jobs/manage.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
{% load static %}
{% load util %}
{% block content %}
{% load user_agents %}
{# todo: build table using ajax to allow dynamic search #}
<div class="table-responsive aw-data">
<table class="table table-striped aw-text-responsive">
Expand Down
4 changes: 2 additions & 2 deletions src/ansible-webui/aw/templates/jobs/manage_job.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
{% set_var executions|get_value:job.id as job_executions %}
<tr>
<td>{{ job.name }}</td>
<td class="aw-responsive-lg">{{ job.inventory }}</td>
<td class="aw-responsive-lg">{{ job.playbook }}</td>
<td class="aw-responsive-lg">{{ job.inventory_file }}</td>
<td class="aw-responsive-lg">{{ job.playbook_file }}</td>
<td class="aw-responsive-lg">{{ job.comment|get_fallback:"-" }}</td>
<td>{{ job.schedule|get_fallback:"-" }}
{% if job.schedule|exists and not job.enabled %}
Expand Down
21 changes: 15 additions & 6 deletions src/ansible-webui/aw/templatetags/form_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,19 @@ def get_form_field_select(bf: BoundField, existing: dict) -> str:

@register.filter
def get_form_field_input(bf: BoundField, existing: dict) -> str:
field_type = ''
if bf.name.find('pass') != -1:
field_type = 'type="password" '

return (f'<input class="form-control" id="{bf.id_for_label}" name="{bf.name}" {field_type}'
field_classes = 'form-control'
field_attrs = f'id="{bf.id_for_label}" name="{bf.name}"'
search_choices = ''
if bf.name.find('_pass') != -1:
field_attrs += ' type="password"'

elif bf.name.find('_file') != -1:
field_classes += ' aw-fs-browse'
field_attrs += (f' type="text" aw-fs-selector="{bf.name}" aw-fs-type="files"'
f' aw-fs-choices="aw-fs-choices-{bf.name}" pattern="^\\b$"')
search_choices = f'<ul id="aw-fs-choices-{bf.name}"></ul>'

return (f'<input class="{field_classes}" {field_attrs} '
f'{get_form_field_value(bf, existing)} {get_form_required(bf)}'
f'{get_form_field_attributes(bf)} {get_form_field_validators(bf)}>')
f'{get_form_field_attributes(bf)} {get_form_field_validators(bf)}>'
f'{search_choices}')
6 changes: 6 additions & 0 deletions src/ansible-webui/aw/templatetags/job_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from aw.model.job import JobExecution, CHOICES_JOB_EXEC_STATUS
from aw.config.hardcoded import SHORT_TIME_FORMAT
from aw.utils.util import datetime_from_db, is_null
from aw.utils.permission import get_permission_name

register = template.Library()

Expand Down Expand Up @@ -98,3 +99,8 @@ def execution_logfile_exists(execution: JobExecution, attr: str = 'log_stdout')
return False

return Path(log_attr).is_file()


@register.filter
def permission_name(perm: int) -> str:
return get_permission_name(perm)

0 comments on commit 3523812

Please sign in to comment.