diff --git a/src/ansible-webui/aw/api.py b/src/ansible-webui/aw/api.py index 96b79b6..1cb537b 100644 --- a/src/ansible-webui/aw/api.py +++ b/src/ansible-webui/aw/api.py @@ -2,7 +2,8 @@ from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView from aw.api_endpoints.key import APIKey, APIKeyItem -from aw.api_endpoints.job import APIJob, APIJobItem, APIJobExecutionItem, APIJobExecutionLogs, APIJobExecutionLogFile +from aw.api_endpoints.job import APIJob, APIJobItem, APIJobExecutionItem, APIJobExecutionLogs, \ + APIJobExecutionLogFile, APIJobExecution from aw.api_endpoints.permission import APIPermission, APIPermissionItem from aw.api_endpoints.filesystem import APIFsBrowse @@ -13,6 +14,7 @@ path('api/job///log', APIJobExecutionLogFile.as_view()), path('api/job//', APIJobExecutionItem.as_view()), path('api/job/', APIJobItem.as_view()), + path('api/job_exec', APIJobExecution.as_view()), path('api/job', APIJob.as_view()), path('api/permission/', APIPermissionItem.as_view()), path('api/permission', APIPermission.as_view()), diff --git a/src/ansible-webui/aw/api_endpoints/job.py b/src/ansible-webui/aw/api_endpoints/job.py index 892d7b6..7bff078 100644 --- a/src/ansible-webui/aw/api_endpoints/job.py +++ b/src/ansible-webui/aw/api_endpoints/job.py @@ -1,21 +1,23 @@ from django.core.exceptions import ObjectDoesNotExist from django.db.utils import IntegrityError +from django.shortcuts import HttpResponse 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 drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiParameter +from aw.config.hardcoded import JOB_EXECUTION_LIMIT from aw.model.job import Job, JobExecution, BaseJobCredentials, \ - CHOICE_JOB_PERMISSION_READ, CHOICE_JOB_PERMISSION_WRITE, CHOICE_JOB_PERMISSION_EXECUTE, CHOICE_JOB_PERMISSION_FULL + CHOICE_JOB_PERMISSION_READ, CHOICE_JOB_PERMISSION_WRITE, CHOICE_JOB_PERMISSION_EXECUTE, \ + CHOICE_JOB_PERMISSION_FULL from aw.api_endpoints.base import API_PERMISSION, get_api_user, BaseResponse, GenericResponse -from aw.api_endpoints.job_util import get_viewable_jobs_serialized, JobReadResponse +from aw.api_endpoints.job_util import get_viewable_jobs_serialized, JobReadResponse, get_job_executions_serialized, \ + JobExecutionReadResponse, get_viewable_jobs, get_job_execution_serialized from aw.utils.permission import has_job_permission from aw.execute.queue import queue_add from aw.execute.util import update_execution_status, is_execution_status from aw.utils.util import is_null -LIMIT_JOB_RESULTS = 10 - class JobWriteRequest(serializers.ModelSerializer): class Meta: @@ -46,6 +48,27 @@ def _find_job_and_execution(job_id: int, exec_id: int) -> tuple[Job, (JobExecuti return job, None +def _job_execution_count(request) -> (int, None): + max_count = None + if 'execution_count' in request.GET: + max_count = int(request.GET['execution_count']) + max_count = min(max_count, 1000) + + return max_count + + +def _want_job_executions(request) -> tuple: + max_count = None + if 'executions' in request.GET and request.GET['executions'] == 'true': + try: + return True, _job_execution_count(request) + + except TypeError: + pass + + return False, max_count + + class APIJob(APIView): http_method_names = ['post', 'get'] serializer_class = JobReadResponse @@ -58,10 +81,32 @@ class APIJob(APIView): 200: OpenApiResponse(JobReadResponse, description='Return list of jobs'), }, summary='Return list of all jobs the current user is privileged to view.', - operation_id='job_list' + operation_id='job_list', + parameters=[ + OpenApiParameter( + name='executions', type=bool, default=False, description='Return list of job-executions', + required=False, + ), + OpenApiParameter( + name='execution_count', type=int, default=JOB_EXECUTION_LIMIT, + description='Maximum count of job-executions to return', + required=False, + ), + ], ) def get(request): - return Response(data=get_viewable_jobs_serialized(get_api_user(request)), status=200) + want_exec, exec_count = _want_job_executions(request) + if want_exec: + data = get_viewable_jobs_serialized( + user=get_api_user(request), + executions=True, + execution_count=exec_count, + ) + + else: + data = get_viewable_jobs_serialized(get_api_user(request)) + + return Response(data=data, status=200) @extend_schema( request=JobWriteRequest, @@ -112,11 +157,23 @@ class APIJobItem(APIView): request=None, responses={ 200: OpenApiResponse(JobReadResponse, description='Return job information'), + 400: OpenApiResponse(GenericResponse, description='Bad parameters provided'), 403: OpenApiResponse(GenericResponse, description='Not privileged to view the job'), 404: OpenApiResponse(JobReadResponse, description='Job does not exist'), }, summary='Return information about a job.', - operation_id='job_view' + operation_id='job_view', + parameters=[ + OpenApiParameter( + name='executions', type=bool, default=False, description='Return list of job-executions', + required=False, + ), + OpenApiParameter( + name='execution_count', type=int, default=JOB_EXECUTION_LIMIT, + description='Maximum count of job-executions to return', + required=False, + ), + ], ) def get(self, request, job_id: int): self.serializer_class = JobReadResponse @@ -128,7 +185,13 @@ def get(self, request, job_id: int): if not has_job_permission(user=user, job=job, permission_needed=CHOICE_JOB_PERMISSION_READ): return Response(data={'msg': f"Job '{job.name}' is not viewable"}, status=403) - return Response(data=JobReadResponse(instance=job).data, status=200) + data = JobReadResponse(instance=job).data + + want_exec, exec_count = _want_job_executions(request) + if want_exec: + data['executions'] = get_job_executions_serialized(job=job, execution_count=exec_count) + + return Response(data=data, status=200) @extend_schema( request=None, @@ -286,9 +349,9 @@ class APIJobExecutionLogs(APIView): @extend_schema( request=None, responses={ - 200: OpenApiResponse(JobReadResponse, description='Return job logs'), - 403: OpenApiResponse(JobReadResponse, description='Not privileged to view the job logs'), - 404: OpenApiResponse(JobReadResponse, description='Job, execution or logs do not exist'), + 200: OpenApiResponse(JobExecutionLogReadResponse, description='Return job logs'), + 403: OpenApiResponse(JobExecutionLogReadResponse, description='Not privileged to view the job logs'), + 404: OpenApiResponse(JobExecutionLogReadResponse, description='Job, execution or log-file do not exist'), }, summary='Get logs of a job execution.', operation_id='job_exec_logs' @@ -309,10 +372,13 @@ def get(self, request, job_id: int, exec_id: int, line_start: int = 0): lines = logfile.readlines() return Response(data={'lines': lines[line_start:]}, status=200) - except ObjectDoesNotExist: + except (ObjectDoesNotExist, FileNotFoundError): pass - return Response(data={'msg': f"Job with ID '{job_id}' or execution does not exist"}, status=404) + return Response( + data={'msg': f"Job with ID '{job_id}', execution with ID '{exec_id}' or log-file does not exist"}, + status=404, + ) class APIJobExecutionLogFile(APIView): @@ -323,12 +389,19 @@ class APIJobExecutionLogFile(APIView): @extend_schema( request=None, responses={ - 200: OpenApiResponse(JobReadResponse, description='Download job log-file'), - 403: OpenApiResponse(JobReadResponse, description='Not privileged to view the job logs'), - 404: OpenApiResponse(JobReadResponse, description='Job, execution or logs do not exist'), + 200: OpenApiResponse(GenericResponse, description='Download job log-file'), + 403: OpenApiResponse(GenericResponse, description='Not privileged to view the job logs'), + 404: OpenApiResponse(GenericResponse, description='Job, execution or log-file do not exist'), }, summary='Download log-file of a job execution.', - operation_id='job_exec_logfile' + operation_id='job_exec_logfile', + parameters=[ + OpenApiParameter( + name='type', type=str, default='stdout', + description="Type of log-file to download. Either 'stdout' or 'stderr'", + required=False, + ), + ], ) def get(self, request, job_id: int, exec_id: int): user = get_api_user(request) @@ -339,15 +412,67 @@ def get(self, request, job_id: int, exec_id: int): if not has_job_permission(user=user, job=job, permission_needed=CHOICE_JOB_PERMISSION_READ): return Response(data={'msg': f"Not privileged to view logs of the job '{job.name}'"}, status=403) - if execution.log_stdout is None: + logfile = execution.log_stdout + if 'type' in request.GET: + logfile = execution.log_stderr if request.GET['type'] == 'stderr' else logfile + + if logfile is None: return Response(data={'msg': f"No logs found for job '{job.name}'"}, status=404) - with open(execution.log_stdout, 'rb') as logfile: - response = Response(logfile.read(), content_type='text/plain', status=200) - response['Content-Disposition'] = f"inline; filename={execution.log_stdout.rsplit('/', 1)[1]}" + with open(logfile, 'rb') as _logfile: + content_b = _logfile.read() + if content_b == b'': + return Response(data={'msg': f"Job log-file is empty: '{logfile}'"}, status=404) + + response = HttpResponse(content_b, content_type='text/plain', status=200) + response['Content-Disposition'] = f"inline; filename={logfile.rsplit('/', 1)[1]}" return response - except ObjectDoesNotExist: + except (ObjectDoesNotExist, FileNotFoundError): pass - return Response(data={'msg': f"Job with ID '{job_id}' or execution does not exist"}, status=404) + return Response( + data={'msg': f"Job with ID '{job_id}', execution with ID '{exec_id}' or log-file does not exist"}, + status=404, + ) + + +class APIJobExecution(APIView): + http_method_names = ['get'] + serializer_class = JobExecutionReadResponse + permission_classes = API_PERMISSION + + @extend_schema( + request=None, + responses={ + 200: OpenApiResponse(JobExecutionReadResponse, description='Return job-execution information'), + 404: OpenApiResponse(JobExecutionReadResponse, description='No viewable jobs or executions found'), + }, + summary='Return list of job-executions the current user is privileged to view.', + operation_id='job_exec_list', + parameters=[ + OpenApiParameter( + name='execution_count', type=int, default=JOB_EXECUTION_LIMIT, + description='Maximum count of job-executions to return', + required=False, + ), + ], + ) + def get(self, request): + # pylint: disable=E1101 + jobs = get_viewable_jobs(get_api_user(request)) + if len(jobs) == 0: + return Response(data={'msg': 'No viewable jobs found'}, status=404) + + exec_count = _job_execution_count(request) + if exec_count is None: + exec_count = JOB_EXECUTION_LIMIT + + serialized = [] + for execution in JobExecution.objects.filter(job__in=jobs).order_by('updated')[:exec_count]: + serialized.append(get_job_execution_serialized(execution)) + + if len(serialized) == 0: + return Response(data={'msg': 'No viewable job-executions found'}, status=404) + + return Response(data=serialized, status=200) diff --git a/src/ansible-webui/aw/api_endpoints/job_util.py b/src/ansible-webui/aw/api_endpoints/job_util.py index 71328fe..df96ae7 100644 --- a/src/ansible-webui/aw/api_endpoints/job_util.py +++ b/src/ansible-webui/aw/api_endpoints/job_util.py @@ -1,8 +1,10 @@ from django.conf import settings from rest_framework import serializers -from aw.model.job import Job +from aw.config.hardcoded import SHORT_TIME_FORMAT, JOB_EXECUTION_LIMIT +from aw.model.job import Job, CHOICES_JOB_EXEC_STATUS, JobExecution from aw.utils.permission import get_viewable_jobs +from aw.utils.util import datetime_from_db class JobReadResponse(serializers.ModelSerializer): @@ -11,5 +13,77 @@ class Meta: fields = Job.api_fields_read -def get_viewable_jobs_serialized(user: settings.AUTH_USER_MODEL) -> list[dict]: - return [JobReadResponse(instance=job).data for job in get_viewable_jobs(user)] +class JobExecutionReadResponse(serializers.ModelSerializer): + class Meta: + model = JobExecution + fields = JobExecution.api_fields_read + + job_name = serializers.CharField(required=False) + job_comment = serializers.CharField(required=False) + user_name = serializers.CharField(required=False) + status_name = serializers.CharField(required=False) + failed = serializers.BooleanField(required=False) + error_s = serializers.CharField(required=False) + error_m = serializers.CharField(required=False) + time_start = serializers.CharField(required=False) + time_fin = serializers.CharField(required=False) + log_stdout = serializers.CharField(required=False) + log_stdout_url = serializers.CharField(required=False) + log_stderr = serializers.CharField(required=False) + log_stderr_url = serializers.CharField(required=False) + + +def get_job_execution_serialized(execution: JobExecution) -> dict: + # pylint: disable=E1101 + serialized = { + 'id': execution.id, + 'job': execution.job.id, + 'job_name': execution.job.name, + 'job_comment': execution.job.comment, + 'user': execution.user.id, + 'user_name': execution.user.username if execution.user is not None else 'Scheduled', + 'status': execution.status, + 'status_name': CHOICES_JOB_EXEC_STATUS[execution.status][1], + 'time_start': datetime_from_db(execution.created).strftime(SHORT_TIME_FORMAT), + 'time_fin': None, + 'failed': None, + 'error_s': None, + 'error_m': None, + 'log_stdout': execution.log_stdout, + 'log_stdout_url': f"/api/job/{execution.job.id}/{execution.id}/log", + 'log_stderr': execution.log_stderr, + 'log_stderr_url': f"/api/job/{execution.job.id}/{execution.id}/log?type=stderr", + } + if execution.result is not None: + serialized['time_fin'] = datetime_from_db(execution.result.time_fin).strftime(SHORT_TIME_FORMAT) + serialized['failed'] = execution.result.failed + if execution.result.error is not None: + serialized['error_s'] = execution.result.error.short + serialized['error_m'] = execution.result.error.med + + return serialized + + +def get_job_executions_serialized(job: Job, execution_count: int = JOB_EXECUTION_LIMIT) -> list[dict]: + # pylint: disable=E1101 + serialized = [] + for execution in JobExecution.objects.filter(job=job).order_by('-updated')[:execution_count]: + serialized.append(get_job_execution_serialized(execution)) + + return serialized + + +def get_viewable_jobs_serialized( + user: settings.AUTH_USER_MODEL, executions: bool = False, + execution_count: int = None +) -> list[dict]: + serialized = [] + + for job in get_viewable_jobs(user): + job_serialized = JobReadResponse(instance=job).data + if executions: + job_serialized['executions'] = get_job_executions_serialized(job=job, execution_count=execution_count) + + serialized.append(job_serialized) + + return serialized diff --git a/src/ansible-webui/aw/config/hardcoded.py b/src/ansible-webui/aw/config/hardcoded.py index 736c141..83a7f61 100644 --- a/src/ansible-webui/aw/config/hardcoded.py +++ b/src/ansible-webui/aw/config/hardcoded.py @@ -9,3 +9,4 @@ FILE_TIME_FORMAT = '%Y-%m-%d_%H-%M-%S' KEY_TIME_FORMAT = '%Y-%m-%d-%H-%M-%S' MIN_SECRET_LEN = 30 +JOB_EXECUTION_LIMIT = 20 diff --git a/src/ansible-webui/aw/model/job.py b/src/ansible-webui/aw/model/job.py index 5ad65d0..de200e8 100644 --- a/src/ansible-webui/aw/model/job.py +++ b/src/ansible-webui/aw/model/job.py @@ -162,7 +162,7 @@ class Job(BaseJob): form_fields = CHANGE_FIELDS api_fields_read = ['id'] api_fields_read.extend(CHANGE_FIELDS) - api_fields_write = api_fields_read + api_fields_write = api_fields_read.copy() api_fields_write.extend(['vault_pass', 'become_pass', 'connect_pass']) name = models.CharField(max_length=150) @@ -328,6 +328,11 @@ def __str__(self) -> str: class JobExecution(BaseJob): + api_fields_read = [ + 'id', 'job', 'job_name', 'user', 'user_name', 'result', 'status', 'status_name', 'time_start', 'time_fin', + 'failed', 'error_s', 'error_m', 'log_stdout', 'log_stdout_url', 'log_stderr', 'log_stderr_url', + ] + # NOTE: scheduled execution will have no user user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, diff --git a/src/ansible-webui/aw/static/js/aw.js b/src/ansible-webui/aw/static/js/aw.js index 2e8d408..39de227 100644 --- a/src/ansible-webui/aw/static/js/aw.js +++ b/src/ansible-webui/aw/static/js/aw.js @@ -174,33 +174,49 @@ function apiBrowseDir(inputElement, choicesElement, selector, base, searchType) }); } -function fetchApiTableData(apiEndpoint, updateFunction) { +function fetchApiTableData(apiEndpoint, updateFunction, secondRow = false) { // NOTE: data needs to be list of dict and include an 'id' attribute dataTable = document.getElementById("aw-api-data-table"); + secondRowAppendix = '_2'; $.get(apiEndpoint, function(data) { existingEntryIds = []; // for each existing entry for (i = 0, len = data.length; i < len; i++) { let entry = data[i]; - existingEntryIds.push(String(entry.id)); + entryId = String(entry.id); + entryId2 = String(entry.id) + secondRowAppendix; + existingEntryIds.push(entryId); + if (secondRow) { + existingEntryIds.push(entryId2); + } entryChanged = false; entryRow = null; + entryRow2 = null; lastData = null; + lastData2 = null; // check if the entry existed before for (i2 = 0, len2 = dataTable.rows.length; i2 < len2; i2++) { let existingRow = dataTable.rows[i2]; let existingRowId = existingRow.getAttribute("aw-api-entry"); - if (String(existingRowId) == String(entry.id)) { + if (String(existingRowId) == entryId) { entryRow = existingRow; lastData = entryRow.getAttribute("aw-api-last"); - break } + if (String(existingRowId) == entryId2) { + entryRow2 = existingRow; + } + if (entryRow != null && !secondRow) {break} + if (entryRow != null && entryRow2 != null) {break} } // if new entry - insert new row if (entryRow == null) { + if (secondRow && entryRow2 == null) { + entryRow2 = dataTable.insertRow(1); + entryRow2.setAttribute("aw-api-entry", entryId2); + } entryRow = dataTable.insertRow(1); - entryRow.setAttribute("aw-api-entry", entry.id); + entryRow.setAttribute("aw-api-entry", entryId); entryChanged = true; } // if new entry or data changed - update the row-content @@ -209,7 +225,12 @@ function fetchApiTableData(apiEndpoint, updateFunction) { console.log("Data entry changed:", entry); entryRow.innerHTML = ""; entryRow.setAttribute("aw-api-last", newData); - updateFunction(entryRow, entry); + if (secondRow) { + entryRow2.innerHTML = ""; + updateFunction(entryRow, entryRow2, entry); + } else { + updateFunction(entryRow, entry); + } } } // remove rows of deleted entries diff --git a/src/ansible-webui/aw/static/js/jobs/logs.js b/src/ansible-webui/aw/static/js/jobs/logs.js index 51c94f9..db8676e 100644 --- a/src/ansible-webui/aw/static/js/jobs/logs.js +++ b/src/ansible-webui/aw/static/js/jobs/logs.js @@ -51,10 +51,44 @@ function addLogLines($this) { }; } +function updateApiTableDataJobLogs(row, row2, entry) { + console.log(entry); + row.insertCell(0).innerHTML = entry.time_start + '
' + entry.user_name + + '
' + + entry.status_name + '
'; + row.insertCell(1).innerText = entry.job_name; + if (entry.job_comment == "") { + row.insertCell(2).innerText = "-"; + } else { + row.insertCell(2).innerText = entry.job_comment; + } + + actionsTemplate = document.getElementById("aw-api-data-tmpl-actions").innerHTML; + actionsTemplate = actionsTemplate.replaceAll('${ID}', entry.id); + actionsTemplate = actionsTemplate.replaceAll('${JOB_ID}', entry.job); + row.insertCell(3).innerHTML = actionsTemplate; + + logsTemplates = document.getElementById("aw-api-data-tmpl-logs").innerHTML; + logsTemplates = logsTemplates.replaceAll('${ID}', entry.id); + logsTemplates = logsTemplates.replaceAll('${JOB_ID}', entry.job); + logsTemplates = logsTemplates.replaceAll('${LOG_STDOUT_URL}', entry.log_stdout_url); + logsTemplates = logsTemplates.replaceAll('${LOG_STDERR_URL}', entry.log_stderr_url); + logsTemplates = logsTemplates.replaceAll('${LOG_STDERR}', entry.log_stderr); + logsTemplates = logsTemplates.replaceAll('${LOG_STDOUT}', entry.log_stdout); + row2.setAttribute("hidden", "hidden"); + row2.setAttribute("id", "aw-spoiler-" + entry.id); + row2Col = row2.insertCell(0); + row2Col.setAttribute("colspan", "100%"); + row2Col.innerHTML = logsTemplates; +} + $( document ).ready(function() { $(".aw-main").on("click", ".aw-log-read", function(){ $this = jQuery(this); addLogLines($this); setInterval('addLogLines($this)', (DATA_REFRESH_SEC * 1000)); }); + apiEndpoint = "/api/job_exec"; + fetchApiTableData(apiEndpoint, updateApiTableDataJobLogs, true); + setInterval('fetchApiTableData(apiEndpoint, updateApiTableDataJobLogs, true)', (DATA_REFRESH_SEC * 1000)); }); diff --git a/src/ansible-webui/aw/templates/jobs/logs.html b/src/ansible-webui/aw/templates/jobs/logs.html index fe77034..208dbe1 100644 --- a/src/ansible-webui/aw/templates/jobs/logs.html +++ b/src/ansible-webui/aw/templates/jobs/logs.html @@ -12,54 +12,35 @@ {% include "../button/refresh.html" %}
- +
-{% if executions|length == 0 %} - - - - - - -{% endif %} -{% for execution in executions %} - - - - - - - - - -{% endfor %} +
Run Job Comment Show Logs
----
{{ execution|execution_info_brief|get_fallback:"-"|safe }}{{ execution.job.name }}{{ execution.job.comment|get_fallback:"-" }} - -
+ + +
{% endblock %} diff --git a/src/ansible-webui/aw/views/settings.py b/src/ansible-webui/aw/views/settings.py index 4d25d40..a5848a0 100644 --- a/src/ansible-webui/aw/views/settings.py +++ b/src/ansible-webui/aw/views/settings.py @@ -7,7 +7,6 @@ from django.forms import ModelForm, MultipleChoiceField, SelectMultiple from aw.utils.http import ui_endpoint_wrapper, ui_endpoint_wrapper_kwargs -from aw.model.api import AwAPIKey from aw.model.job import Job, JobPermission from aw.config.form_metadata import FORM_LABEL, FORM_HELP @@ -15,11 +14,7 @@ @login_required @ui_endpoint_wrapper def setting_api_key(request) -> HttpResponse: - api_key_tokens = [key.name for key in AwAPIKey.objects.filter(user=request.user)] - return render( - request, status=200, template_name='settings/api_key.html', - context={'api_key_tokens': api_key_tokens} - ) + return render(request, status=200, template_name='settings/api_key.html') @login_required