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
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