diff --git a/src/imgtests/logger.py b/src/imgtests/logger.py index e37a03a8..7384ca74 100644 --- a/src/imgtests/logger.py +++ b/src/imgtests/logger.py @@ -1,16 +1,31 @@ +import json import logging +import re import sys -from typing import TYPE_CHECKING, Literal, Self, TextIO +from enum import StrEnum +from pathlib import Path +from typing import Literal, Self, TextIO from pythonjsonlogger.json import JsonFormatter -if TYPE_CHECKING: - from pathlib import Path - +from imgtests.constant import LIB_DATA_DIR LogLevel = Literal["debug", "info", "warning", "error", "critical"] +class LoggingPatterns(StrEnum): + TASK_STARTED_STATUS = "RUNNING" + TASK_STATUS = r"Task id=([\w-]+) path=[\w|\.]+ state=(\w+)" + TESTS_COUNT = r"Total amount of tests per run: (\d+)" + RUNS_COUNT = r"Starting test run (\d+) of (\d+)" + DEFAULT_TEST_START = r"Starting '(.*\.)'.*" + DEFAULT_TEST_FINISH = r"'(.*\.)' test finished." + SUITE_START = r"Running suite (.*)\." + PROFILED_TEST_START = r"\[PLAN\] run stage=([\w-]+) tool=([\w-]+) subsystem=([\w-]+).*" + PROFILED_TEST_FINISH = r"\[PLAN\] done .*" + PROFILE_DONE = r"\[PROFILED\] DONE profile=(\w+) pattern=(\w+) .*" + + class StreamFormatter(logging.Formatter): def __init__(self: Self) -> None: self._level_fmt = "[%(levelname)s]" @@ -23,6 +38,99 @@ def format(self: Self, record: logging.LogRecord) -> str: return super().format(record) +class ProgressHandler(logging.Handler): + progress_template = { # noqa: RUF012 + "total_test_count": 0, + "test_count": 0, + "total_run_count": 0, + "current_test_run": 0, + "current_suite": "Not started yet", + "last_profile_done": "Not done yet", + "current_test": "Not started yet", + } + + def __init__(self, level: logging._Level = logging.DEBUG): + super().__init__(level) + self.progress_data = {} + self.proc_to_task = {} + + def emit(self, record: logging.LogRecord): # noqa: PLR0915 + proc = str(record.process) + msg = self.format(record) + + # detect task started or finished + match = re.search(LoggingPatterns.TASK_STATUS, msg) + if match: + task_id = match.group(1) + status = match.group(2) + # task started + if status == LoggingPatterns.TASK_STARTED_STATUS: + self.proc_to_task[proc] = task_id + # task finished or broke + else: + self.proc_to_task[proc] = None + # flush progress_data for process + self.progress_data[proc] = self.progress_template.copy() + + match = re.search(LoggingPatterns.TESTS_COUNT, msg) + if match and self.progress_data[proc]: + total = int(match.group(1)) + self.progress_data[proc]["total_test_count"] = total + + match = re.search(LoggingPatterns.RUNS_COUNT, msg) + if match and self.progress_data[proc]: + # set current run + cur = int(match.group(1)) + total = int(match.group(2)) + self.progress_data[proc]["current_test_run"] = cur + self.progress_data[proc]["total_run_count"] = total + # reset tests count + self.progress_data[proc]["test_count"] = 0 + + # default runner matches + match = re.search(LoggingPatterns.SUITE_START, msg) + if match and self.progress_data[proc]: + suite = match.group(1) + self.progress_data[proc]["current_suite"] = suite + + match = re.search(LoggingPatterns.DEFAULT_TEST_START, msg) + if match and self.progress_data[proc]: + test = match.group(1) + self.progress_data[proc]["current_test"] = test + + match = re.search(LoggingPatterns.DEFAULT_TEST_FINISH, msg) + if match and self.progress_data[proc]: + self.progress_data[proc]["test_count"] += 1 + self.progress_data[proc]["current_test"] = "Not started yet" + + # profiled runner matches + match = re.search(LoggingPatterns.PROFILE_DONE, msg) + if match and self.progress_data[proc]: + profile = "-".join([match.group(1), match.group(2)]) + self.progress_data[proc]["last_profile_done"] = profile + + match = re.search(LoggingPatterns.PROFILED_TEST_START, msg) + if match and self.progress_data[proc]: + subsystem = match.group(3) + profile = match.group(1) + tool = match.group(2) + self.progress_data[proc]["current_test"] = f"{subsystem}-{profile} via {tool}" + + match = re.search(LoggingPatterns.PROFILED_TEST_FINISH, msg) + if match and self.progress_data[proc]: + self.progress_data[proc]["test_count"] += 1 + self.progress_data[proc]["current_test"] = "Not started yet" + + if proc in self.proc_to_task and self.proc_to_task[proc] is not None: + task_id = self.proc_to_task[proc] + with Path.open( + LIB_DATA_DIR / (task_id + "_progress.log"), + "w", + encoding="utf-8", + ) as file: + json.dump(self.progress_data[proc], file, indent=4) + + def set_handlers( logger: logging.Logger, filename: Path, @@ -45,6 +153,7 @@ def set_handlers( """ levelno = getattr(logging, log_level.upper()) logger.setLevel(levelno) + logger.addHandler(__get_progress_handler()) logger.addHandler(__get_file_handler(filename)) if levelno in [logging.INFO, logging.DEBUG]: logger.addHandler(__get_stdout_handler()) @@ -82,3 +191,12 @@ def filter(self: Self, record: logging.LogRecord) -> bool: stdout_handler.setFormatter(StreamFormatter()) return stdout_handler + + +def __get_progress_handler() -> ProgressHandler: + progress_handle = ProgressHandler() + progress_handle.setLevel(logging.DEBUG) + progress_handle.setFormatter(StreamFormatter()) + progress_handle.set_name("progress_handler") + + return progress_handle diff --git a/src/imgtests/runner.py b/src/imgtests/runner.py index 7e6b0893..29a6cf99 100644 --- a/src/imgtests/runner.py +++ b/src/imgtests/runner.py @@ -647,6 +647,11 @@ def run_tests( ) -> None: if mode == "default" and config is None: config = load_test_config(distro) + + total_tests_amount = __calc_total_tests_amount(config, mode) + logger.info("Total amount of tests per run: %d", total_tests_amount) + + # start test runs for i in range(test_runs_count): logger.info("Starting test run %d of %d", i + 1, test_runs_count) _run_single(distro, mode, config) @@ -661,3 +666,26 @@ def get_test_name( if hasattr(test, "__class__"): return test.__class__.__name__ return str(test) + + +def __calc_total_tests_amount(config: dict[str, Any] | None, mode: Runner) -> int: + from imgtests.suites.map import ALL_SUITES # noqa: PLC0415 + + total_tests_amount = 0 + if mode == "default" and config: + for suite in config["suites"]: + if suite in config["selected_tests"]: + total_tests_amount += len(config["selected_tests"][suite]) + else: + total_tests_amount += len(ALL_SUITES[suite].tests) + # default runner runs 2 system tests for each suite (runner.py: 616 -> 621 -> 149) + total_tests_amount += 2 + if mode == "profiled": + tmp_config = build_profiled_settings(config=config) + total_tests_amount = len(tmp_config.subsystems) + # default profile config consists of 3 stages + if config is None: + total_tests_amount *= 3 + if tmp_config.run_matrix: + total_tests_amount *= len(tmp_config.matrix_profiles) + return total_tests_amount diff --git a/src/imgtests/web/static/css/style.css b/src/imgtests/web/static/css/style.css index 85d91a1c..2389fddf 100644 --- a/src/imgtests/web/static/css/style.css +++ b/src/imgtests/web/static/css/style.css @@ -403,3 +403,84 @@ h1 i { color: rgba(255, 255, 255, 0.9); margin: 0; } + +.progress-card { + background: white; + margin-bottom: 20px; + padding: 30px; + width: 100%; + border-radius: 12px; + border: 1px solid #ddd; +} + +.progress-group { + margin-bottom: 20px; +} + +.progress-label { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + font-size: 14px; + font-weight: 600; + color: #7f8c8d; +} + +.progress-bg { + background-color: #e0e0e0; + border-radius: 8px; + overflow: hidden; + height: 16px; + width: 100%; +} + +.progress-bar { + background-color: #2ecc71; + height: 100%; + width: 0%; + transition: width 0.4s ease-out; +} + +#runs-bar { + background-color: #3498db; +} + +@keyframes pulse-animation { + 0% { opacity: 1; } + 50% { opacity: 0.4; } + 100% { opacity: 1; } +} + +.pulse { + animation: pulse-animation 1.5s infinite ease-in-out; + background-image: linear-gradient(45deg, rgba(255,255,255,.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,.15) 50%, rgba(255,255,255,.15) 75%, transparent 75%, transparent); + background-size: 1rem 1rem; +} + +.info-section { + margin-top: 25px; + border-top: 1px solid #ecf0f1; + padding-top: 15px; +} + +.info-item { + margin-bottom: 10px; + font-size: 14px; +} + +.info-item strong { + color: #34495e; +} + +.info-item span { + color: #16a085; + font-weight: 500; +} + +.status-error { + color: #e74c3c; + text-align: center; + font-size: 12px; + margin-top: 10px; + display: none; +} diff --git a/src/imgtests/web/static/js/launch_test.js b/src/imgtests/web/static/js/launch_test.js index a1fc3161..ac593639 100644 --- a/src/imgtests/web/static/js/launch_test.js +++ b/src/imgtests/web/static/js/launch_test.js @@ -89,8 +89,7 @@ document.getElementById("runTestsBtn").addEventListener("click", function () { runner: runner, config: config, }), - }) - .then((response) => response.json()) + }).then((response) => response.json()) .then((data) => { if (data.success && data.task_id) { outputContainer.textContent = interpolate( @@ -99,6 +98,16 @@ document.getElementById("runTestsBtn").addEventListener("click", function () { true ); pollStatus(data.task_id); + // add progress display + document.getElementById("progress-card").style.display = "block"; + // hide suite | profile depending on selected mode + if (runner === "profiled") { + document.getElementById("current-suite-div").style.display = "none"; + document.getElementById("last-profile-div").style.display = "inline"; + } else { + document.getElementById("current-suite-div").style.display = "inline"; + document.getElementById("last-profile-div").style.display = "none"; + } } else { outputContainer.textContent = interpolate( gettext("Error: %(error)s"), @@ -107,6 +116,7 @@ document.getElementById("runTestsBtn").addEventListener("click", function () { ); btn.disabled = false; btn.textContent = gettext("Run tests"); + document.getElementById("progress-card").style.display = "none"; } }) .catch((error) => { @@ -117,6 +127,7 @@ document.getElementById("runTestsBtn").addEventListener("click", function () { ); btn.disabled = false; btn.textContent = gettext("Run tests"); + document.getElementById("progress-card").style.display = "none"; }); }); @@ -132,11 +143,13 @@ function pollStatus(taskId) { outputContainer.textContent = gettext("Tests running... Please wait."); setTimeout(checkStatus, 2000); + updateDashboard(taskId); } else if (data.status === "completed") { outputContainer.textContent = data.output || gettext("Tests completed successfully."); btn.disabled = false; btn.textContent = gettext("Run tests"); + document.getElementById("progress-card").style.display = "none"; } else if (data.status === "failed") { let errorMsg = data.error || gettext("Test failed"); if (data.stderr) { @@ -148,10 +161,12 @@ function pollStatus(taskId) { outputContainer.textContent = errorMsg; btn.disabled = false; btn.textContent = gettext("Run tests"); + document.getElementById("progress-card").style.display = "none"; } else { outputContainer.textContent = gettext("Unknown status"); btn.disabled = false; btn.textContent = gettext("Run tests"); + document.getElementById("progress-card").style.display = "none"; } }) .catch((error) => { @@ -162,8 +177,57 @@ function pollStatus(taskId) { ); btn.disabled = false; btn.textContent = gettext("Run tests"); + document.getElementById("progress-card").style.display = "none"; }); }; checkStatus(); } + +const PRGORESS_POLLING_INTERVAL = 3000; + +function updateDashboard(taskId) { + fetch("/current-progress/" + taskId + "/", { cache: "no-store" }) + .then(response => { + if (!response.ok) throw new Error('Data load error'); + return response.json(); + }) + .then(data => { + document.getElementById('error-msg').style.display = 'none'; + + const totalTests = data.total_test_count || 0; + const currentTests = data.test_count || 0; + const testsPercent = totalTests > 0 ? Math.min(Math.round((currentTests / totalTests) * 100), 100) : 0; + + document.getElementById('tests-text').textContent = `${currentTests} / ${totalTests} (${testsPercent}%)`; + document.getElementById('tests-bar').style.width = `${testsPercent}%`; + + + const totalRuns = data.total_run_count || 0; + const currentRun = data.current_test_run || 0; + const runsPercent = totalRuns > 0 ? Math.min(Math.round((currentRun / totalRuns) * 100), 100) : 0; + + const runsBar = document.getElementById('runs-bar'); + const runsText = document.getElementById('runs-text'); + + runsBar.style.width = `${runsPercent}%`; + + if (currentRun > 0 && currentRun <= totalRuns) { + runsBar.classList.add('pulse'); + runsText.textContent = `Run ${currentRun} out of ${totalRuns} is in progress (${runsPercent}%)`; + runsText.style.color = '#3498db'; + } else { + runsBar.classList.remove('pulse'); + runsText.textContent = `${currentRun} / ${totalRuns} (${runsPercent}%)`; + runsText.style.color = '#7f8c8d'; + } + + document.getElementById('current-suite').textContent = data.current_suite; + document.getElementById('current-test').textContent = data.current_test; + document.getElementById('last-profile').textContent = data.last_profile_done; + }) + .catch(error => { + console.error('JSON processing error:', error); + document.getElementById('error-msg').style.display = 'block'; + }); +} diff --git a/src/imgtests/web/tests_interface/templates/tests_interface/distro_page.html b/src/imgtests/web/tests_interface/templates/tests_interface/distro_page.html index 3af981f0..e241a5ed 100644 --- a/src/imgtests/web/tests_interface/templates/tests_interface/distro_page.html +++ b/src/imgtests/web/tests_interface/templates/tests_interface/distro_page.html @@ -131,6 +131,39 @@

{% translate "Subsystems" %}

+ +
/", views.get_run_progress, name="current_progress"), path("test-status//", views.get_test_status, name="test_status"), path("reports/", views.report_list, name="report_list"), re_path( diff --git a/src/imgtests/web/tests_interface/views.py b/src/imgtests/web/tests_interface/views.py index c0b35e21..4e47fd95 100644 --- a/src/imgtests/web/tests_interface/views.py +++ b/src/imgtests/web/tests_interface/views.py @@ -16,7 +16,13 @@ from sqlalchemy import Engine, create_engine, select from sqlalchemy.orm import Session -from imgtests.constant import CONFIG_DIR, DISTRIBUTION_DESCRIPTIONS, EXCEL_REPORTS_DIR, REPORTS_DIR +from imgtests.constant import ( + CONFIG_DIR, + DISTRIBUTION_DESCRIPTIONS, + EXCEL_REPORTS_DIR, + LIB_DATA_DIR, + REPORTS_DIR, +) from imgtests.database.database import ImgtestsDatabase, PostgresCreds from imgtests.database.models.configuration import ConfigurationBase from imgtests.reporting.cli import EXPORT_TABLES @@ -234,6 +240,22 @@ def run_tests(request: HttpRequest) -> JsonResponse: return JsonResponse({"success": True, "task_id": task_id, "status": "running"}) +def get_run_progress(request: HttpRequest, task_id: str) -> JsonResponse: # noqa: ARG001 + data = {} + progress_file = LIB_DATA_DIR / (task_id + "_progress.log") + if progress_file.exists(): + with progress_file.open(encoding="utf-8") as file: + try: + data = json.load(file) + except json.decoder.JSONDecodeError as err: + logger.error( # noqa: TRY400 + "Error loading JSON from progress file '%s'. Error: %s.", + progress_file, + err, + ) + return JsonResponse(data) + + def get_test_status(request: HttpRequest, task_id: str) -> JsonResponse: # noqa: ARG001 if task_id not in test_runs: return JsonResponse({"error": "Task not found"}, status=404)