From 05598f35bd3d426fecb2906b81ee0f82f0c100e6 Mon Sep 17 00:00:00 2001 From: cpadlab Date: Fri, 31 Jul 2026 10:31:36 +0200 Subject: [PATCH 01/12] feat: Add pagination to history entries --- src/web/server.py | 69 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/src/web/server.py b/src/web/server.py index d037e5fc..7b945659 100755 --- a/src/web/server.py +++ b/src/web/server.py @@ -2,11 +2,13 @@ import asyncio import json import logging.config +import math import os import signal import ssl import time import urllib +from datetime import datetime, timezone from urllib.parse import urlencode import tornado.concurrent @@ -679,18 +681,76 @@ def post(self): file_utils.write_file(file_path, value) +ALLOWED_PAGE_SIZES = {10, 25, 50, 100, 250, 500} +DEFAULT_PAGE_SIZE = 25 + + class GetShortHistoryEntriesHandler(BaseRequestHandler): @check_authorization @inject_user def get(self, user): + page_arg = self.get_argument('page', None) + size_arg = self.get_argument('size', None) + history_entries = self.application.execution_logging_service.get_history_entries(user.user_id) + + def _get_sort_key(entry): + if entry.start_time is None: + return datetime.min.replace(tzinfo=timezone.utc) + return entry.start_time + + history_entries.sort(key=_get_sort_key, reverse=True) + + if page_arg is None and size_arg is None: + 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)) + return + + try: + size = int(size_arg) if size_arg is not None else DEFAULT_PAGE_SIZE + if size not in ALLOWED_PAGE_SIZES: + size = DEFAULT_PAGE_SIZE + except (ValueError, TypeError): + size = DEFAULT_PAGE_SIZE + + try: + page = int(page_arg) if page_arg is not None else 1 + if page < 1: + page = 1 + except (ValueError, TypeError): + page = 1 + + total_count = len(history_entries) + total_pages = math.ceil(total_count / size) if total_count > 0 else 1 + + if page > total_pages and total_pages > 0: + page = total_pages + + start_idx = (page - 1) * size + end_idx = start_idx + size + page_entries = history_entries[start_idx:end_idx] + running_script_ids = [] - for entry in history_entries: + for entry in page_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)) + short_logs = to_short_execution_log(page_entries, running_script_ids) + + response = { + 'records': short_logs, + 'total': total_count, + 'page': page, + 'pageSize': size, + 'totalPages': total_pages + } + + self.write(json.dumps(response)) class GetLongHistoryEntryHandler(BaseRequestHandler): @@ -895,7 +955,8 @@ def init(server_config: ServerConfig, asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) io_loop = tornado.ioloop.IOLoop.current() - global _http_server + global _http_server, _tornado_app + _tornado_app = application _http_server = httpserver.HTTPServer( application, ssl_options=ssl_context, From 14f7e2d522bd38e13d604d0d9de24927f895322b Mon Sep 17 00:00:00 2001 From: cpadlab Date: Fri, 31 Jul 2026 10:32:29 +0200 Subject: [PATCH 02/12] test: Add tests for history log pagination --- src/tests/web/server_test.py | 92 ++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/src/tests/web/server_test.py b/src/tests/web/server_test.py index 59fdceb6..cf26ad3b 100644 --- a/src/tests/web/server_test.py +++ b/src/tests/web/server_test.py @@ -313,6 +313,98 @@ def start_server(self, port, address, *, xsrf_protection=XSRF_PROTECTION_TOKEN): self._admin_session.cookies['username'] = create_signed_value(cookie_secret, 'username', 'admin_user') \ .decode('utf8') + def _create_mock_history_entry(self, entry_id, user_name='normal_user', script_name='script1', start_time_ms=None, exit_code=0): + from execution.logging import HistoryEntry + from datetime import datetime, timezone + entry = HistoryEntry() + entry.id = str(entry_id) + entry.user_name = user_name + entry.user_id = user_name + entry.script_name = script_name + entry.command = 'python script.py' + entry.output_format = 'terminal' + entry.exit_code = exit_code + if start_time_ms is not None: + entry.start_time = datetime.fromtimestamp(start_time_ms / 1000.0, tz=timezone.utc) + return entry + + def test_history_short_log_pagination_empty(self): + self.start_server(12345, '127.0.0.1') + server._tornado_app.execution_logging_service.get_history_entries.return_value = [] + + response = self.request('GET', 'http://127.0.0.1:12345/history/execution_log/short?page=1&size=25') + self.assertEqual(response, { + 'records': [], + 'total': 0, + 'page': 1, + 'pageSize': 25, + 'totalPages': 1 + }) + + def test_history_short_log_pagination_slicing_and_sorting(self): + self.start_server(12345, '127.0.0.1') + entries = [ + self._create_mock_history_entry('e1', start_time_ms=1000), + self._create_mock_history_entry('e2', start_time_ms=3000), + self._create_mock_history_entry('e3', start_time_ms=2000), + ] + server._tornado_app.execution_logging_service.get_history_entries.return_value = entries + + page1 = self.request('GET', 'http://127.0.0.1:12345/history/execution_log/short?page=1&size=10') + self.assertEqual(page1['total'], 3) + self.assertEqual(page1['page'], 1) + self.assertEqual(page1['pageSize'], 10) + self.assertEqual(page1['totalPages'], 1) + self.assertEqual([r['id'] for r in page1['records']], ['e2', 'e3', 'e1']) + + def test_history_short_log_pagination_multiple_pages(self): + self.start_server(12345, '127.0.0.1') + entries = [self._create_mock_history_entry(f'e{i}', start_time_ms=i * 1000) for i in range(1, 35)] + server._tornado_app.execution_logging_service.get_history_entries.return_value = entries + + res_p1 = self.request('GET', 'http://127.0.0.1:12345/history/execution_log/short?page=1&size=10') + self.assertEqual(res_p1['total'], 34) + self.assertEqual(res_p1['page'], 1) + self.assertEqual(res_p1['pageSize'], 10) + self.assertEqual(res_p1['totalPages'], 4) + self.assertEqual(len(res_p1['records']), 10) + self.assertEqual(res_p1['records'][0]['id'], 'e34') + + res_p4 = self.request('GET', 'http://127.0.0.1:12345/history/execution_log/short?page=4&size=10') + self.assertEqual(res_p4['page'], 4) + self.assertEqual(len(res_p4['records']), 4) + self.assertEqual(res_p4['records'][-1]['id'], 'e1') + + def test_history_short_log_pagination_invalid_size_fallback(self): + self.start_server(12345, '127.0.0.1') + server._tornado_app.execution_logging_service.get_history_entries.return_value = [] + + response = self.request('GET', 'http://127.0.0.1:12345/history/execution_log/short?page=1&size=999') + self.assertEqual(response['pageSize'], 25) + + response_abc = self.request('GET', 'http://127.0.0.1:12345/history/execution_log/short?page=1&size=abc') + self.assertEqual(response_abc['pageSize'], 25) + + def test_history_short_log_pagination_invalid_page_fallback(self): + self.start_server(12345, '127.0.0.1') + server._tornado_app.execution_logging_service.get_history_entries.return_value = [] + + response = self.request('GET', 'http://127.0.0.1:12345/history/execution_log/short?page=-5&size=25') + self.assertEqual(response['page'], 1) + + response_invalid = self.request('GET', 'http://127.0.0.1:12345/history/execution_log/short?page=xyz&size=25') + self.assertEqual(response_invalid['page'], 1) + + def test_history_short_log_legacy_unpaginated(self): + self.start_server(12345, '127.0.0.1') + entries = [self._create_mock_history_entry('e1', start_time_ms=1000)] + server._tornado_app.execution_logging_service.get_history_entries.return_value = entries + + response = self.request('GET', 'http://127.0.0.1:12345/history/execution_log/short') + self.assertIsInstance(response, list) + self.assertEqual(len(response), 1) + self.assertEqual(response[0]['id'], 'e1') + def start_loop(self): io_loop = IOLoop.current() self.ioloop_thread = threading.Thread(target=io_loop.start) From d36d1269e0715a4ad89cc9359e6f834057c25388 Mon Sep 17 00:00:00 2001 From: cpadlab Date: Fri, 31 Jul 2026 10:35:14 +0200 Subject: [PATCH 03/12] refactor: Extract history pagination logic --- src/web/server.py | 102 +++++++++++++++++++++++----------------------- 1 file changed, 50 insertions(+), 52 deletions(-) diff --git a/src/web/server.py b/src/web/server.py index 7b945659..7fba9513 100755 --- a/src/web/server.py +++ b/src/web/server.py @@ -685,72 +685,70 @@ def post(self): DEFAULT_PAGE_SIZE = 25 -class GetShortHistoryEntriesHandler(BaseRequestHandler): - @check_authorization - @inject_user - def get(self, user): - page_arg = self.get_argument('page', None) - size_arg = self.get_argument('size', None) +def paginate_history_entries(entries, page_arg, size_arg, is_running_checker=None): + def _get_sort_key(entry): + if entry.start_time is None: + return datetime.min.replace(tzinfo=timezone.utc) + return entry.start_time - history_entries = self.application.execution_logging_service.get_history_entries(user.user_id) + entries.sort(key=_get_sort_key, reverse=True) - def _get_sort_key(entry): - if entry.start_time is None: - return datetime.min.replace(tzinfo=timezone.utc) - return entry.start_time + if page_arg is None and size_arg is None: + running_ids = [e.id for e in entries if is_running_checker and is_running_checker(e.id)] + return to_short_execution_log(entries, running_ids) - history_entries.sort(key=_get_sort_key, reverse=True) + try: + size = int(size_arg) if size_arg is not None else DEFAULT_PAGE_SIZE + if size not in ALLOWED_PAGE_SIZES: + size = DEFAULT_PAGE_SIZE + except (ValueError, TypeError): + size = DEFAULT_PAGE_SIZE - if page_arg is None and size_arg is None: - running_script_ids = [] - for entry in history_entries: - if self.application.execution_service.is_running(entry.id, user): - running_script_ids.append(entry.id) + try: + page = int(page_arg) if page_arg is not None else 1 + if page < 1: + page = 1 + except (ValueError, TypeError): + page = 1 - short_logs = to_short_execution_log(history_entries, running_script_ids) - self.write(json.dumps(short_logs)) - return + total_count = len(entries) + total_pages = math.ceil(total_count / size) if total_count > 0 else 1 - try: - size = int(size_arg) if size_arg is not None else DEFAULT_PAGE_SIZE - if size not in ALLOWED_PAGE_SIZES: - size = DEFAULT_PAGE_SIZE - except (ValueError, TypeError): - size = DEFAULT_PAGE_SIZE + if page > total_pages and total_pages > 0: + page = total_pages - try: - page = int(page_arg) if page_arg is not None else 1 - if page < 1: - page = 1 - except (ValueError, TypeError): - page = 1 + start_idx = (page - 1) * size + end_idx = start_idx + size + page_entries = entries[start_idx:end_idx] - total_count = len(history_entries) - total_pages = math.ceil(total_count / size) if total_count > 0 else 1 + running_ids = [e.id for e in page_entries if is_running_checker and is_running_checker(e.id)] - if page > total_pages and total_pages > 0: - page = total_pages + return { + 'records': to_short_execution_log(page_entries, running_ids), + 'total': total_count, + 'page': page, + 'pageSize': size, + 'totalPages': total_pages + } - start_idx = (page - 1) * size - end_idx = start_idx + size - page_entries = history_entries[start_idx:end_idx] - running_script_ids = [] - for entry in page_entries: - if self.application.execution_service.is_running(entry.id, user): - running_script_ids.append(entry.id) +class GetShortHistoryEntriesHandler(BaseRequestHandler): + @check_authorization + @inject_user + def get(self, user): + page_arg = self.get_argument('page', None) + size_arg = self.get_argument('size', None) - short_logs = to_short_execution_log(page_entries, running_script_ids) + history_entries = self.application.execution_logging_service.get_history_entries(user.user_id) - response = { - 'records': short_logs, - 'total': total_count, - 'page': page, - 'pageSize': size, - 'totalPages': total_pages - } + result = paginate_history_entries( + history_entries, + page_arg, + size_arg, + is_running_checker=lambda entry_id: self.application.execution_service.is_running(entry_id, user) + ) - self.write(json.dumps(response)) + self.write(json.dumps(result)) class GetLongHistoryEntryHandler(BaseRequestHandler): From 4b6ca327064aa266bdb0ccdeda37a386cdf1de24 Mon Sep 17 00:00:00 2001 From: cpadlab Date: Fri, 31 Jul 2026 10:36:56 +0200 Subject: [PATCH 04/12] test: Add unit tests for history pagination --- src/tests/web/server_test.py | 45 ++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/tests/web/server_test.py b/src/tests/web/server_test.py index cf26ad3b..45ccc979 100644 --- a/src/tests/web/server_test.py +++ b/src/tests/web/server_test.py @@ -405,6 +405,51 @@ def test_history_short_log_legacy_unpaginated(self): self.assertEqual(len(response), 1) self.assertEqual(response[0]['id'], 'e1') + def test_history_short_log_pagination_page_out_of_bounds(self): + self.start_server(12345, '127.0.0.1') + entries = [self._create_mock_history_entry(f'e{i}', start_time_ms=i * 1000) for i in range(1, 31)] + server._tornado_app.execution_logging_service.get_history_entries.return_value = entries + + response = self.request('GET', 'http://127.0.0.1:12345/history/execution_log/short?page=99&size=10') + self.assertEqual(response['page'], 3) + self.assertEqual(response['totalPages'], 3) + self.assertEqual(len(response['records']), 10) + + def test_history_short_log_pagination_with_none_start_time(self): + self.start_server(12345, '127.0.0.1') + entries = [ + self._create_mock_history_entry('e_none', start_time_ms=None), + self._create_mock_history_entry('e_valid', start_time_ms=5000) + ] + server._tornado_app.execution_logging_service.get_history_entries.return_value = entries + + response = self.request('GET', 'http://127.0.0.1:12345/history/execution_log/short?page=1&size=10') + self.assertEqual([r['id'] for r in response['records']], ['e_valid', 'e_none']) + + def test_history_short_log_pagination_all_supported_sizes(self): + self.start_server(12345, '127.0.0.1') + server._tornado_app.execution_logging_service.get_history_entries.return_value = [] + + for size in [10, 25, 50, 100, 250, 500]: + response = self.request('GET', f'http://127.0.0.1:12345/history/execution_log/short?page=1&size={size}') + self.assertEqual(response['pageSize'], size) + + def test_history_short_log_pagination_running_script_status(self): + self.start_server(12345, '127.0.0.1') + entries = [ + self._create_mock_history_entry('running_1', start_time_ms=2000, exit_code=None), + self._create_mock_history_entry('finished_1', start_time_ms=1000, exit_code=0) + ] + server._tornado_app.execution_logging_service.get_history_entries.return_value = entries + server._tornado_app.execution_service.is_running.side_effect = lambda entry_id, user: entry_id == 'running_1' + + response = self.request('GET', 'http://127.0.0.1:12345/history/execution_log/short?page=1&size=10') + records = response['records'] + self.assertEqual(records[0]['id'], 'running_1') + self.assertEqual(records[0]['status'], 'running') + self.assertEqual(records[1]['id'], 'finished_1') + self.assertEqual(records[1]['status'], 'finished') + def start_loop(self): io_loop = IOLoop.current() self.ioloop_thread = threading.Thread(target=io_loop.start) From 87ed8e069d6835db06cfaeadca1c02f8bd479661 Mon Sep 17 00:00:00 2001 From: cpadlab Date: Fri, 31 Jul 2026 10:44:36 +0200 Subject: [PATCH 05/12] test: Add isolated unit tests for history pagination --- src/tests/web/server_test.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/tests/web/server_test.py b/src/tests/web/server_test.py index 45ccc979..35d4bd3f 100644 --- a/src/tests/web/server_test.py +++ b/src/tests/web/server_test.py @@ -450,6 +450,37 @@ def test_history_short_log_pagination_running_script_status(self): self.assertEqual(records[1]['id'], 'finished_1') self.assertEqual(records[1]['status'], 'finished') + def test_isolated_paginate_empty(self): + result = server.paginate_history_entries([], '1', '25') + self.assertEqual(result, { + 'records': [], + 'total': 0, + 'page': 1, + 'pageSize': 25, + 'totalPages': 1 + }) + + def test_isolated_paginate_legacy_unpaginated_returns_list(self): + entries = [self._create_mock_history_entry('e1', start_time_ms=1000)] + result = server.paginate_history_entries(entries, None, None) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + + def test_isolated_paginate_running_checker_only_called_for_sliced_page(self): + entries = [self._create_mock_history_entry(f'e{i}', start_time_ms=i * 1000) for i in range(1, 100)] + checked_ids = [] + + def mock_checker(entry_id): + checked_ids.append(entry_id) + return entry_id == 'e99' + + result = server.paginate_history_entries(entries, '1', '10', is_running_checker=mock_checker) + self.assertEqual(result['total'], 99) + self.assertEqual(result['page'], 1) + self.assertEqual(len(result['records']), 10) + self.assertEqual(len(checked_ids), 10) + self.assertEqual(checked_ids[0], 'e99') + def start_loop(self): io_loop = IOLoop.current() self.ioloop_thread = threading.Thread(target=io_loop.start) From 5f5393d03594824302096a77fea4a2d18ecf3702 Mon Sep 17 00:00:00 2001 From: cpadlab Date: Fri, 31 Jul 2026 10:47:31 +0200 Subject: [PATCH 06/12] feat: Add pagination state to execution store --- web-src/src/common/store/executions-module.js | 69 +++++++++++++++++-- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/web-src/src/common/store/executions-module.js b/web-src/src/common/store/executions-module.js index e3e57ec5..351d5873 100644 --- a/web-src/src/common/store/executions-module.js +++ b/web-src/src/common/store/executions-module.js @@ -7,23 +7,75 @@ const store = () => ({ selectedExecution: null, selectedExecutionId: null, loading: false, - detailsLoading: false + detailsLoading: false, + page: 1, + pageSize: 25, + total: 0, + totalPages: 1, + pageSizeOptions: [10, 25, 50, 100, 250, 500] }, namespaced: true, actions: { - init({commit}) { + init({dispatch}) { + return dispatch('loadExecutions'); + }, + + loadExecutions({commit, state}, params = {}) { commit('SET_LOADING', true); commit('SET_EXECUTION_DETAILS', {execution: null, id: null}); - axiosInstance.get('history/execution_log/short').then(({data}) => { - sortExecutionLogs(data); + const page = params.page !== undefined ? params.page : state.page; + const size = params.size !== undefined ? params.size : state.pageSize; + + return axiosInstance.get('history/execution_log/short', { + params: { + page, + size + } + }).then(({data}) => { + let executions = []; + let paginationData = {}; + + if (Array.isArray(data)) { + sortExecutionLogs(data); + executions = data.map(log => translateExecutionLog(log)); + paginationData = { + page: 1, + pageSize: executions.length || size, + total: executions.length, + totalPages: 1 + }; + } else if (data && typeof data === 'object') { + let records = data.records || []; + executions = records.map(log => translateExecutionLog(log)); + paginationData = { + page: data.page || page, + pageSize: data.pageSize || size, + total: data.total || 0, + totalPages: data.totalPages || 1 + }; + } - let executions = data.map(log => translateExecutionLog(log)); commit('SET_EXECUTIONS', executions); + commit('SET_PAGINATION', paginationData); + commit('SET_LOADING', false); + }).catch((error) => { commit('SET_LOADING', false); + logError(error); }); }, + changePage({dispatch, state}, newPage) { + if (newPage < 1 || (state.totalPages > 0 && newPage > state.totalPages)) { + return; + } + return dispatch('loadExecutions', {page: newPage, size: state.pageSize}); + }, + + changePageSize({dispatch}, newSize) { + return dispatch('loadExecutions', {page: 1, size: newSize}); + }, + selectExecution({commit, state}, executionId) { if (isEmptyString(executionId)) { commit('SET_EXECUTION_DETAILS', {id: executionId, execution: null}); @@ -65,6 +117,13 @@ const store = () => ({ state.executions = executions; }, + SET_PAGINATION(state, {page, pageSize, total, totalPages}) { + if (page !== undefined) state.page = page; + if (pageSize !== undefined) state.pageSize = pageSize; + if (total !== undefined) state.total = total; + if (totalPages !== undefined) state.totalPages = totalPages; + }, + SET_EXECUTION_DETAILS(state, {execution, id}) { state.selectedExecution = execution; state.selectedExecutionId = id; From 0d3271cfe5958ce685e35188ab5e88fa37326681 Mon Sep 17 00:00:00 2001 From: cpadlab Date: Fri, 31 Jul 2026 10:51:52 +0200 Subject: [PATCH 07/12] feat: Add Paginator component --- .../common/components/history/Paginator.vue | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 web-src/src/common/components/history/Paginator.vue diff --git a/web-src/src/common/components/history/Paginator.vue b/web-src/src/common/components/history/Paginator.vue new file mode 100644 index 00000000..c7c7cafe --- /dev/null +++ b/web-src/src/common/components/history/Paginator.vue @@ -0,0 +1,208 @@ + + + + + From 627dab09add1d4486e3b86f88a570fc595cee31f Mon Sep 17 00:00:00 2001 From: cpadlab Date: Fri, 31 Jul 2026 10:53:01 +0200 Subject: [PATCH 08/12] feat: Integrate Paginator in executions log view --- .../components/history/executions-log.vue | 44 ++++++++++++++++--- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/web-src/src/common/components/history/executions-log.vue b/web-src/src/common/components/history/executions-log.vue index fef05f76..2f0e0ddb 100644 --- a/web-src/src/common/components/history/executions-log.vue +++ b/web-src/src/common/components/history/executions-log.vue @@ -1,14 +1,27 @@