Coverage for main/ui/pipeline_execution_page.py: 89%
333 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-03 15:51 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-03 15:51 +0000
1import os
2import json
3import re
4import sys
6from PyQt6.QtGui import QFont
7from PyQt6.QtWidgets import (
8 QWidget, QVBoxLayout, QLabel, QPushButton,
9 QTextEdit, QFrame, QHBoxLayout, QScrollArea, QGridLayout,
10 QMessageBox
11)
12from PyQt6.QtCore import Qt, QTimer, QProcess, QCoreApplication
14from components.circular_progress_bar import CircularProgress
15from components.folder_card import FolderCard
16from utils import get_bin_path
17from page import Page
18from logger import get_logger
20log = get_logger()
23class PipelineExecutionPage(Page):
24 """
25 GUI page that manages the **execution of a processing pipeline**.
27 This class runs an external pipeline process, tracks its progress,
28 captures log output in real time, and provides user controls
29 (e.g., stop execution, monitor folders, show progress).
31 It extends :class:`Page` and is typically part of a multi-page
32 PyQt-based GUI workflow.
33 """
35 # ─────────────────────────────────────────────
36 # INITIALIZATION
37 # ─────────────────────────────────────────────
38 def __init__(self, context=None, previous_page=None):
39 """
40 Initialize the pipeline execution page.
42 Args:
43 context (dict): Application context (e.g., workspace path, signals).
44 previous_page (Page): Reference to the previous page in the workflow.
45 """
46 super().__init__()
47 self.context = context
48 self.workspace_path = context["workspace_path"]
49 self.previous_page = previous_page
50 self.next_page = None
52 # Pipeline state
53 self.pipeline_process = None
54 self.pipeline_completed = False
55 self.pipeline_error = None
57 # Load the most recent pipeline configuration file
58 self.config_path = self._find_latest_config()
60 # Extract pipeline ID and output directory
61 config_filename = os.path.basename(self.config_path)
62 try:
63 config_id = config_filename.split('_')[0]
64 self.pipeline_output_dir = os.path.join(
65 self.workspace_path, "pipeline", f"{config_id}_output"
66 )
67 os.makedirs(self.pipeline_output_dir, exist_ok=True)
68 except (IndexError, ValueError):
69 log.debug("Failed to create pipeline_output_dir")
70 self.pipeline_output_dir = os.path.join(self.workspace_path, "pipeline")
72 # Resolve path to pipeline executable
73 try:
74 self.pipeline_bin_path = get_bin_path("pipeline_runner")
75 except FileNotFoundError:
76 log.error("Pipeline runner binary not found")
77 raise RuntimeError("Pipeline runner executable missing.")
79 log.debug(f"Pipeline binary path: {self.pipeline_bin_path}")
81 self.folder_cards = {}
83 # Build UI
84 self._setup_ui()
85 self._translate_ui()
87 # Connect translation handler if needed
88 if context and "language_changed" in context:
89 context["language_changed"].connect(self._translate_ui)
91 # ─────────────────────────────────────────────
92 # CONFIGURATION FILE HANDLING
93 # ─────────────────────────────────────────────
94 def _find_latest_config(self):
95 """Find the config file with the highest numeric ID in the pipeline directory."""
96 import glob
97 pipeline_dir = os.path.join(self.workspace_path, "pipeline")
99 if not os.path.exists(pipeline_dir):
100 return os.path.join(pipeline_dir, "pipeline_config.json")
102 config_pattern = os.path.join(pipeline_dir, "*_config.json")
103 config_files = glob.glob(config_pattern)
105 if not config_files:
106 return os.path.join(pipeline_dir, "pipeline_config.json")
108 max_id = 0
109 latest_config = None
111 # Parse filenames to detect the latest config ID
112 for config_file in config_files:
113 filename = os.path.basename(config_file)
114 try:
115 config_id = int(filename.split('_')[0])
116 if config_id > max_id:
117 max_id = config_id
118 latest_config = config_file
119 except (ValueError, IndexError):
120 continue
122 return latest_config or os.path.join(pipeline_dir, "pipeline_config.json")
124 # ─────────────────────────────────────────────
125 # UI SETUP
126 # ─────────────────────────────────────────────
127 def _setup_ui(self):
128 """Constructs the page layout and visual elements."""
129 main_layout = QVBoxLayout(self)
131 # Header
132 self.header = QLabel("Pipeline Execution")
133 self.header.setFont(QFont("Arial", 18, QFont.Weight.Bold))
134 self.header.setAlignment(Qt.AlignmentFlag.AlignCenter)
135 main_layout.addWidget(self.header)
137 # Subheader: current operation label
138 self.current_operation = QLabel(
139 QCoreApplication.translate("PipelineExecutionPage", "Preparing to start...")
140 )
141 self.current_operation.setStyleSheet("""
142 font-size: 13px;
143 color: #7f8c8d;
144 margin-top: 8px;
145 """)
146 self.current_operation.setAlignment(Qt.AlignmentFlag.AlignCenter)
147 main_layout.addWidget(self.current_operation)
149 # Content grid (progress bar + folders + logs)
150 content_layout = QGridLayout()
151 main_layout.addLayout(content_layout, stretch=1)
153 # Left: progress indicator
154 left_layout = QVBoxLayout()
155 self.progress_bar = CircularProgress()
156 left_layout.addWidget(self.progress_bar, alignment=Qt.AlignmentFlag.AlignCenter)
157 content_layout.addLayout(left_layout, 0, 0)
159 # Right: folder cards (output directories)
160 scroll_area = QScrollArea()
161 scroll_area.setWidgetResizable(True)
162 scroll_content = QWidget()
163 self.scroll_layout = QVBoxLayout(scroll_content)
164 scroll_area.setWidget(scroll_content)
165 content_layout.addWidget(scroll_area, 0, 1)
167 # Grid layout proportions
168 content_layout.setColumnStretch(0, 1)
169 content_layout.setColumnStretch(1, 2)
170 content_layout.setRowStretch(0, 1)
171 content_layout.setRowStretch(1, 0)
172 content_layout.setRowStretch(2, 1)
174 # Log section
175 self.log_label = QLabel("Execution Log:")
176 self.log_label.setStyleSheet("""
177 font-size: 16px;
178 font-weight: bold;
179 color: #2c3e50;
180 margin-top: 15px;
181 margin-bottom: 5px;
182 """)
183 content_layout.addWidget(self.log_label, 1, 0, 1, 2)
185 # Log text box
186 self.log_text = QTextEdit()
187 self.log_text.setStyleSheet("""
188 QTextEdit {
189 background-color: #2c3e50;
190 color: #ecf0f1;
191 font-family: 'Courier New', monospace;
192 font-size: 11px;
193 border: 2px solid #34495e;
194 border-radius: 8px;
195 padding: 8px;
196 }
197 """)
198 self.log_text.setMaximumHeight(200)
199 self.log_text.setReadOnly(True)
200 content_layout.addWidget(self.log_text, 2, 0, 1, 2)
202 # Stop button
203 button_frame = QFrame()
204 button_layout = QHBoxLayout(button_frame)
205 button_layout.setContentsMargins(0, 15, 0, 0)
207 self.stop_button = QPushButton("Stop Pipeline")
208 self.stop_button.setStyleSheet("""
209 QPushButton {
210 font-size: 14px;
211 font-weight: bold;
212 background-color: #e74c3c;
213 color: white;
214 border-radius: 8px;
215 padding: 10px 20px;
216 min-width: 140px;
217 }
218 QPushButton:hover { background-color: #c0392b; }
219 QPushButton:disabled {
220 background-color: #bdc3c7;
221 color: #7f8c8d;
222 }
223 """)
224 self.stop_button.clicked.connect(self._on_stop_clicked)
225 button_layout.addStretch()
226 button_layout.addWidget(self.stop_button)
227 button_layout.addStretch()
229 main_layout.addWidget(button_frame)
231 def _setup_folder_cards(self):
232 # Load folder cards dynamically from configuration
233 self.watch_dirs = self.get_sub_list(self.config_path)
234 self.watch_dirs = [os.path.join(self.pipeline_output_dir, d) for d in self.watch_dirs]
235 self.dir_completed = 0
236 for d in self.watch_dirs:
237 card = FolderCard(self.context, d)
238 card.open_folder_requested.connect(self.context["tree_view"]._open_in_explorer)
239 self.scroll_layout.addWidget(card)
240 self.folder_cards[d] = card
242 # ─────────────────────────────────────────────
243 # PIPELINE PROCESS HANDLING
244 # ─────────────────────────────────────────────
245 def _start_pipeline(self):
246 """Start the external pipeline process and monitor its output."""
247 if self.pipeline_process:
248 return # Already running
250 self.pipeline_process = QProcess()
251 self.pipeline_process.finished.connect(self._on_process_finished)
252 self.pipeline_process.errorOccurred.connect(self._on_process_error)
253 self.pipeline_process.readyReadStandardOutput.connect(self._on_stdout_ready)
254 self.pipeline_process.readyReadStandardError.connect(self._on_stderr_ready)
256 self.progress_bar.setValue(0)
257 self.stop_button.setEnabled(True)
258 self._log_message(QCoreApplication.translate("PipelineExecutionPage", "Starting pipeline execution..."))
260 cmd = [
261 self.pipeline_bin_path,
262 "--config", self.config_path,
263 "--work-dir", self.workspace_path,
264 "--out-dir", self.pipeline_output_dir
265 ]
266 log.debug(f"{self.pipeline_bin_path} --config {self.config_path} --work-dir {self.workspace_path} --out-dir {self.pipeline_output_dir}")
268 self.pipeline_process.start(cmd[0], cmd[1:])
270 if not self.pipeline_process.waitForStarted(3000):
271 self._log_message(
272 QCoreApplication.translate("PipelineExecutionPage", "ERROR: Failed to start pipeline process"))
273 self._on_pipeline_error(
274 QCoreApplication.translate("PipelineExecutionPage", "Failed to start pipeline process"))
276 # ─────────────────────────────────────────────
277 # PROCESS SIGNAL HANDLERS
278 # ─────────────────────────────────────────────
279 def _on_process_finished(self, exit_code, exit_status):
280 """Called when the process terminates."""
281 if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
282 self._on_pipeline_finished()
283 else:
284 self._on_pipeline_error(
285 QCoreApplication.translate(
286 "PipelineExecutionPage",
287 "Process exited with code {exit_code}"
288 ).format(exit_code=exit_code)
289 )
291 def _on_process_error(self, error):
292 """Called when an error occurs in the process."""
293 error_messages = {
294 QProcess.ProcessError.FailedToStart: QCoreApplication.translate("PipelineExecutionPage",
295 "Failed to start process"),
296 QProcess.ProcessError.Crashed: QCoreApplication.translate("PipelineExecutionPage", "Process crashed"),
297 QProcess.ProcessError.Timedout: QCoreApplication.translate("PipelineExecutionPage", "Process timed out"),
298 QProcess.ProcessError.WriteError: QCoreApplication.translate("PipelineExecutionPage", "Write error"),
299 QProcess.ProcessError.ReadError: QCoreApplication.translate("PipelineExecutionPage", "Read error"),
300 QProcess.ProcessError.UnknownError: QCoreApplication.translate("PipelineExecutionPage", "Unknown error")
301 }
302 error_msg = error_messages.get(
303 error,
304 QCoreApplication.translate("PipelineExecutionPage",
305 "Process error: {error}").format(error=error)
306 )
307 self._on_pipeline_error(error_msg)
309 # ─────────────────────────────────────────────
310 # STDOUT / STDERR MONITORING
311 # ─────────────────────────────────────────────
312 def _on_stdout_ready(self):
313 """Called when data is available on stdout."""
314 if self.pipeline_process:
315 data = self.pipeline_process.readAllStandardOutput()
316 output = data.data().decode('utf-8').strip()
318 for line in output.split('\n'):
319 if line.strip():
320 self._process_pipeline_output(line.strip())
322 def _on_stderr_ready(self):
323 """Called when data is available on stderr."""
324 if self.pipeline_process:
325 data = self.pipeline_process.readAllStandardError()
326 error_output = data.data().decode('utf-8').strip()
328 for line in error_output.split('\n'):
329 if line.strip():
330 self._log_message(f"STDERR: {line.strip()}")
331 # ─────────────────────────────────────────────
332 # OUTPUT INTERPRETATION
333 # ─────────────────────────────────────────────
334 def _process_pipeline_output(self, line):
335 """Process the pipeline output logs"""
336 if line.startswith("LOG: "):
337 message = line[5:]
338 self._log_message(message)
339 self._update_current_operation(message)
340 elif line.startswith("ERROR: "):
341 error_msg = line[7:]
342 self._log_message(
343 QCoreApplication.translate("PipelineExecutionPage", "ERROR: {error}").format(error=error_msg))
344 elif line.startswith("PROGRESS: "):
345 progress_info = line[10:]
346 self._update_progress(progress_info)
347 elif line.startswith("PATIENT: "):
348 patient_info = line[9:]
349 self._log_message(
350 QCoreApplication.translate("PipelineExecutionPage", "Pipeline finished for patient: {message}").format(message=patient_info))
351 self._update_progress(patient_info)
352 elif line.startswith("FINISHED: "):
353 message = line[10:]
354 self._log_message(QCoreApplication.translate("PipelineExecutionPage", "FINISHED for: {message}").format(message=message))
355 else:
356 self._log_message(line)
358 # ─────────────────────────────────────────────
359 # STATE MANAGEMENT
360 # ─────────────────────────────────────────────
361 def _on_pipeline_finished(self):
362 """Called when the pipeline finishes successfully."""
363 self.pipeline_completed = True
364 self.current_operation.setText(QCoreApplication.translate("PipelineExecutionPage", "All patients processed successfully."))
365 self.progress_bar.setValue(100)
366 self.stop_button.setEnabled(False)
367 self._log_message(
368 QCoreApplication.translate("PipelineExecutionPage", "Pipeline execution completed successfully!"))
369 self._log_message(
370 QCoreApplication.translate("PipelineExecutionPage", "Results saved in: {pipeline_output_dir}").format(
371 pipeline_output_dir=self.pipeline_output_dir))
372 self.pipeline_process = None
373 for card in self.folder_cards.values():
374 card.set_finished_state()
375 self.context["update_main_buttons"]()
377 def _on_pipeline_error(self, error_message):
378 """Called when the pipeline terminates due to an error."""
379 self.pipeline_error = error_message
380 self.current_operation.setText(
381 QCoreApplication.translate("PipelineExecutionPage", "An error occurred during execution."))
382 self.current_operation.setStyleSheet("""
383 color: #c0392b;
384 font-weight: bold;
385 """)
386 self.progress_bar.setColor("#c0392b")
387 self.stop_button.setEnabled(False)
388 self._log_message(QCoreApplication.translate("PipelineExecutionPage", "ERROR: {error_message}").format(error_message=error_message))
389 self.pipeline_process = None
390 self.context["update_main_buttons"]()
392 # ─────────────────────────────────────────────
393 # PROGRESS AND UI UPDATES
394 # ─────────────────────────────────────────────
395 def _update_progress(self, progress_info):
396 """
397 Update the circular progress bar based on progress information.
399 Expected format from pipeline: `"PROGRESS: X/Y"`
401 Args:
402 progress_info (str): Progress data string (e.g., `"3/10"`).
403 """
404 try:
405 if '/' in progress_info:
406 current, total = map(int, progress_info.split('/'))
407 percentage = int((current / total) * 100)
408 self.progress_bar.setValue(percentage)
410 self.check_new_files() # Update folder views
411 elif "sub" in progress_info:
412 match = re.search(r"(sub-[a-zA-Z0-9_]+)", progress_info)
413 if match:
414 sub_id = match.group(1)
415 log.debug(f"Detected progress completion for: {sub_id}")
417 for folder_path, card in self.folder_cards.items():
418 folder_name = os.path.basename(folder_path)
419 if sub_id in folder_name:
420 log.debug(f"Setting finished state for card: {folder_name}")
421 card.set_finished_state()
422 break
423 else:
424 log.warning(f"No matching FolderCard found for {sub_id}")
425 else:
426 log.warning(f"Could not extract sub-id from progress info: {progress_info}")
428 except ValueError:
429 log.warning("Failed to parse progress info")
431 def _update_current_operation(self, message):
432 """
433 Update the current operation label based on pipeline output message.
435 Args:
436 message (str): Message received from the pipeline log.
437 """
438 if "Starting pipeline" in message:
439 self.current_operation.setText(
440 QCoreApplication.translate("PipelineExecutionPage", "Initializing pipeline...")
441 )
442 elif "Processing patient" in message or "sub-" in message:
443 patient_id = self._extract_patient_id_from_log(message)
444 if patient_id:
445 self.current_operation.setText(
446 QCoreApplication.translate(
447 "PipelineExecutionPage",
448 "Processing patient: {patient_id}"
449 ).format(patient_id=patient_id)
450 )
451 else:
452 self.current_operation.setText(
453 QCoreApplication.translate("PipelineExecutionPage", "Processing patient data...")
454 )
455 elif "analysis" in message.lower():
456 self.current_operation.setText(
457 QCoreApplication.translate("PipelineExecutionPage", "Performing statistical analysis...")
458 )
459 elif "saving" in message.lower() or "csv" in message.lower():
460 self.current_operation.setText(
461 QCoreApplication.translate("PipelineExecutionPage", "Saving results...")
462 )
464 def _extract_patient_id_from_log(self, message):
465 """
466 Extract a patient ID (e.g., 'sub-001') from a log message.
468 Args:
469 message (str): Log message.
471 Returns:
472 str | None: The patient ID if extracted, otherwise None.
473 """
474 import re
475 match = re.search(r'sub-(\w+)', message)
476 return f"sub-{match.group(1)}" if match else None
478 # ─────────────────────────────────────────────
479 # LOGGING AND FILE MONITORING
480 # ─────────────────────────────────────────────
481 def _log_message(self, message):
482 """
483 Append a message to the on-screen log with a timestamp.
485 Args:
486 message (str): The message text to append.
487 """
488 from datetime import datetime
489 timestamp = datetime.now().strftime("%H:%M:%S")
490 formatted_message = f"[{timestamp}] {message}"
491 self.log_text.append(formatted_message)
493 # Auto-scroll to the newest entry
494 scrollbar = self.log_text.verticalScrollBar()
495 scrollbar.setValue(scrollbar.maximum())
497 def check_new_files(self):
498 """Check each FolderCard for newly created files."""
499 for card in self.folder_cards.values():
500 card.check_new_files()
502 # ─────────────────────────────────────────────
503 # USER INTERACTION: STOP / NAVIGATION
504 # ─────────────────────────────────────────────
505 def _on_stop_clicked(self):
506 """
507 Handle a user click on the "Stop Pipeline" button.
509 Attempts graceful termination, followed by forced termination if necessary.
510 """
511 if self.pipeline_process and self.pipeline_process.state() == QProcess.ProcessState.Running:
512 self._log_message(QCoreApplication.translate("PipelineExecutionPage", "Stopping pipeline..."))
514 # Attempt graceful termination first
515 self.pipeline_process.terminate()
517 # Force termination if not stopped within 5 seconds
518 if not self.pipeline_process.waitForFinished(5000):
519 self.pipeline_process.kill()
520 self.pipeline_process.waitForFinished(3000)
522 self._log_message(QCoreApplication.translate("PipelineExecutionPage", "Pipeline stopped by user."))
524 self.current_operation.setText(
525 QCoreApplication.translate("PipelineExecutionPage", "Execution was interrupted by user."))
527 self.progress_bar.setValue(0)
528 self.stop_button.setEnabled(False)
530 self.pipeline_process = None
531 self.context["update_main_buttons"]()
533 def _return_to_import(self):
534 """
535 Return to the import page if no pipeline is running.
537 Prevents navigation while the pipeline is still active.
538 """
539 if self.pipeline_process and self.pipeline_process.state() == QProcess.ProcessState.Running:
540 return # Navigation disabled while pipeline is running
542 if self.context and "return_to_import" in self.context:
543 self.context["return_to_import"]()
545 # ─────────────────────────────────────────────
546 # PAGE NAVIGATION (from Page base class)
547 # ─────────────────────────────────────────────
548 def on_enter(self):
549 """
550 Called automatically when this page becomes visible.
552 Starts the pipeline automatically after the UI is fully rendered.
553 """
554 # Load the most recent pipeline configuration file
555 self.config_path = self._find_latest_config()
557 # Extract pipeline ID and output directory
558 config_filename = os.path.basename(self.config_path)
559 try:
560 config_id = config_filename.split('_')[0]
561 self.pipeline_output_dir = os.path.join(
562 self.workspace_path, "pipeline", f"{config_id}_output"
563 )
564 os.makedirs(self.pipeline_output_dir, exist_ok=True)
565 except (IndexError, ValueError):
566 log.debug("Failed to create pipeline_output_dir")
567 self.pipeline_output_dir = os.path.join(self.workspace_path, "pipeline")
569 # Resolve path to pipeline executable
570 try:
571 self.pipeline_bin_path = get_bin_path("pipeline_runner")
572 except FileNotFoundError:
573 log.error("Pipeline runner binary not found")
574 raise RuntimeError("Pipeline runner executable missing.")
576 if not self.pipeline_completed and self.pipeline_process is None:
577 QTimer.singleShot(500, self._start_pipeline)
579 self._setup_folder_cards()
581 def back(self):
582 """
583 Navigate back to the previous page, resetting this page first.
585 Returns:
586 Page | None: The previous page, if available.
587 """
588 self.reset_page()
589 if self.previous_page:
590 self.previous_page.on_enter()
591 return self.previous_page
592 return None
594 def next(self, context):
595 """
596 Called when the user presses "Next".
598 Displays a confirmation dialog asking whether to return to the import page.
600 Args:
601 context (dict): Application context.
603 Returns:
604 Page | None: The next page to navigate to.
605 """
606 msg_box = QMessageBox()
607 msg_box.setWindowTitle(QCoreApplication.translate("PipelineExecutionPage", "Confirm"))
608 msg_box.setText(QCoreApplication.translate("PipelineExecutionPage", "You want to return to the import page?"))
609 msg_box.setIcon(QMessageBox.Icon.Question)
610 msg_box.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel)
612 result = msg_box.exec()
614 if result == QMessageBox.StandardButton.Ok:
615 self.reset_page()
616 self._return_to_import()
617 return self.context["return_to_import"]()
618 return None
620 def is_ready_to_go_back(self):
621 """Allow going back only when the pipeline has finished or encountered an error."""
622 return self.pipeline_completed or self.pipeline_error is not None
624 def is_ready_to_advance(self):
625 """Allow advancing only when the pipeline has finished or encountered an error."""
626 return self.pipeline_completed or self.pipeline_error is not None
628 # ─────────────────────────────────────────────
629 # JSON UTILITIES
630 # ─────────────────────────────────────────────
631 def get_sub_list(self, json_path: str) -> list:
632 """
633 Read a pipeline configuration JSON file and return the list of subjects.
635 Args:
636 json_path (str): Path to the JSON configuration file.
638 Returns:
639 list[str]: A list of keys that begin with `"sub-"`.
640 """
641 with open(json_path, "r", encoding="utf-8") as f:
642 data = json.load(f)
644 return [key for key in data.keys() if key.startswith("sub-")]
646 # ─────────────────────────────────────────────
647 # STATE RESET / CLEANUP
648 # ─────────────────────────────────────────────
649 def reset_page(self):
650 """
651 Reset the page state to its initial condition.
653 Clears logs, progress, and error flags.
654 """
655 if hasattr(self, "scroll_layout"):
656 while self.scroll_layout.count():
657 item = self.scroll_layout.takeAt(0)
658 widget = item.widget()
659 if widget is not None:
660 widget.setParent(None)
661 widget.deleteLater()
662 self.folder_cards = {}
664 self.progress_bar.setValue(0)
665 self.progress_bar.setColor("#3498DB")
666 self.stop_button.setEnabled(False)
667 self.current_operation.setText("Preparing to start...")
668 self.current_operation.setStyleSheet("""
669 font-size: 13px;
670 color: #7f8c8d;
671 margin-top: 8px;
672 """)
673 self.log_text.clear()
674 self.pipeline_process = None
675 self.pipeline_error = None
676 self.pipeline_completed = False
678 def __del__(self):
679 """Ensure that any running pipeline process is terminated when this object is destroyed."""
680 if self.pipeline_process and self.pipeline_process.state() == QProcess.ProcessState.Running:
681 self.pipeline_process.kill()
682 self.pipeline_process.waitForFinished(1000)
684 def _translate_ui(self):
685 """Update all translatable UI text (used for multilingual support)."""
686 self.header.setText(QCoreApplication.translate("PipelineExecutionPage", "Pipeline Execution"))
687 self.stop_button.setText(QCoreApplication.translate("PipelineExecutionPage", "Stop Pipeline"))
688 self.log_text.setText(QCoreApplication.translate("PipelineExecutionPage", "Execution Log:"))
689 self.log_label.setText(QCoreApplication.translate("PipelineExecutionPage", "Execution Log:"))