From 29f0dc6c4c4bf798c3113b86cb4d9aab058e3222 Mon Sep 17 00:00:00 2001 From: Yaroslav Shepilov Date: Mon, 3 Aug 2026 17:59:21 +0200 Subject: [PATCH] Paginate execution history with cursors, indexed in the logging service Alternative to #824. History and admin logs no longer load every entry at once, but the paging happens inside ExecutionLoggingService rather than in the request handler, and it is cursor-based rather than offset-based. _renew_files_cache already parsed every log file exactly once and discarded the result, while get_history_entries re-opened and re-parsed all of them on every request. Keeping what was already computed removes that re-parse: _ids_to_file_map (id -> filename) becomes _logs_by_id (id -> filename + summary), so there is one index rather than two. The summary omits command and output_format - command is the one unbounded field in a log header, and both are only needed by the detail view, which reads that single file anyway. The index is populated in start_logging, where those values are already in hand, so executions started in the current process are visible in history immediately. Paging uses a (sort value, id) cursor, which is stable while new executions are being appended; total is still returned, since it is just a counter on the filter pass. Search and sorting moved to the server along with paging - once only one page is shipped, a client-side search box silently searches the visible page only. GET history/execution_log/short now accepts limit, after, search, sort and order, and always answers {records, total, nextCursor}. The Status column is no longer sortable: it depends on live "is running" state, which the index does not hold. --- src/execution/logging.py | 302 +++++++++++++++--- src/tests/execution_logging_test.py | 299 ++++++++++++++--- src/tests/web/server_test.py | 87 ++++- src/web/server.py | 40 ++- .../history/executions-log-table.vue | 94 +++--- .../components/history/executions-log.vue | 3 + .../history/executions-paginator.vue | 127 ++++++++ web-src/src/common/store/executions-module.js | 156 +++++++-- .../unit/history/executions-module_test.js | 257 +++++++++++++++ .../unit/history/executions-paginator_test.js | 149 +++++++++ .../history/AppHistoryPanel_test.js | 11 +- 11 files changed, 1354 insertions(+), 171 deletions(-) create mode 100644 web-src/src/common/components/history/executions-paginator.vue create mode 100644 web-src/tests/unit/history/executions-module_test.js create mode 100644 web-src/tests/unit/history/executions-paginator_test.js diff --git a/src/execution/logging.py b/src/execution/logging.py index 56f007de..cc4e94fa 100644 --- a/src/execution/logging.py +++ b/src/execution/logging.py @@ -1,9 +1,14 @@ # noinspection PyBroadException +import base64 +import binascii +import heapq +import json import logging import os import re +from datetime import datetime, timezone from string import Template -from typing import Optional +from typing import List, Optional from auth.authorization import is_same_user from execution.execution_service import ExecutionService @@ -13,15 +18,33 @@ from utils import file_utils, audit_utils from utils.audit_utils import get_audit_name from utils.collection_utils import get_first_existing -from utils.date_utils import get_current_millis, ms_to_datetime +from utils.date_utils import get_current_millis, ms_to_datetime, to_millis ENCODING = 'utf8' OUTPUT_STARTED_MARKER = '>>>>> OUTPUT STARTED <<<<<' +SORT_START_TIME = 'startTime' +SORT_ID = 'id' +SORT_USER = 'user' +SORT_SCRIPT = 'script' +SORTABLE_FIELDS = (SORT_START_TIME, SORT_ID, SORT_USER, SORT_SCRIPT) + +ORDER_ASC = 'asc' +ORDER_DESC = 'desc' +SORT_ORDERS = (ORDER_ASC, ORDER_DESC) + +MAX_PAGE_LIMIT = 500 + +_MIN_DATETIME = datetime.min.replace(tzinfo=timezone.utc) + LOGGER = logging.getLogger('script_server.execution.logging') +class InvalidCursorException(Exception): + pass + + class ScriptOutputLogger: def __init__(self, log_file_path, output_stream): self.opened = False @@ -109,6 +132,39 @@ def __init__(self): self.exit_code = None +class HistoryEntrySummary: + """Everything needed to list, sort, search and access-check an execution, without its command. + + One instance per known execution stays in memory for the lifetime of the process, so the + unbounded fields (command, output format) are left out and read from the log file on demand. + """ + + __slots__ = ('id', 'user_name', 'user_id', 'start_time', 'script_name', 'exit_code') + + def __init__(self, id, user_name, user_id, start_time, script_name, exit_code): + self.id = id + self.user_name = user_name + self.user_id = user_id + self.start_time = start_time + self.script_name = script_name + self.exit_code = exit_code + + +class HistoryPage: + def __init__(self, records, total, next_cursor): + self.records = records + self.total = total + self.next_cursor = next_cursor + + +class _IndexedLog: + __slots__ = ('filename', 'summary') + + def __init__(self, filename, summary): + self.filename = filename + self.summary = summary + + class ExecutionLoggingService: def __init__(self, output_folder, log_name_creator, authorizer): self._output_folder = output_folder @@ -116,7 +172,7 @@ def __init__(self, output_folder, log_name_creator, authorizer): self._authorizer = authorizer self._visited_files = set() - self._ids_to_file_map = {} + self._logs_by_id = {} self._output_loggers = {} file_utils.prepare_folder(output_folder) @@ -167,12 +223,18 @@ def start_logging(self, execution_id, log_filename = os.path.basename(log_file_path) self._visited_files.add(log_filename) - self._ids_to_file_map[execution_id] = log_filename + self._logs_by_id[execution_id] = _IndexedLog(log_filename, HistoryEntrySummary( + id=execution_id, + user_name=user_name, + user_id=user_id, + start_time=ms_to_datetime(start_time_millis), + script_name=script_name, + exit_code=None)) self._output_loggers[execution_id] = output_logger def write_post_execution_info(self, execution_id, exit_code): - filename = self._ids_to_file_map.get(execution_id) - if not filename: + indexed_log = self._logs_by_id.get(execution_id) + if not indexed_log: LOGGER.warning('Failed to find filename for execution ' + execution_id) return @@ -181,50 +243,117 @@ def write_post_execution_info(self, execution_id, exit_code): LOGGER.warning('Failed to find logger for execution ' + execution_id) return - log_file_path = os.path.join(self._output_folder, filename) + log_file_path = os.path.join(self._output_folder, indexed_log.filename) + + def close_callback(): + self._write_post_execution_info(log_file_path, exit_code) + indexed_log.summary.exit_code = int(exit_code) if exit_code is not None else None + + logger.set_close_callback(close_callback) - logger.set_close_callback(lambda: self._write_post_execution_info(log_file_path, exit_code)) + def get_history_entries(self, user_id, *, system_call=False) -> List[HistoryEntrySummary]: + self._renew_files_cache() + + return [log.summary for log in self._logs_by_id.values() + if self._can_access(log.summary.user_id, user_id, system_call)] + + def get_history_page(self, + user_id, + *, + system_call=False, + search=None, + sort=None, + order=None, + limit=None, + after=None) -> HistoryPage: + """Return a single page of history entries, newest first by default. + + search: case-insensitive substring, matched against the script name or the user name + sort/order: see SORTABLE_FIELDS and SORT_ORDERS + limit: page size, 1..MAX_PAGE_LIMIT; None returns every matching entry + after: cursor from a previous page's next_cursor; must have been produced for the same + sort and order, otherwise InvalidCursorException is raised + + total counts everything the user may see for this search, regardless of the cursor. + """ - def get_history_entries(self, user_id, *, system_call=False): self._renew_files_cache() - result = [] + sort = sort if sort is not None else SORT_START_TIME + order = order if order is not None else ORDER_DESC + + if sort not in SORTABLE_FIELDS: + raise ValueError('Unsupported sort field: ' + str(sort)) + if order not in SORT_ORDERS: + raise ValueError('Unsupported sort order: ' + str(order)) + if limit is not None and (limit < 1 or limit > MAX_PAGE_LIMIT): + raise ValueError('limit should be between 1 and ' + str(MAX_PAGE_LIMIT)) + + cursor_key = _decode_cursor(after, sort, order) if after else None + descending = order == ORDER_DESC + search_text = search.strip().lower() if search else None + + total = 0 + candidates = [] + for indexed_log in self._logs_by_id.values(): + summary = indexed_log.summary + + if not self._can_access(summary.user_id, user_id, system_call): + continue + + if search_text and not _matches_search(summary, search_text): + continue + + total += 1 + + sort_key = _sort_key(summary, sort) + if cursor_key is not None and not _is_after_cursor(sort_key, cursor_key, descending): + continue + + candidates.append((sort_key, summary)) + + if limit is None: + candidates.sort(key=_candidate_key, reverse=descending) + return HistoryPage([summary for _, summary in candidates], total, None) - for file in self._ids_to_file_map.values(): - history_entry = self._extract_history_entry(file) - if history_entry is not None and self._can_access_entry(history_entry, user_id, system_call): - result.append(history_entry) + if descending: + page = heapq.nlargest(limit, candidates, key=_candidate_key) + else: + page = heapq.nsmallest(limit, candidates, key=_candidate_key) - return result + has_more = len(candidates) > limit + next_cursor = _encode_cursor(page[-1][1], sort, order) if (has_more and page) else None - def find_history_entry(self, execution_id, user_id): + return HistoryPage([summary for _, summary in page], total, next_cursor) + + def find_history_entry(self, execution_id, user_id) -> Optional[HistoryEntry]: self._renew_files_cache() - file = self._ids_to_file_map.get(execution_id) - if file is None: + indexed_log = self._logs_by_id.get(execution_id) + if indexed_log is None: LOGGER.warning('find_history_entry: file for %s id not found', execution_id) return None - entry = self._extract_history_entry(file) - if entry is None: - LOGGER.warning('find_history_entry: cannot parse file for %s', execution_id) - - elif not self._can_access_entry(entry, user_id): + if not self._can_access(indexed_log.summary.user_id, user_id): message = 'User ' + user_id + ' has no access to execution #' + str(execution_id) - LOGGER.warning('%s. Original user: %s', message, entry.user_id) + LOGGER.warning('%s. Original user: %s', message, indexed_log.summary.user_id) raise AccessProhibitedException(message) + entry = self._extract_history_entry(indexed_log.filename) + if entry is None: + LOGGER.warning('find_history_entry: cannot parse file for %s', execution_id) + return entry def find_log(self, execution_id): self._renew_files_cache() - file = self._ids_to_file_map.get(execution_id) - if file is None: + indexed_log = self._logs_by_id.get(execution_id) + if indexed_log is None: LOGGER.warning('find_log: file for %s id not found', execution_id) return None - file_content = file_utils.read_file(os.path.join(self._output_folder, file), + file_content = file_utils.read_file(os.path.join(self._output_folder, indexed_log.filename), keep_newlines=True) log = file_content.split(OUTPUT_STARTED_MARKER, 1)[1] return _lstrip_any_linesep(log) @@ -250,17 +379,17 @@ def _read_parameters_text(file_path): return correct_format, parameters_text def _renew_files_cache(self): - cache = self._ids_to_file_map + index = self._logs_by_id obsolete_ids = [] - for id, file in cache.items(): - path = os.path.join(self._output_folder, file) + for id, indexed_log in index.items(): + path = os.path.join(self._output_folder, indexed_log.filename) if not os.path.exists(path): obsolete_ids.append(id) for obsolete_id in obsolete_ids: LOGGER.info('Logs for execution #' + obsolete_id + ' were deleted') - del cache[obsolete_id] + del index[obsolete_id] for file in os.listdir(self._output_folder): if not file.lower().endswith('.log'): @@ -275,7 +404,7 @@ def _renew_files_cache(self): if entry is None: continue - cache[entry.id] = file + index[entry.id] = _IndexedLog(file, _to_summary(entry)) @staticmethod def _create_log_identifier(audit_name, script_name, start_time): @@ -345,11 +474,8 @@ def _write_post_execution_info(log_file_path, exit_code): new_content = parameters_text + OUTPUT_STARTED_MARKER + os.linesep + file_parts[1] file_utils.write_file(log_file_path, new_content.encode(ENCODING), byte_content=True) - def _can_access_entry(self, entry, user_id, system_call=False): - if entry is None: - return True - - if is_same_user(entry.user_id, user_id): + def _can_access(self, entry_user_id, user_id, system_call=False): + if is_same_user(entry_user_id, user_id): return True if system_call: @@ -448,6 +574,108 @@ def finished(execution_id, user): self._execution_service.add_finish_listener(finished) +def _to_summary(entry: HistoryEntry) -> HistoryEntrySummary: + return HistoryEntrySummary( + id=entry.id, + user_name=entry.user_name, + user_id=entry.user_id, + start_time=entry.start_time, + script_name=entry.script_name, + exit_code=entry.exit_code) + + +def _matches_search(summary: HistoryEntrySummary, search_text): + return (search_text in (summary.script_name or '').lower() + or search_text in (summary.user_name or '').lower()) + + +def _candidate_key(candidate): + return candidate[0] + + +def _is_after_cursor(sort_key, cursor_key, descending): + return sort_key < cursor_key if descending else sort_key > cursor_key + + +def _id_sort_key(id): + """Ids are generated as incrementing numbers, but the folder may contain hand-made log files.""" + try: + return 1, int(id), '' + except (TypeError, ValueError): + return 0, 0, str(id) + + +def _text_sort_key(value): + return (1, value.lower()) if value is not None else (0, '') + + +def _datetime_sort_key(value): + return (1, value) if value is not None else (0, _MIN_DATETIME) + + +def _sort_key(summary: HistoryEntrySummary, sort): + id_key = _id_sort_key(summary.id) + + if sort == SORT_ID: + primary = id_key + elif sort == SORT_USER: + primary = _text_sort_key(summary.user_name) + elif sort == SORT_SCRIPT: + primary = _text_sort_key(summary.script_name) + else: + primary = _datetime_sort_key(summary.start_time) + + return primary, id_key + + +def _cursor_value(summary: HistoryEntrySummary, sort): + if sort == SORT_ID: + return summary.id + if sort == SORT_USER: + return summary.user_name + if sort == SORT_SCRIPT: + return summary.script_name + return to_millis(summary.start_time) if summary.start_time is not None else None + + +def _encode_cursor(summary: HistoryEntrySummary, sort, order): + payload = json.dumps({'s': sort, 'o': order, 'i': summary.id, 'v': _cursor_value(summary, sort)}) + return base64.urlsafe_b64encode(payload.encode(ENCODING)).decode('ascii').rstrip('=') + + +def _decode_cursor(cursor, sort, order): + try: + padded = cursor + '=' * (-len(cursor) % 4) + payload = json.loads(base64.urlsafe_b64decode(padded.encode('ascii')).decode(ENCODING)) + except (ValueError, binascii.Error, UnicodeDecodeError): + raise InvalidCursorException('Malformed cursor') + + if not isinstance(payload, dict): + raise InvalidCursorException('Malformed cursor') + + if payload.get('s') != sort or payload.get('o') != order: + raise InvalidCursorException('Cursor was created for a different sorting') + + id = payload.get('i') + if id is None: + raise InvalidCursorException('Malformed cursor') + + value = payload.get('v') + id_key = _id_sort_key(id) + + try: + if sort == SORT_ID: + primary = id_key + elif sort in (SORT_USER, SORT_SCRIPT): + primary = _text_sort_key(value) + else: + primary = _datetime_sort_key(ms_to_datetime(value) if value is not None else None) + except (AttributeError, TypeError, ValueError, OverflowError, OSError): + raise InvalidCursorException('Malformed cursor') + + return primary, id_key + + def _rstrip_once(text, char): if text.endswith(char): text = text[:-1] diff --git a/src/tests/execution_logging_test.py b/src/tests/execution_logging_test.py index 79de102a..88bddc56 100644 --- a/src/tests/execution_logging_test.py +++ b/src/tests/execution_logging_test.py @@ -1,3 +1,4 @@ +import base64 import functools import inspect import os @@ -14,7 +15,8 @@ from execution import executor from execution.execution_service import ExecutionService from execution.logging import ScriptOutputLogger, ExecutionLoggingService, OUTPUT_STARTED_MARKER, \ - LogNameCreator, ExecutionLoggingController + LogNameCreator, ExecutionLoggingController, InvalidCursorException, MAX_PAGE_LIMIT, \ + ORDER_ASC, ORDER_DESC, SORT_ID, SORT_SCRIPT, SORT_START_TIME, SORT_USER from model.model_helper import AccessProhibitedException from model.script_config import OUTPUT_FORMAT_TERMINAL from model.server_conf import LoggingConfig @@ -27,6 +29,12 @@ USER_X = User('userX', []) +_START_MILLIS = 1500000000000 + + +def _ids(page): + return [record.id for record in page.records] + def default_values_decorator(func): @functools.wraps(func) @@ -185,14 +193,11 @@ def test_get_history_entries_when_one(self): entries = self.logging_service.get_history_entries('user1') self.assertEqual(1, len(entries)) - entry = entries[0] - self.validate_history_entry(entry, - id='id1', - user_name='user1', - script_name='My script', - start_time=start_time, - command='./script.sh -p p1 --flag', - output_format='html') + self.validate_history_summary(entries[0], + id='id1', + user_name='user1', + script_name='My script', + start_time=start_time) def test_no_history_for_wrong_file(self): log_path = os.path.join(test_utils.temp_folder, 'wrong.log') @@ -203,10 +208,8 @@ def test_no_history_for_wrong_file(self): def test_multiline_command_in_history(self): self.simulate_logging(execution_id='id1', command='./script.sh -p a\nb -p2 "\n\n\n"') - entries = self.logging_service.get_history_entries('userX') - self.assertEqual(1, len(entries)) - entry = entries[0] + entry = self.logging_service.find_history_entry('id1', 'userX') self.validate_history_entry(entry, id='id1', command='./script.sh -p a\nb -p2 "\n\n\n"') def test_get_log_by_id(self): @@ -224,14 +227,14 @@ def test_get_log_by_wrong_id(self): def test_exit_code_in_history(self): self.simulate_logging(execution_id='1', log_lines=['text'], exit_code=13) - entry = self.logging_service.get_history_entries('userX')[0] - self.validate_history_entry(entry, id='1', exit_code=13) + summary = self.logging_service.get_history_entries('userX')[0] + self.validate_history_summary(summary, id='1', exit_code=13) def test_exit_code_when_no_post_execution_call(self): self.simulate_logging(execution_id='1', log_lines=['text'], exit_code=13, write_post_execution_info=False) - entry = self.logging_service.get_history_entries('userX')[0] - self.validate_history_entry(entry, id='1', exit_code=None) + summary = self.logging_service.get_history_entries('userX')[0] + self.validate_history_summary(summary, id='1', exit_code=None) def test_write_post_execution_info_before_log_closed(self): output_stream = Observable() @@ -257,8 +260,8 @@ def test_history_entries_after_restart(self): self.simulate_logging(execution_id='id1') new_service = ExecutionLoggingService(test_utils.temp_folder, LogNameCreator(), self.authorizer) - entry = new_service.get_history_entries('userX')[0] - self.validate_history_entry(entry, id='id1') + summary = new_service.get_history_entries('userX')[0] + self.validate_history_summary(summary, id='id1') def test_get_history_entries_after_delete(self): self.simulate_logging(execution_id='id1') @@ -282,8 +285,8 @@ def test_get_history_entries_only_for_current_user(self, user_id): entries = self._get_entries_sorted(user_id) self.assertEqual(2, len(entries)) - self.validate_history_entry(entry=entries[0], id='id1', user_id='userA') - self.validate_history_entry(entry=entries[1], id='id4', user_id='userA') + self.validate_history_summary(summary=entries[0], id='id1', user_id='userA') + self.validate_history_summary(summary=entries[1], id='id4', user_id='userA') def test_get_history_entries_for_power_user(self): self.simulate_logging(execution_id='id1', user_id='userA') @@ -294,10 +297,10 @@ def test_get_history_entries_for_power_user(self): entries = self._get_entries_sorted('power_user') self.assertEqual(4, len(entries)) - self.validate_history_entry(entry=entries[0], id='id1', user_id='userA') - self.validate_history_entry(entry=entries[1], id='id2', user_id='userB') - self.validate_history_entry(entry=entries[2], id='id3', user_id='userC') - self.validate_history_entry(entry=entries[3], id='id4', user_id='userA') + self.validate_history_summary(summary=entries[0], id='id1', user_id='userA') + self.validate_history_summary(summary=entries[1], id='id2', user_id='userB') + self.validate_history_summary(summary=entries[2], id='id3', user_id='userC') + self.validate_history_summary(summary=entries[3], id='id4', user_id='userA') def test_get_history_entries_for_system_call(self): self.simulate_logging(execution_id='id1', user_id='userA') @@ -308,10 +311,10 @@ def test_get_history_entries_for_system_call(self): entries = self._get_entries_sorted('some user', system_call=True) self.assertEqual(4, len(entries)) - self.validate_history_entry(entry=entries[0], id='id1', user_id='userA') - self.validate_history_entry(entry=entries[1], id='id2', user_id='userB') - self.validate_history_entry(entry=entries[2], id='id3', user_id='userC') - self.validate_history_entry(entry=entries[3], id='id4', user_id='userA') + self.validate_history_summary(summary=entries[0], id='id1', user_id='userA') + self.validate_history_summary(summary=entries[1], id='id2', user_id='userB') + self.validate_history_summary(summary=entries[2], id='id3', user_id='userC') + self.validate_history_summary(summary=entries[3], id='id4', user_id='userA') def test_find_history_entry_after_delete(self): self.simulate_logging(execution_id='id1') @@ -388,6 +391,229 @@ def test_find_log_when_windows_line_separator(self): log = self.logging_service.find_log('id1') self.assertEqual('hello\r\nwonderful\r\nworld\r\n', log) + def test_get_history_entries_when_execution_is_still_running(self): + output_stream = Observable() + self.start_logging(output_stream, execution_id='id1') + + entries = self.logging_service.get_history_entries('userX') + + self.assertEqual(['id1'], [entry.id for entry in entries]) + + output_stream.close() + + def test_get_history_page_when_no_limit(self): + self._simulate_executions(3) + + page = self.logging_service.get_history_page('userX') + + self.assertEqual(['id3', 'id2', 'id1'], _ids(page)) + self.assertEqual(3, page.total) + self.assertIsNone(page.next_cursor) + + def test_get_history_page_when_limit_smaller_than_total(self): + self._simulate_executions(3) + + page = self.logging_service.get_history_page('userX', limit=2) + + self.assertEqual(['id3', 'id2'], _ids(page)) + self.assertEqual(3, page.total) + self.assertIsNotNone(page.next_cursor) + + def test_get_history_page_when_limit_covers_everything(self): + self._simulate_executions(2) + + page = self.logging_service.get_history_page('userX', limit=5) + + self.assertEqual(['id2', 'id1'], _ids(page)) + self.assertIsNone(page.next_cursor) + + def test_get_history_page_when_cursor_traversal(self): + self._simulate_executions(7) + + visited = self._traverse_pages(limit=2) + + self.assertEqual(['id7', 'id6', 'id5', 'id4', 'id3', 'id2', 'id1'], visited) + + def test_get_history_page_when_execution_added_between_pages(self): + self._simulate_executions(4) + + first_page = self.logging_service.get_history_page('userX', limit=2) + self.assertEqual(['id4', 'id3'], _ids(first_page)) + + self.simulate_logging(execution_id='id5', start_time_millis=_START_MILLIS + 5000) + + second_page = self.logging_service.get_history_page('userX', limit=2, after=first_page.next_cursor) + + self.assertEqual(['id2', 'id1'], _ids(second_page)) + + def test_get_history_page_when_total_and_cursor(self): + self._simulate_executions(5) + + first_page = self.logging_service.get_history_page('userX', limit=2) + second_page = self.logging_service.get_history_page('userX', limit=2, after=first_page.next_cursor) + + self.assertEqual(5, first_page.total) + self.assertEqual(5, second_page.total) + + def test_get_history_page_when_entry_without_start_time(self): + self.simulate_logging(execution_id='id1', start_time_millis=_START_MILLIS) + self._write_log_without_start_time('id2') + + page = self.logging_service.get_history_page('userX') + + self.assertEqual(['id1', 'id2'], _ids(page)) + + @parameterized.expand([ + ('script name', 'BACK'), + ('user name', 'ALI'), + ]) + def test_get_history_page_when_search(self, _, search): + self.simulate_logging(execution_id='id1', user_name='alice', script_name='backup') + self.simulate_logging(execution_id='id2', user_name='bob', script_name='deploy') + + page = self.logging_service.get_history_page('power_user', search=search) + + self.assertEqual(['id1'], _ids(page)) + self.assertEqual(1, page.total) + + def test_get_history_page_when_search_matches_nothing(self): + self._simulate_executions(3) + + page = self.logging_service.get_history_page('userX', search='no such script') + + self.assertEqual([], _ids(page)) + self.assertEqual(0, page.total) + self.assertIsNone(page.next_cursor) + + @parameterized.expand([ + (SORT_ID, ORDER_ASC, ['id1', 'id2', 'id3']), + (SORT_ID, ORDER_DESC, ['id3', 'id2', 'id1']), + (SORT_SCRIPT, ORDER_ASC, ['id2', 'id3', 'id1']), + (SORT_SCRIPT, ORDER_DESC, ['id1', 'id3', 'id2']), + (SORT_START_TIME, ORDER_ASC, ['id1', 'id2', 'id3']), + (SORT_START_TIME, ORDER_DESC, ['id3', 'id2', 'id1']), + ]) + def test_get_history_page_when_sorted(self, sort, order, expected_ids): + self.simulate_logging(execution_id='id1', script_name='ccc', start_time_millis=_START_MILLIS + 1000) + self.simulate_logging(execution_id='id2', script_name='aaa', start_time_millis=_START_MILLIS + 2000) + self.simulate_logging(execution_id='id3', script_name='bbb', start_time_millis=_START_MILLIS + 3000) + + page = self.logging_service.get_history_page('userX', sort=sort, order=order) + + self.assertEqual(expected_ids, _ids(page)) + + @parameterized.expand([ + (ORDER_ASC, ['id2', 'id3', 'id1']), + (ORDER_DESC, ['id1', 'id3', 'id2']), + ]) + def test_get_history_page_when_sorted_by_user(self, order, expected_ids): + self.simulate_logging(execution_id='id1', user_name='carol') + self.simulate_logging(execution_id='id2', user_name='alice') + self.simulate_logging(execution_id='id3', user_name='bob') + + page = self.logging_service.get_history_page('power_user', sort=SORT_USER, order=order) + + self.assertEqual(expected_ids, _ids(page)) + + def test_get_history_page_when_sorted_and_cursor_traversal(self): + self.simulate_logging(execution_id='id1', script_name='ccc') + self.simulate_logging(execution_id='id2', script_name='aaa') + self.simulate_logging(execution_id='id3', script_name='bbb') + + visited = self._traverse_pages(limit=1, sort=SORT_SCRIPT, order=ORDER_ASC) + + self.assertEqual(['id2', 'id3', 'id1'], visited) + + def test_get_history_page_when_another_user(self): + self.simulate_logging(execution_id='id1', user_id='userA') + self.simulate_logging(execution_id='id2', user_id='userB') + + page = self.logging_service.get_history_page('userA') + + self.assertEqual(['id1'], _ids(page)) + self.assertEqual(1, page.total) + + def test_get_history_page_when_cursor_from_different_sorting(self): + self._simulate_executions(3) + + cursor = self.logging_service.get_history_page('userX', limit=1).next_cursor + + with self.assertRaises(InvalidCursorException): + self.logging_service.get_history_page('userX', limit=1, after=cursor, sort=SORT_ID) + + @parameterized.expand([ + ('not a cursor at all!',), + ('YWJjZA',), + (base64.urlsafe_b64encode(b'{"s": "startTime", "o": "desc", "i": "id1", "v": "oops"}').decode('ascii'),), + ]) + def test_get_history_page_when_malformed_cursor(self, cursor): + self._simulate_executions(3) + + with self.assertRaises(InvalidCursorException): + self.logging_service.get_history_page('userX', limit=1, after=cursor) + + @parameterized.expand([ + ({'sort': 'unknown_field'},), + ({'order': 'sideways'},), + ({'limit': 0},), + ({'limit': -1},), + ({'limit': MAX_PAGE_LIMIT + 1},), + ]) + def test_get_history_page_when_invalid_argument(self, arguments): + with self.assertRaises(ValueError): + self.logging_service.get_history_page('userX', **arguments) + + def _simulate_executions(self, count): + for index in range(1, count + 1): + self.simulate_logging(execution_id='id' + str(index), + start_time_millis=_START_MILLIS + index * 1000) + + def _traverse_pages(self, *, limit, user_id='userX', **kwargs): + visited = [] + cursor = None + + for _ in range(limit + len(self.get_log_files()) + 1): + page = self.logging_service.get_history_page(user_id, limit=limit, after=cursor, **kwargs) + visited.extend(_ids(page)) + + cursor = page.next_cursor + if cursor is None: + return visited + + self.fail('Cursor traversal did not finish') + + def _write_log_without_start_time(self, execution_id): + log_path = os.path.join(test_utils.temp_folder, execution_id + '_no_start_time.log') + file_utils.write_file(log_path, '\n'.join([ + 'id:' + execution_id, + 'user_name:userX', + 'user_id:userX', + 'script:my_script', + 'command:cmd', + 'output_format:' + OUTPUT_FORMAT_TERMINAL, + OUTPUT_STARTED_MARKER, + ''])) + + def validate_history_summary(self, summary, *, + id, + user_name='userX', + user_id=None, + script_name='my_script', + start_time='IGNORE', + exit_code: Optional[int] = 0): + + if user_id is None: + user_id = user_name + + self.assertEqual(id, summary.id) + self.assertEqual(user_name, summary.user_name) + self.assertEqual(user_id, summary.user_id) + self.assertEqual(script_name, summary.script_name) + if start_time != 'IGNORE': + self.assertEqual(ms_to_datetime(start_time), summary.start_time) + + self.assertEqual(exit_code, summary.exit_code) + def validate_history_entry(self, entry, *, id, user_name='userX', @@ -398,19 +624,16 @@ def validate_history_entry(self, entry, *, output_format=OUTPUT_FORMAT_TERMINAL, exit_code: Optional[int] = 0): - if user_id is None: - user_id = user_name + self.validate_history_summary(entry, + id=id, + user_name=user_name, + user_id=user_id, + script_name=script_name, + start_time=start_time, + exit_code=exit_code) - self.assertEqual(id, entry.id) - self.assertEqual(user_name, entry.user_name) - self.assertEqual(user_id, entry.user_id) - self.assertEqual(script_name, entry.script_name) self.assertEqual(command, entry.command) self.assertEqual(output_format, entry.output_format) - if start_time != 'IGNORE': - self.assertEqual(ms_to_datetime(start_time), entry.start_time) - - self.assertEqual(exit_code, entry.exit_code) def read_logs_only(self, log_file): content = file_utils.read_file(log_file, keep_newlines=True) diff --git a/src/tests/web/server_test.py b/src/tests/web/server_test.py index 59fdceb6..a2f2b6d4 100644 --- a/src/tests/web/server_test.py +++ b/src/tests/web/server_test.py @@ -14,6 +14,7 @@ from auth.authorization import Authorizer, ANY_USER, EmptyGroupProvider from config.config_service import ConfigService +from execution.logging import HistoryEntrySummary, HistoryPage, InvalidCursorException from features.file_download_feature import FileDownloadFeature from features.file_upload_feature import FileUploadFeature from files.user_file_storage import UserFileStorage @@ -21,6 +22,7 @@ from tests import test_utils from tests.test_utils import MockAuthenticator from utils import os_utils, env_utils, file_utils +from utils.date_utils import ms_to_datetime from web import server @@ -255,6 +257,76 @@ def test_get_scripts_when_basic_auth_failure(self): response = requests.get('http://127.0.0.1:12345/scripts', auth=HTTPBasicAuth('normal_user', 'wrong_pass')) self.assertEqual(401, response.status_code) + def test_history_short_log(self): + execution_service = MagicMock() + execution_service.is_running.side_effect = lambda execution_id, user: execution_id == 'id2' + self.start_server(12345, '127.0.0.1', + execution_service=execution_service, + execution_logging_service=self._logging_service_returning( + ['id3', 'id2', 'id1'], total=7, next_cursor='cursor1')) + + response = self.request('GET', 'http://127.0.0.1:12345/history/execution_log/short?limit=3') + + self.assertEqual(['id3', 'id2', 'id1'], [record['id'] for record in response['records']]) + self.assertEqual(['finished', 'running', 'finished'], + [record['status'] for record in response['records']]) + self.assertEqual(7, response['total']) + self.assertEqual('cursor1', response['nextCursor']) + + def test_history_short_log_when_arguments_specified(self): + logging_service = self._logging_service_returning([]) + self.start_server(12345, '127.0.0.1', execution_logging_service=logging_service) + + self.request('GET', 'http://127.0.0.1:12345/history/execution_log/short' + '?limit=10&after=some_cursor&search=backup&sort=user&order=asc') + + logging_service.get_history_page.assert_called_once_with( + 'normal_user', search='backup', sort='user', order='asc', limit=10, after='some_cursor') + + def test_history_short_log_when_no_arguments(self): + logging_service = self._logging_service_returning([]) + self.start_server(12345, '127.0.0.1', execution_logging_service=logging_service) + + self.request('GET', 'http://127.0.0.1:12345/history/execution_log/short') + + logging_service.get_history_page.assert_called_once_with( + 'normal_user', search=None, sort=None, order=None, limit=None, after=None) + + def test_history_short_log_when_limit_not_a_number(self): + self.start_server(12345, '127.0.0.1', execution_logging_service=self._logging_service_returning([])) + + response = self._user_session.get('http://127.0.0.1:12345/history/execution_log/short?limit=abc') + + self.assertEqual(400, response.status_code) + + @parameterized.expand([ + (ValueError('Unsupported sort field: unknown_field'),), + (InvalidCursorException('Malformed cursor'),), + ]) + def test_history_short_log_when_service_rejects_arguments(self, error): + logging_service = MagicMock() + logging_service.get_history_page.side_effect = error + self.start_server(12345, '127.0.0.1', execution_logging_service=logging_service) + + response = self._user_session.get('http://127.0.0.1:12345/history/execution_log/short?limit=10') + + self.assertEqual(400, response.status_code) + + @staticmethod + def _logging_service_returning(ids, *, total=None, next_cursor=None): + records = [HistoryEntrySummary(id=id, + user_name='normal_user', + user_id='normal_user', + start_time=ms_to_datetime(1500000000000), + script_name='my_script', + exit_code=0) + for id in ids] + + logging_service = MagicMock() + logging_service.get_history_page.return_value = HistoryPage( + records, total if total is not None else len(records), next_cursor) + return logging_service + @staticmethod def get_xsrf_token(session): response = session.get('http://127.0.0.1:12345/admin/scripts') @@ -272,7 +344,10 @@ def check_server_running(self): response = self._user_session.get('http://127.0.0.1:12345/conf') self.assertEqual(response.status_code, 200) - def start_server(self, port, address, *, xsrf_protection=XSRF_PROTECTION_TOKEN): + def start_server(self, port, address, *, + xsrf_protection=XSRF_PROTECTION_TOKEN, + execution_service=None, + execution_logging_service=None): file_download_feature = FileDownloadFeature(UserFileStorage(b'some_secret'), test_utils.temp_folder) config = ServerConfig() config.port = port @@ -281,8 +356,12 @@ def start_server(self, port, address, *, xsrf_protection=XSRF_PROTECTION_TOKEN): config.max_request_size_mb = 1 authorizer = Authorizer(ANY_USER, ['admin_user'], [], ['admin_user'], EmptyGroupProvider()) - execution_service = MagicMock() - execution_service.start_script.return_value = 3 + if execution_service is None: + execution_service = MagicMock() + execution_service.start_script.return_value = 3 + + if execution_logging_service is None: + execution_logging_service = MagicMock() cookie_secret = b'cookie_secret' @@ -294,7 +373,7 @@ def start_server(self, port, address, *, xsrf_protection=XSRF_PROTECTION_TOKEN): authorizer, execution_service, MagicMock(), - MagicMock(), + execution_logging_service, ConfigService(authorizer, self.conf_folder, True, test_utils.process_invoker), MagicMock(), FileUploadFeature(UserFileStorage(cookie_secret), test_utils.temp_folder), diff --git a/src/web/server.py b/src/web/server.py index d037e5fc..2685d22f 100755 --- a/src/web/server.py +++ b/src/web/server.py @@ -24,7 +24,7 @@ CorruptConfigFileException from config.exceptions import InvalidConfigException from execution.execution_service import ExecutionService -from execution.logging import ExecutionLoggingService +from execution.logging import ExecutionLoggingService, InvalidCursorException from features.file_download_feature import FileDownloadFeature from features.file_upload_feature import FileUploadFeature from model import external_model @@ -679,18 +679,40 @@ def post(self): file_utils.write_file(file_path, value) +def _parse_limit(limit_arg): + if limit_arg is None: + return None + + try: + return int(limit_arg) + except ValueError: + raise ValueError('limit should be an integer') + + class GetShortHistoryEntriesHandler(BaseRequestHandler): @check_authorization @inject_user def get(self, user): - history_entries = self.application.execution_logging_service.get_history_entries(user.user_id) - running_script_ids = [] - for entry in history_entries: - if self.application.execution_service.is_running(entry.id, user): - running_script_ids.append(entry.id) - - short_logs = to_short_execution_log(history_entries, running_script_ids) - self.write(json.dumps(short_logs)) + try: + page = self.application.execution_logging_service.get_history_page( + user.user_id, + search=self.get_argument('search', None), + sort=self.get_argument('sort', None), + order=self.get_argument('order', None), + limit=_parse_limit(self.get_argument('limit', None)), + after=self.get_argument('after', None)) + except (ValueError, InvalidCursorException) as e: + respond_error(self, 400, str(e)) + return + + running_script_ids = [entry.id for entry in page.records + if self.application.execution_service.is_running(entry.id, user)] + + self.write(json.dumps({ + 'records': to_short_execution_log(page.records, running_script_ids), + 'total': page.total, + 'nextCursor': page.next_cursor + })) class GetLongHistoryEntryHandler(BaseRequestHandler): diff --git a/web-src/src/common/components/history/executions-log-table.vue b/web-src/src/common/components/history/executions-log-table.vue index 2d3483c7..34b4aa53 100644 --- a/web-src/src/common/components/history/executions-log-table.vue +++ b/web-src/src/common/components/history/executions-log-table.vue @@ -16,14 +16,14 @@ ID - Start Time + Start Time User Script - Status + Status - + {{ row.id }} {{ row.startTimeString }} {{ row.user }} @@ -33,14 +33,17 @@

History will appear here

+

No executions found

+ + diff --git a/web-src/src/common/store/executions-module.js b/web-src/src/common/store/executions-module.js index e3e57ec5..3de2071b 100644 --- a/web-src/src/common/store/executions-module.js +++ b/web-src/src/common/store/executions-module.js @@ -1,26 +1,117 @@ -import {isEmptyString, isNull, logError} from '@/common/utils/common'; +import {isBlankString, isEmptyString, isNull, logError} from '@/common/utils/common'; import {axiosInstance} from '@/common/utils/axios_utils'; +export const DEFAULT_PAGE_SIZE = 25; +export const PAGE_SIZE_OPTIONS = [10, 25, 50, 100, 250, 500]; +export const DEFAULT_SORT_COLUMN = 'startTime'; +export const DEFAULT_ORDER = 'desc'; + const store = () => ({ state: { executions: [], selectedExecution: null, selectedExecutionId: null, loading: false, - detailsLoading: false + detailsLoading: false, + pageSize: DEFAULT_PAGE_SIZE, + total: 0, + searchText: '', + sortColumn: DEFAULT_SORT_COLUMN, + order: DEFAULT_ORDER, + hasNext: false, + hasPrev: false, + // cursors of the pages visited before the current one; its length is also the current page index + cursorStack: [], + currentCursor: null, + nextCursor: null, + // incremented per request, so that a late response of an outdated request is dropped + requestToken: 0 }, namespaced: true, actions: { - init({commit}) { - commit('SET_LOADING', true); + init({commit, dispatch}) { commit('SET_EXECUTION_DETAILS', {execution: null, id: null}); - axiosInstance.get('history/execution_log/short').then(({data}) => { - sortExecutionLogs(data); + return dispatch('loadFirstPage'); + }, + + loadFirstPage({dispatch}) { + return dispatch('loadPage', {cursor: null, cursorStack: []}); + }, + + reload({dispatch}) { + return dispatch('loadFirstPage'); + }, + + nextPage({dispatch, state}) { + if (!state.hasNext) { + return Promise.resolve(); + } + + return dispatch('loadPage', { + cursor: state.nextCursor, + cursorStack: [...state.cursorStack, state.currentCursor] + }); + }, + + prevPage({dispatch, state}) { + if (!state.hasPrev) { + return Promise.resolve(); + } + + const cursorStack = [...state.cursorStack]; + const cursor = cursorStack.pop(); + + return dispatch('loadPage', {cursor, cursorStack}); + }, + + setPageSize({commit, dispatch}, pageSize) { + commit('SET_PAGE_SIZE', pageSize); + + return dispatch('loadFirstPage'); + }, + + setSearch({commit, dispatch}, searchText) { + commit('SET_SEARCH_TEXT', isNull(searchText) ? '' : searchText); + + return dispatch('loadFirstPage'); + }, + + setSort({commit, dispatch}, {column, order}) { + commit('SET_SORT', {column, order}); + + return dispatch('loadFirstPage'); + }, + + loadPage({commit, state}, {cursor, cursorStack}) { + const requestToken = state.requestToken + 1; + commit('SET_REQUEST_TOKEN', requestToken); + commit('SET_LOADING', true); - let executions = data.map(log => translateExecutionLog(log)); - commit('SET_EXECUTIONS', executions); + const params = { + limit: state.pageSize, + sort: state.sortColumn, + order: state.order + }; + if (!isBlankString(state.searchText)) { + params.search = state.searchText; + } + if (!isEmptyString(cursor)) { + params.after = cursor; + } + + return axiosInstance.get('history/execution_log/short', {params}).then(({data}) => { + if (requestToken !== state.requestToken) { + return; + } + + commit('SET_PAGE', {data, cursor, cursorStack}); commit('SET_LOADING', false); + }).catch((error) => { + if (requestToken === state.requestToken) { + commit('SET_LOADING', false); + } + logError(error); }); }, @@ -72,31 +163,40 @@ const store = () => ({ SET_DETAILS_LOADING(state, loading) { state.detailsLoading = loading; - } - } -}); + }, -export default store + SET_REQUEST_TOKEN(state, requestToken) { + state.requestToken = requestToken; + }, -function sortExecutionLogs(logs) { - logs.sort(function (v1, v2) { - if (isNull(v1.startTime)) { - if (isNull(v2.startTime)) { - return v1.user.localeCompare(v2.user); - } - return 1; - } else if (isNull(v2.startTime)) { - return -1; - } + SET_PAGE_SIZE(state, pageSize) { + state.pageSize = pageSize; + }, - let dateCompare = Date.parse(v2.startTime) - Date.parse(v1.startTime); - if (dateCompare !== 0) { - return dateCompare; + SET_SEARCH_TEXT(state, searchText) { + state.searchText = searchText; + }, + + SET_SORT(state, {column, order}) { + state.sortColumn = column; + state.order = order; + }, + + SET_PAGE(state, {data, cursor, cursorStack}) { + const records = isNull(data) || isNull(data.records) ? [] : data.records; + + state.executions = records.map(log => translateExecutionLog(log)); + state.total = isNull(data) || isNull(data.total) ? 0 : data.total; + state.nextCursor = isNull(data) || isNull(data.nextCursor) ? null : data.nextCursor; + state.hasNext = !isNull(state.nextCursor); + state.currentCursor = isNull(cursor) ? null : cursor; + state.cursorStack = cursorStack; + state.hasPrev = cursorStack.length > 0; } + } +}); - return v1.user.localeCompare(v2.user); - }); -} +export default store export function translateExecutionLog(log) { log.startTimeString = getStartTimeString(log); diff --git a/web-src/tests/unit/history/executions-module_test.js b/web-src/tests/unit/history/executions-module_test.js new file mode 100644 index 00000000..b80d599e --- /dev/null +++ b/web-src/tests/unit/history/executions-module_test.js @@ -0,0 +1,257 @@ +'use strict'; +import historyModule from '@/common/store/executions-module'; +import {axiosInstance} from '@/common/utils/axios_utils'; +import MockAdapter from 'axios-mock-adapter'; +import Vuex from 'vuex'; +import {createScriptServerTestVue, flushPromises} from '../test_utils'; + +const localVue = createScriptServerTestVue(); +localVue.use(Vuex); + +let axiosMock; +let requestParams; + +function record(id, user = 'user' + id, script = 'script' + id) { + return {id, startTime: null, user, script, status: 'finished', exitCode: 0}; +} + +function page(records, total, nextCursor) { + return {records, total, nextCursor}; +} + +function mockPages(pagesByCursor) { + axiosMock.onGet('history/execution_log/short').reply(config => { + requestParams.push(config.params); + const cursor = config.params.after; + return [200, pagesByCursor[cursor === undefined ? 'FIRST' : cursor]]; + }); +} + +function mockDeferredPages() { + const pendingResponses = []; + + axiosMock.onGet('history/execution_log/short').reply(config => { + requestParams.push(config.params); + return new Promise(resolve => pendingResponses.push(data => resolve([200, data]))); + }); + + return pendingResponses; +} + +function lastParams() { + return requestParams[requestParams.length - 1]; +} + +describe('Test executions module', function () { + let store; + + beforeEach(function () { + store = new Vuex.Store({ + modules: { + history: historyModule() + } + }); + + axiosMock = new MockAdapter(axiosInstance); + requestParams = []; + }); + + afterEach(function () { + axiosMock.restore(); + }); + + describe('Test first page', function () { + + it('test load first page', async function () { + mockPages({FIRST: page([record('1'), record('2')], 5, 'cursor1')}); + + await store.dispatch('history/init'); + await flushPromises(); + + expect(lastParams()).toEqual({limit: 25, sort: 'startTime', order: 'desc'}); + expect(store.state.history.executions.map(e => e.id)).toEqual(['1', '2']); + expect(store.state.history.total).toEqual(5); + expect(store.state.history.hasNext).toBeTrue(); + expect(store.state.history.hasPrev).toBeFalse(); + expect(store.state.history.loading).toBeFalse(); + }); + + it('test translate records', async function () { + mockPages({FIRST: page([{id: '1', startTime: null, user: 'me', script: 's', status: 'error', exitCode: 3}], 1, null)}); + + await store.dispatch('history/init'); + await flushPromises(); + + expect(store.state.history.executions[0].fullStatus).toEqual('error (3)'); + expect(store.state.history.executions[0].startTimeString).toEqual(''); + }); + + it('test no next page when cursor is null', async function () { + mockPages({FIRST: page([record('1')], 1, null)}); + + await store.dispatch('history/init'); + await flushPromises(); + + expect(store.state.history.hasNext).toBeFalse(); + }); + + it('test loading reset on failure', async function () { + axiosMock.onGet('history/execution_log/short').reply(500); + + await store.dispatch('history/init'); + await flushPromises(); + + expect(store.state.history.loading).toBeFalse(); + expect(store.state.history.executions).toEqual([]); + }); + }); + + describe('Test traversal', function () { + + beforeEach(async function () { + mockPages({ + FIRST: page([record('1')], 3, 'cursor1'), + cursor1: page([record('2')], 3, 'cursor2'), + cursor2: page([record('3')], 3, null) + }); + + await store.dispatch('history/init'); + await flushPromises(); + }); + + it('test next page sends cursor', async function () { + await store.dispatch('history/nextPage'); + await flushPromises(); + + expect(lastParams().after).toEqual('cursor1'); + expect(store.state.history.executions.map(e => e.id)).toEqual(['2']); + expect(store.state.history.hasPrev).toBeTrue(); + expect(store.state.history.hasNext).toBeTrue(); + }); + + it('test last page has no next', async function () { + await store.dispatch('history/nextPage'); + await flushPromises(); + await store.dispatch('history/nextPage'); + await flushPromises(); + + expect(store.state.history.executions.map(e => e.id)).toEqual(['3']); + expect(store.state.history.hasNext).toBeFalse(); + expect(store.state.history.hasPrev).toBeTrue(); + }); + + it('test prev page returns to first page', async function () { + await store.dispatch('history/nextPage'); + await flushPromises(); + await store.dispatch('history/nextPage'); + await flushPromises(); + await store.dispatch('history/prevPage'); + await flushPromises(); + + expect(lastParams().after).toEqual('cursor1'); + expect(store.state.history.executions.map(e => e.id)).toEqual(['2']); + expect(store.state.history.hasPrev).toBeTrue(); + + await store.dispatch('history/prevPage'); + await flushPromises(); + + expect(lastParams().after).toBeUndefined(); + expect(store.state.history.executions.map(e => e.id)).toEqual(['1']); + expect(store.state.history.hasPrev).toBeFalse(); + }); + + it('test next page ignored on last page', async function () { + await store.dispatch('history/nextPage'); + await flushPromises(); + await store.dispatch('history/nextPage'); + await flushPromises(); + + const requestCount = requestParams.length; + await store.dispatch('history/nextPage'); + await flushPromises(); + + expect(requestParams.length).toEqual(requestCount); + }); + + it('test prev page ignored on first page', async function () { + const requestCount = requestParams.length; + + await store.dispatch('history/prevPage'); + await flushPromises(); + + expect(requestParams.length).toEqual(requestCount); + }); + }); + + describe('Test reset to first page', function () { + + beforeEach(async function () { + mockPages({ + FIRST: page([record('1')], 3, 'cursor1'), + cursor1: page([record('2')], 3, 'cursor2') + }); + + await store.dispatch('history/init'); + await flushPromises(); + await store.dispatch('history/nextPage'); + await flushPromises(); + }); + + it('test search resets to first page', async function () { + await store.dispatch('history/setSearch', 'abc'); + await flushPromises(); + + expect(lastParams()).toEqual({limit: 25, sort: 'startTime', order: 'desc', search: 'abc'}); + expect(store.state.history.searchText).toEqual('abc'); + expect(store.state.history.hasPrev).toBeFalse(); + }); + + it('test blank search is not sent', async function () { + await store.dispatch('history/setSearch', ' '); + await flushPromises(); + + expect(lastParams().search).toBeUndefined(); + }); + + it('test sort resets to first page', async function () { + await store.dispatch('history/setSort', {column: 'user', order: 'asc'}); + await flushPromises(); + + expect(lastParams()).toEqual({limit: 25, sort: 'user', order: 'asc'}); + expect(store.state.history.hasPrev).toBeFalse(); + }); + + it('test page size resets to first page', async function () { + await store.dispatch('history/setPageSize', 100); + await flushPromises(); + + expect(lastParams()).toEqual({limit: 100, sort: 'startTime', order: 'desc'}); + expect(store.state.history.pageSize).toEqual(100); + expect(store.state.history.hasPrev).toBeFalse(); + }); + }); + + describe('Test out of order responses', function () { + + it('test stale response does not overwrite newer state', async function () { + const pendingResponses = mockDeferredPages(); + + store.dispatch('history/init'); + await flushPromises(); + store.dispatch('history/setSearch', 'newer'); + await flushPromises(); + + expect(pendingResponses.length).toEqual(2); + + pendingResponses[1](page([record('newer')], 1, null)); + await flushPromises(); + pendingResponses[0](page([record('stale')], 99, 'staleCursor')); + await flushPromises(); + + expect(store.state.history.executions.map(e => e.id)).toEqual(['newer']); + expect(store.state.history.total).toEqual(1); + expect(store.state.history.hasNext).toBeFalse(); + expect(store.state.history.loading).toBeFalse(); + }); + }); +}); diff --git a/web-src/tests/unit/history/executions-paginator_test.js b/web-src/tests/unit/history/executions-paginator_test.js new file mode 100644 index 00000000..58947370 --- /dev/null +++ b/web-src/tests/unit/history/executions-paginator_test.js @@ -0,0 +1,149 @@ +'use strict'; +import ExecutionsPaginator from '@/common/components/history/executions-paginator' +import historyModule from '@/common/store/executions-module'; +import {mount} from '@vue/test-utils'; +import Vuex from 'vuex'; +import {attachToDocument, createScriptServerTestVue, vueTicks} from '../test_utils'; + +const localVue = createScriptServerTestVue(); +localVue.use(Vuex); + +function records(count) { + const result = []; + for (let i = 0; i < count; i++) { + result.push({id: String(i)}); + } + return result; +} + +describe('Test executions paginator', function () { + let paginator; + let store; + + beforeEach(async function () { + store = new Vuex.Store({ + modules: { + history: historyModule() + } + }); + + paginator = mount(ExecutionsPaginator, { + attachTo: attachToDocument(), + store, + localVue + }); + + await vueTicks(); + }); + + afterEach(function () { + paginator.destroy(); + }); + + async function setPageState({executions = [], total = 0, pageSize = 25, cursorStack = [], hasNext = false, hasPrev = false, loading = false}) { + const state = store.state.history; + state.executions = executions; + state.total = total; + state.pageSize = pageSize; + state.cursorStack = cursorStack; + state.hasNext = hasNext; + state.hasPrev = hasPrev; + state.loading = loading; + + await vueTicks(); + } + + function rangeLabel() { + return paginator.find('.range-label').text(); + } + + function prevButton() { + return paginator.find('.prev-button').element; + } + + function nextButton() { + return paginator.find('.next-button').element; + } + + describe('Test range label', function () { + + it('test first page range', async function () { + await setPageState({executions: records(25), total: 348}); + + expect(rangeLabel()).toEqual('1-25 of 348'); + }); + + it('test second page range', async function () { + await setPageState({executions: records(25), total: 348, cursorStack: ['cursor1']}); + + expect(rangeLabel()).toEqual('26-50 of 348'); + }); + + it('test partial last page range', async function () { + await setPageState({executions: records(23), total: 348, cursorStack: ['cursor1', 'cursor2']}); + + expect(rangeLabel()).toEqual('51-73 of 348'); + }); + + it('test custom page size range', async function () { + await setPageState({executions: records(10), total: 348, pageSize: 10, cursorStack: ['cursor1']}); + + expect(rangeLabel()).toEqual('11-20 of 348'); + }); + + it('test empty range', async function () { + await setPageState({executions: [], total: 0}); + + expect(rangeLabel()).toEqual('0 of 0'); + }); + }); + + describe('Test button states', function () { + + it('test both disabled on single page', async function () { + await setPageState({executions: records(3), total: 3}); + + expect(prevButton().disabled).toBeTrue(); + expect(nextButton().disabled).toBeTrue(); + }); + + it('test next enabled when more pages', async function () { + await setPageState({executions: records(25), total: 100, hasNext: true}); + + expect(prevButton().disabled).toBeTrue(); + expect(nextButton().disabled).toBeFalse(); + }); + + it('test prev enabled on later page', async function () { + await setPageState({executions: records(25), total: 100, cursorStack: ['cursor1'], hasPrev: true, hasNext: true}); + + expect(prevButton().disabled).toBeFalse(); + expect(nextButton().disabled).toBeFalse(); + }); + + it('test both disabled while loading', async function () { + await setPageState({ + executions: records(25), total: 100, cursorStack: ['cursor1'], + hasPrev: true, hasNext: true, loading: true + }); + + expect(prevButton().disabled).toBeTrue(); + expect(nextButton().disabled).toBeTrue(); + }); + }); + + describe('Test page size select', function () { + + it('test options', async function () { + const options = paginator.findAll('.page-size-select option').wrappers.map(w => w.text()); + + expect(options).toEqual(['10', '25', '50', '100', '250', '500']); + }); + + it('test select disabled while loading', async function () { + await setPageState({loading: true}); + + expect(paginator.find('.page-size-select').element.disabled).toBeTrue(); + }); + }); +}); diff --git a/web-src/tests/unit/main-app/components/history/AppHistoryPanel_test.js b/web-src/tests/unit/main-app/components/history/AppHistoryPanel_test.js index d6757eee..cd81f93e 100644 --- a/web-src/tests/unit/main-app/components/history/AppHistoryPanel_test.js +++ b/web-src/tests/unit/main-app/components/history/AppHistoryPanel_test.js @@ -22,7 +22,16 @@ describe('Test AppHistoryPanel', function () { namespaced: true, state: { loading: false, - detailsLoading: false + detailsLoading: false, + executions: [], + pageSize: 25, + total: 0, + searchText: '', + sortColumn: 'startTime', + order: 'desc', + hasNext: false, + hasPrev: false, + cursorStack: [] } }, page: pageModule