diff --git a/src/imgtests/constant.py b/src/imgtests/constant.py index 371ea115..15961b53 100644 --- a/src/imgtests/constant.py +++ b/src/imgtests/constant.py @@ -9,6 +9,7 @@ # Paths LIB_DATA_DIR: Final = Path.home() / LIB_NAME LOG_PATH: Final = LIB_DATA_DIR / "processing.log" +PROG_LOG_PATH: Final = LIB_DATA_DIR / "test_progress.log" CONFIG_DIR: Final = LIB_DATA_DIR / "test_configs" CONFIG_FILE: Final = LIB_DATA_DIR / "user_distros.json" REPORTS_DIR: Final = LIB_DATA_DIR / "results" diff --git a/src/imgtests/logger.py b/src/imgtests/logger.py index e37a03a8..73b5b6df 100644 --- a/src/imgtests/logger.py +++ b/src/imgtests/logger.py @@ -1,12 +1,13 @@ +import json import logging +import re import sys -from typing import TYPE_CHECKING, Literal, Self, TextIO +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 PROG_LOG_PATH LogLevel = Literal["debug", "info", "warning", "error", "critical"] @@ -23,6 +24,91 @@ def format(self: Self, record: logging.LogRecord) -> str: return super().format(record) +class ProgressHandle(logging.Handler): + tests_count_pattern = r"Total amount of tests per run: (\d+)" + test_runs_pattern = r"Starting test run (\d+) of (\d+)" + default_test_start_pattern = r"Starting '(.*\.)'.*" + default_test_finish_pattern = r"'(.*\.)' test finished." + suite_start_pattern = r"Running suite (.*)\." + profiled_test_start_pattern = r"\[PLAN\] run stage=([\w-]+) tool=([\w-]+) subsystem=([\w-]+).*" + profiled_test_finish_pattern = r"\[PLAN\] done .*" + profile_done_pattern = r"\[PROFILED\] DONE profile=(\w+) pattern=(\w+) .*" + + def __init__(self, level: logging._Level = logging.DEBUG): + super().__init__(level) + self.progress_data = {} + self.flush() + + def flush(self): + self.progress_data = { + "total_test_count": 0, + "test_count": 0, + "total_run_count": 0, + "current_test_run": 0, + "current_suite": "Not starter yet", + "last_profile_done": "Not done yet", + "current_test": "Not starter yet", + } + + def get_progress_data(self): + return self.progress_data + + def emit(self, record: logging.LogRecord): + msg = self.format(record) + + match = re.search(self.tests_count_pattern, msg) + if match: + total = int(match.group(1)) + self.progress_data["total_test_count"] = total + + match = re.search(self.test_runs_pattern, msg) + if match: + # set current run + cur = int(match.group(1)) + total = int(match.group(2)) + self.progress_data["current_test_run"] = cur + self.progress_data["total_run_count"] = total + # reset tests count + self.progress_data["test_count"] = 0 + + # default runner matches + match = re.search(self.suite_start_pattern, msg) + if match: + suite = match.group(1) + self.progress_data["current_suite"] = suite + + match = re.search(self.default_test_start_pattern, msg) + if match: + test = match.group(1) + self.progress_data["current_test"] = test + + match = re.search(self.default_test_finish_pattern, msg) + if match: + self.progress_data["test_count"] += 1 + self.progress_data["current_test"] = "Not started yet" + + # profiled runner matches + match = re.search(self.profile_done_pattern, msg) + if match: + profile = "-".join([match.group(1), match.group(2)]) + self.progress_data["last_profile_done"] = profile + + match = re.search(self.profiled_test_start_pattern, msg) + if match: + subsystem = match.group(3) + profile = match.group(1) + tool = match.group(2) + self.progress_data["current_test"] = f"{subsystem}-{profile} via {tool}" + + match = re.search(self.profiled_test_finish_pattern, msg) + if match: + self.progress_data["test_count"] += 1 + self.progress_data["current_test"] = "Not started yet" + + with Path.open(PROG_LOG_PATH, "w", encoding="utf-8") as file: + json.dump(self.progress_data, file, indent=4) + + def set_handlers( logger: logging.Logger, filename: Path, @@ -45,6 +131,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 +169,12 @@ def filter(self: Self, record: logging.LogRecord) -> bool: stdout_handler.setFormatter(StreamFormatter()) return stdout_handler + + +def __get_progress_handler() -> ProgressHandle: + progress_handle = ProgressHandle() + 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 c84d9a53..f6c5b2cf 100644 --- a/src/imgtests/runner.py +++ b/src/imgtests/runner.py @@ -637,8 +637,29 @@ def run_tests( test_runs_count: int = 1, config: dict[str, Any] | None = None, ) -> None: + from imgtests.suites.map import ALL_SUITES # noqa: PLC0415 + if mode == "default" and config is None: config = load_test_config(distro) + + # log tests amount for web ui progress card + 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) + if tmp_config.run_matrix: + total_tests_amount *= len(tmp_config.matrix_profiles) + 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) 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 1d1ddfc9..d285bcf6 100644 --- a/src/imgtests/web/static/js/launch_test.js +++ b/src/imgtests/web/static/js/launch_test.js @@ -78,6 +78,8 @@ document.getElementById("runTestsBtn").addEventListener("click", function () { btn.textContent = "Running..."; outputContainer.textContent = "Tests Running..."; + // flush progress handler the run tests + fetch("/flush-progress/").then(() => { fetch("/run-tests/", { method: "POST", headers: { @@ -89,25 +91,37 @@ document.getElementById("runTestsBtn").addEventListener("click", function () { testing_mode: testing_mode, config: config, }), - }) - .then((response) => response.json()) + }).then((response) => response.json()) .then((data) => { if (data.success && data.task_id) { outputContainer.textContent = "Tests running... (Task ID: " + data.task_id + ")"; pollStatus(data.task_id); + // add progress display + document.getElementById("progress-card").style.display = "block"; + // hide suite | profile depending on selected mode + if (testing_mode === "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 = "Error: " + (data.error || "Failed to start tests"); btn.disabled = false; btn.textContent = "Run tests"; + document.getElementById("progress-card").style.display = "none"; } }) .catch((error) => { outputContainer.textContent = "Error: " + error; btn.disabled = false; btn.textContent = "Run tests"; + document.getElementById("progress-card").style.display = "none"; }); + }); }); function pollStatus(taskId) { @@ -127,6 +141,7 @@ function pollStatus(taskId) { data.output || "Tests completed successfully."; btn.disabled = false; btn.textContent = "Run tests"; + document.getElementById("progress-card").style.display = "none"; } else if (data.status === "failed") { let errorMsg = data.error || "Test failed"; if (data.stderr) { @@ -138,18 +153,72 @@ function pollStatus(taskId) { outputContainer.textContent = errorMsg; btn.disabled = false; btn.textContent = "Run tests"; + document.getElementById("progress-card").style.display = "none"; } else { outputContainer.textContent = "Unknown status"; btn.disabled = false; btn.textContent = "Run tests"; + document.getElementById("progress-card").style.display = "none"; } }) .catch((error) => { outputContainer.textContent = "Error checking status: " + error; btn.disabled = false; btn.textContent = "Run tests"; + document.getElementById("progress-card").style.display = "none"; }); }; checkStatus(); } + +const PRGORESS_POLLING_INTERVAL = 3000; + +function updateDashboard() { + fetch("/current-progress/", { cache: "no-store" }) + .then(response => { + if (!response.ok) throw new 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'; + }); +} + +updateDashboard(); +let interval = setInterval(updateDashboard, PRGORESS_POLLING_INTERVAL); 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 aab7b927..9bc02f8f 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 @@

Subsystems

+ +
/", 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 1e86b445..c62cfa4b 100644 --- a/src/imgtests/web/tests_interface/views.py +++ b/src/imgtests/web/tests_interface/views.py @@ -15,7 +15,7 @@ from pydantic_core._pydantic_core import ValidationError from sqlalchemy import create_engine -from imgtests.constant import CONFIG_DIR, EXCEL_REPORTS_DIR, REPORTS_DIR +from imgtests.constant import CONFIG_DIR, EXCEL_REPORTS_DIR, PROG_LOG_PATH, REPORTS_DIR from imgtests.database.database import ImgtestsDatabase, PostgresCreds from imgtests.reporting.excel_export import export_database_to_excel from imgtests.reporting.html_report import ReportGenerator @@ -231,6 +231,26 @@ def run_tests(request: HttpRequest) -> JsonResponse: return JsonResponse({"success": True, "task_id": task_id, "status": "running"}) +def get_run_progress(request: HttpRequest) -> JsonResponse: # noqa: ARG001 + progress_file = Path(PROG_LOG_PATH) + if progress_file.exists(): + try: + with Path.open(PROG_LOG_PATH, encoding="utf-8") as file: + data = json.load(file) + return JsonResponse(data) + except json.decoder.JSONDecodeError: + return JsonResponse({}) + return JsonResponse({}) + + +def flush_progress_handle(request: HttpRequest) -> JsonResponse: # noqa: ARG001 + for handle in logging.getLogger().handlers: + if handle.name == "progress_handler": + handle.flush() + return JsonResponse({"status": "success"}) + return JsonResponse({"status": "failure"}) + + 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)