|
|
@@ -5,51 +5,323 @@ orchestrator; this tab never connects to those services directly.
|
|
|
"""
|
|
|
from __future__ import annotations
|
|
|
|
|
|
+import copy
|
|
|
import json
|
|
|
|
|
|
from PySide6.QtCore import Qt, QTimer, Signal
|
|
|
from PySide6.QtGui import QFont, QColor
|
|
|
from PySide6.QtWidgets import (
|
|
|
QWidget, QSplitter, QVBoxLayout, QHBoxLayout,
|
|
|
- QGroupBox, QLabel, QPushButton, QProgressBar,
|
|
|
+ QLabel, QPushButton, QProgressBar,
|
|
|
QTextEdit, QScrollArea, QSizePolicy, QTabWidget,
|
|
|
QComboBox, QLineEdit, QTableWidget, QTableWidgetItem,
|
|
|
QHeaderView, QAbstractItemView,
|
|
|
+ QDialog, QDialogButtonBox, QListWidget, QListWidgetItem,
|
|
|
+ QFrame, QToolButton, QMessageBox, QSplitter as _QSplitter,
|
|
|
)
|
|
|
|
|
|
from src.clients.orchestrator_client import OrchestratorClient, OrchestratorError
|
|
|
+from src.gui.tr_widgets import TrGroupBox, TrLabel, TrPushButton, bind_tab_text, bind_table_headers
|
|
|
from src.gui.workers import OrchestratorWorker
|
|
|
from src import i18n, theme
|
|
|
|
|
|
|
|
|
-_STATUS_COLORS = {
|
|
|
- "pending": "#9e9e9e",
|
|
|
- "running": "#e65100",
|
|
|
- "done": "#2e7d32",
|
|
|
- "failed": "#c62828",
|
|
|
+# ── Built-in step templates ───────────────────────────────────────────────────
|
|
|
+# Each entry: internal name → display info + default params.
|
|
|
+# display_en / display_ru are shown in the builder UI.
|
|
|
+_STEP_TEMPLATES: dict[str, dict] = {
|
|
|
+ "start_measurement": {
|
|
|
+ "display_en": "Start Measurement",
|
|
|
+ "display_ru": "Запуск измерения",
|
|
|
+ "desc_en": "Submit scan parameters to the spectrometer and trigger acquisition.",
|
|
|
+ "desc_ru": "Отправить параметры скана на спектрометр и запустить сбор данных.",
|
|
|
+ "params": {
|
|
|
+ "info": {
|
|
|
+ "infostr": "custom_signal",
|
|
|
+ "engine": "DefaultEngine",
|
|
|
+ "iadc": {
|
|
|
+ "device_model": "PS4000A",
|
|
|
+ "srate": 80000000,
|
|
|
+ "points": [1000],
|
|
|
+ "n_channels": 1,
|
|
|
+ "averaging": 1,
|
|
|
+ },
|
|
|
+ }
|
|
|
+ },
|
|
|
+ },
|
|
|
+ "wait_data_ready": {
|
|
|
+ "display_en": "Wait Data Ready",
|
|
|
+ "display_ru": "Ожидание данных",
|
|
|
+ "desc_en": "Poll spectrometer until data from the previous measurement is available.",
|
|
|
+ "desc_ru": "Ожидать готовности данных от предыдущего шага измерения.",
|
|
|
+ "params": {},
|
|
|
+ },
|
|
|
+ "fetch_data": {
|
|
|
+ "display_en": "Fetch Data",
|
|
|
+ "display_ru": "Получить данные",
|
|
|
+ "desc_en": "Download raw acquisition data from the spectrometer.",
|
|
|
+ "desc_ru": "Скачать сырые данные с спектрометра.",
|
|
|
+ "params": {"format": "h5"},
|
|
|
+ },
|
|
|
+ "run_reconstruction": {
|
|
|
+ "display_en": "Run Reconstruction",
|
|
|
+ "display_ru": "Реконструкция",
|
|
|
+ "desc_en": "Send raw data to the reconstructor service and retrieve the image.",
|
|
|
+ "desc_ru": "Передать данные в реконструктор и получить изображение.",
|
|
|
+ "params": {
|
|
|
+ "sequence_name": "linear",
|
|
|
+ "digit": "2d",
|
|
|
+ "phase_shift": False,
|
|
|
+ },
|
|
|
+ },
|
|
|
}
|
|
|
|
|
|
-_POLL_INTERVAL_MS = 1500
|
|
|
|
|
|
-# Service status bar
|
|
|
-_SVC_POLL_INTERVAL_MS = 5_000
|
|
|
+# ── Scenario builder dialog ───────────────────────────────────────────────────
|
|
|
+
|
|
|
+class ScenarioBuilderDialog(QDialog):
|
|
|
+ """
|
|
|
+ Modal dialog for composing a custom scenario step-by-step.
|
|
|
+
|
|
|
+ Left column: available step templates.
|
|
|
+ Right column: ordered list of steps to execute.
|
|
|
+ Bottom: JSON params editor for the selected step.
|
|
|
+ """
|
|
|
+
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ extra_tasks: list[str] | None = None,
|
|
|
+ parent: QWidget | None = None,
|
|
|
+ ) -> None:
|
|
|
+ super().__init__(parent)
|
|
|
+ self.setWindowTitle("Scenario Builder")
|
|
|
+ self.setMinimumSize(720, 520)
|
|
|
+ self.resize(820, 580)
|
|
|
+
|
|
|
+ # Merge server-side extra tasks into templates as "bare" entries
|
|
|
+ self._templates = dict(_STEP_TEMPLATES)
|
|
|
+ for t in (extra_tasks or []):
|
|
|
+ if t not in self._templates:
|
|
|
+ self._templates[t] = {
|
|
|
+ "display_en": t,
|
|
|
+ "display_ru": t,
|
|
|
+ "desc_en": "(custom task — no built-in description)",
|
|
|
+ "desc_ru": "(пользовательский шаг)",
|
|
|
+ "params": {},
|
|
|
+ }
|
|
|
+
|
|
|
+ # Internal step list: list of {"name": str, "params": dict}
|
|
|
+ self._steps: list[dict] = []
|
|
|
+
|
|
|
+ self._build_ui()
|
|
|
+
|
|
|
+ # ── result ────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+ def get_steps(self) -> list[dict]:
|
|
|
+ """Return the composed step list (call after exec() == Accepted)."""
|
|
|
+ return copy.deepcopy(self._steps)
|
|
|
+
|
|
|
+ # ── UI ────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+ def _build_ui(self) -> None:
|
|
|
+ root = QVBoxLayout(self)
|
|
|
+ root.setSpacing(6)
|
|
|
+
|
|
|
+ splitter = _QSplitter(Qt.Horizontal)
|
|
|
+
|
|
|
+ # Left: template palette
|
|
|
+ left = QWidget()
|
|
|
+ left.setMinimumWidth(200)
|
|
|
+ left.setMaximumWidth(260)
|
|
|
+ ll = QVBoxLayout(left)
|
|
|
+ ll.setContentsMargins(0, 0, 0, 0)
|
|
|
+ ll.setSpacing(4)
|
|
|
+ ll.addWidget(QLabel("<b>Available steps</b>"))
|
|
|
+
|
|
|
+ self._tmpl_list = QListWidget()
|
|
|
+ self._tmpl_list.setAlternatingRowColors(True)
|
|
|
+ for name, info in self._templates.items():
|
|
|
+ display = info["display_en"] if i18n.current_language() == "en" \
|
|
|
+ else info["display_ru"]
|
|
|
+ item = QListWidgetItem(display)
|
|
|
+ item.setData(Qt.UserRole, name)
|
|
|
+ item.setToolTip(
|
|
|
+ info["desc_en"] if i18n.current_language() == "en"
|
|
|
+ else info["desc_ru"]
|
|
|
+ )
|
|
|
+ self._tmpl_list.addItem(item)
|
|
|
+ self._tmpl_list.itemDoubleClicked.connect(self._add_selected_template)
|
|
|
+ ll.addWidget(self._tmpl_list, stretch=1)
|
|
|
+
|
|
|
+ btn_add = QPushButton("+ Add →")
|
|
|
+ btn_add.clicked.connect(self._add_selected_template)
|
|
|
+ ll.addWidget(btn_add)
|
|
|
+ splitter.addWidget(left)
|
|
|
+
|
|
|
+ # Right: step queue
|
|
|
+ right = QWidget()
|
|
|
+ rl = QVBoxLayout(right)
|
|
|
+ rl.setContentsMargins(0, 0, 0, 0)
|
|
|
+ rl.setSpacing(4)
|
|
|
+ rl.addWidget(QLabel("<b>Steps to run (in order)</b>"))
|
|
|
+
|
|
|
+ self._step_list = QListWidget()
|
|
|
+ self._step_list.setAlternatingRowColors(True)
|
|
|
+ self._step_list.currentRowChanged.connect(self._on_step_selected)
|
|
|
+ rl.addWidget(self._step_list, stretch=1)
|
|
|
+
|
|
|
+ # Row controls: move up / move down / remove
|
|
|
+ ctrl_row = QHBoxLayout()
|
|
|
+ ctrl_row.setSpacing(4)
|
|
|
+ for symbol, tip, slot in [
|
|
|
+ ("▲", "Move up", self._move_up),
|
|
|
+ ("▼", "Move down", self._move_down),
|
|
|
+ ("✕", "Remove", self._remove_step),
|
|
|
+ ]:
|
|
|
+ b = QPushButton(symbol)
|
|
|
+ b.setFixedSize(28, 24)
|
|
|
+ b.setToolTip(tip)
|
|
|
+ b.clicked.connect(slot)
|
|
|
+ ctrl_row.addWidget(b)
|
|
|
+ ctrl_row.addStretch()
|
|
|
+ rl.addLayout(ctrl_row)
|
|
|
+
|
|
|
+ # Params editor (JSON)
|
|
|
+ rl.addWidget(QLabel("<b>Step params (JSON)</b>"))
|
|
|
+ self._params_edit = QTextEdit()
|
|
|
+ self._params_edit.setFont(QFont("Courier New", 9))
|
|
|
+ self._params_edit.setFixedHeight(130)
|
|
|
+ self._params_edit.setPlaceholderText("Select a step to edit its params")
|
|
|
+ self._params_edit.textChanged.connect(self._on_params_edited)
|
|
|
+ rl.addWidget(self._params_edit)
|
|
|
+
|
|
|
+ splitter.addWidget(right)
|
|
|
+ splitter.setSizes([220, 500])
|
|
|
+ root.addWidget(splitter, stretch=1)
|
|
|
+
|
|
|
+ # Dialog buttons
|
|
|
+ btns = QDialogButtonBox(
|
|
|
+ QDialogButtonBox.Ok | QDialogButtonBox.Cancel
|
|
|
+ )
|
|
|
+ btns.button(QDialogButtonBox.Ok).setText("Load as Job")
|
|
|
+ btns.accepted.connect(self._on_accept)
|
|
|
+ btns.rejected.connect(self.reject)
|
|
|
+ root.addWidget(btns)
|
|
|
+
|
|
|
+ # ── template → step queue ─────────────────────────────────────────────
|
|
|
+
|
|
|
+ def _add_selected_template(self) -> None:
|
|
|
+ item = self._tmpl_list.currentItem()
|
|
|
+ if item is None:
|
|
|
+ return
|
|
|
+ name = item.data(Qt.UserRole)
|
|
|
+ tpl = self._templates.get(name, {})
|
|
|
+ self._steps.append({
|
|
|
+ "name": name,
|
|
|
+ "params": copy.deepcopy(tpl.get("params", {})),
|
|
|
+ })
|
|
|
+ self._refresh_step_list()
|
|
|
+ self._step_list.setCurrentRow(len(self._steps) - 1)
|
|
|
+
|
|
|
+ def _refresh_step_list(self) -> None:
|
|
|
+ self._step_list.clear()
|
|
|
+ for idx, step in enumerate(self._steps):
|
|
|
+ name = step["name"]
|
|
|
+ tpl = self._templates.get(name, {})
|
|
|
+ disp = tpl.get("display_en" if i18n.current_language() == "en"
|
|
|
+ else "display_ru", name)
|
|
|
+ item = QListWidgetItem(f"{idx + 1}. {disp} [{name}]")
|
|
|
+ item.setData(Qt.UserRole, idx)
|
|
|
+ self._step_list.addItem(item)
|
|
|
+
|
|
|
+ # ── step controls ─────────────────────────────────────────────────────
|
|
|
+
|
|
|
+ def _current_idx(self) -> int:
|
|
|
+ return self._step_list.currentRow()
|
|
|
+
|
|
|
+ def _move_up(self) -> None:
|
|
|
+ i = self._current_idx()
|
|
|
+ if i <= 0:
|
|
|
+ return
|
|
|
+ self._steps[i - 1], self._steps[i] = self._steps[i], self._steps[i - 1]
|
|
|
+ self._refresh_step_list()
|
|
|
+ self._step_list.setCurrentRow(i - 1)
|
|
|
+
|
|
|
+ def _move_down(self) -> None:
|
|
|
+ i = self._current_idx()
|
|
|
+ if i < 0 or i >= len(self._steps) - 1:
|
|
|
+ return
|
|
|
+ self._steps[i + 1], self._steps[i] = self._steps[i], self._steps[i + 1]
|
|
|
+ self._refresh_step_list()
|
|
|
+ self._step_list.setCurrentRow(i + 1)
|
|
|
+
|
|
|
+ def _remove_step(self) -> None:
|
|
|
+ i = self._current_idx()
|
|
|
+ if i < 0:
|
|
|
+ return
|
|
|
+ self._steps.pop(i)
|
|
|
+ self._refresh_step_list()
|
|
|
+ self._step_list.setCurrentRow(max(0, i - 1))
|
|
|
+
|
|
|
+ # ── params editor ─────────────────────────────────────────────────────
|
|
|
+
|
|
|
+ def _on_step_selected(self, row: int) -> None:
|
|
|
+ if row < 0 or row >= len(self._steps):
|
|
|
+ self._params_edit.setPlainText("")
|
|
|
+ return
|
|
|
+ params = self._steps[row].get("params", {})
|
|
|
+ self._params_edit.blockSignals(True)
|
|
|
+ self._params_edit.setPlainText(
|
|
|
+ json.dumps(params, indent=2, ensure_ascii=False)
|
|
|
+ )
|
|
|
+ self._params_edit.blockSignals(False)
|
|
|
+
|
|
|
+ def _on_params_edited(self) -> None:
|
|
|
+ i = self._current_idx()
|
|
|
+ if i < 0 or i >= len(self._steps):
|
|
|
+ return
|
|
|
+ try:
|
|
|
+ params = json.loads(self._params_edit.toPlainText() or "{}")
|
|
|
+ self._steps[i]["params"] = params
|
|
|
+ self._params_edit.setStyleSheet("")
|
|
|
+ except json.JSONDecodeError:
|
|
|
+ self._params_edit.setStyleSheet("border: 1px solid #c62828;")
|
|
|
+
|
|
|
+ # ── accept ────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+ def _on_accept(self) -> None:
|
|
|
+ if not self._steps:
|
|
|
+ QMessageBox.warning(self, "Empty scenario",
|
|
|
+ "Add at least one step before loading.")
|
|
|
+ return
|
|
|
+ # Validate JSON one last time
|
|
|
+ try:
|
|
|
+ json.loads(self._params_edit.toPlainText() or "{}")
|
|
|
+ except json.JSONDecodeError:
|
|
|
+ QMessageBox.warning(self, "Invalid JSON",
|
|
|
+ "Fix the params JSON before loading.")
|
|
|
+ return
|
|
|
+ self.accept()
|
|
|
|
|
|
-_DOT_ONLINE = "● "
|
|
|
-_DOT_OFFLINE = "● "
|
|
|
-_DOT_UNKNOWN = "● "
|
|
|
|
|
|
-_SVC_STYLE = {
|
|
|
- "online": "color: #2e7d32; font-weight: bold; font-size: 12px;",
|
|
|
- "offline": "color: #c62828; font-weight: bold; font-size: 12px;",
|
|
|
- "unknown": "color: #757575; font-weight: bold; font-size: 12px;",
|
|
|
- "checking": "color: #e65100; font-weight: bold; font-size: 12px;",
|
|
|
+# ── Status colors ─────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+_SVC_POLL_MS = 8_000 # service health-check interval (ms)
|
|
|
+
|
|
|
+_STATUS_COLORS = {
|
|
|
+ "pending": "#9e9e9e",
|
|
|
+ "running": "#e65100",
|
|
|
+ "done": "#2e7d32",
|
|
|
+ "failed": "#c62828",
|
|
|
}
|
|
|
|
|
|
+_POLL_INTERVAL_MS = 1500
|
|
|
+
|
|
|
|
|
|
class ScannerTab(QWidget):
|
|
|
"""Orchestrator-based scanner control panel."""
|
|
|
|
|
|
- scan_result_ready = Signal(str) # path to result JSON — emitted when job finishes with output
|
|
|
+ scan_result_ready = Signal(str) # emitted when job finishes with a JSON output path
|
|
|
|
|
|
def __init__(
|
|
|
self,
|
|
|
@@ -63,36 +335,40 @@ class ScannerTab(QWidget):
|
|
|
) -> None:
|
|
|
super().__init__(parent)
|
|
|
|
|
|
- self._hw_config_path = hw_config_path
|
|
|
- self._orchestrator_url = orchestrator_url.rstrip("/")
|
|
|
- self._seq_interp_url = seq_interp_url.rstrip("/")
|
|
|
+ self._hw_config_path = hw_config_path
|
|
|
+ self._orchestrator_url = orchestrator_url.rstrip("/")
|
|
|
+ # Other service URLs kept for potential future use (not used after services bar removal)
|
|
|
+ self._seq_interp_url = seq_interp_url.rstrip("/")
|
|
|
self._reconstructor_url = reconstructor_url.rstrip("/")
|
|
|
self._spectroscopy_url = spectroscopy_url.rstrip("/")
|
|
|
self._spectrometer_url = spectrometer_url.rstrip("/")
|
|
|
|
|
|
- self._client = OrchestratorClient(orchestrator_url)
|
|
|
- self._job_id: str | None = None
|
|
|
+ self._client = OrchestratorClient(orchestrator_url)
|
|
|
+ self._job_id : str | None = None
|
|
|
self._seq_info: dict | None = None
|
|
|
self._conn_state: str = "offline"
|
|
|
+ self._steps_data: list = []
|
|
|
|
|
|
- # Active workers - kept alive while running
|
|
|
- self._run_worker: OrchestratorWorker | None = None
|
|
|
- self._poll_worker: OrchestratorWorker | None = None
|
|
|
+ self._run_worker : OrchestratorWorker | None = None
|
|
|
+ self._poll_worker : OrchestratorWorker | None = None
|
|
|
|
|
|
self._poll_timer = QTimer(self)
|
|
|
self._poll_timer.setInterval(_POLL_INTERVAL_MS)
|
|
|
self._poll_timer.timeout.connect(self._poll_status)
|
|
|
|
|
|
- # Service status polling
|
|
|
+ # Service health polling
|
|
|
self._svc_workers: dict[str, OrchestratorWorker] = {}
|
|
|
self._svc_timer = QTimer(self)
|
|
|
- self._svc_timer.setInterval(_SVC_POLL_INTERVAL_MS)
|
|
|
+ self._svc_timer.setInterval(_SVC_POLL_MS)
|
|
|
self._svc_timer.timeout.connect(self._poll_all_services)
|
|
|
|
|
|
self._build_layout()
|
|
|
|
|
|
- # Kick off initial check after the event loop starts
|
|
|
- QTimer.singleShot(300, self._poll_all_services)
|
|
|
+ # Load scenarios as soon as the event loop starts
|
|
|
+ QTimer.singleShot(500, self._on_refresh_scenarios)
|
|
|
+
|
|
|
+ # Start service health polling shortly after startup
|
|
|
+ QTimer.singleShot(1200, self._poll_all_services)
|
|
|
self._svc_timer.start()
|
|
|
|
|
|
# ================================================================== #
|
|
|
@@ -103,147 +379,91 @@ class ScannerTab(QWidget):
|
|
|
self._hw_config_path = path
|
|
|
self._append_log(f"HW config: {path}")
|
|
|
|
|
|
+ def refresh_scenarios(self) -> None:
|
|
|
+ """Reload scenario list (called on mode switch or explicit request)."""
|
|
|
+ self._on_refresh_scenarios()
|
|
|
+
|
|
|
def attach_job(self, job_id: str) -> None:
|
|
|
"""
|
|
|
- Called when ScanningTab starts a new scan job via the orchestrator.
|
|
|
- Wires the job into the Scanner tab so the operator can monitor progress.
|
|
|
+ Called when ScanningTab starts a scan job via the orchestrator.
|
|
|
+ Wires the job in so the operator can monitor progress here.
|
|
|
"""
|
|
|
self._job_id = job_id
|
|
|
- short = job_id[:24] + "..." if len(job_id) > 24 else job_id
|
|
|
- self._job_label.setText(short)
|
|
|
+ self._job_label.setText(self._short_id(job_id))
|
|
|
self._append_log(f"Scan job received from Scanning tab: {job_id}")
|
|
|
|
|
|
self._steps_table.setRowCount(0)
|
|
|
self._btn_run_all.setEnabled(False)
|
|
|
self._btn_next.setEnabled(False)
|
|
|
self._btn_abort.setEnabled(True)
|
|
|
+ self._show_job_progress(True)
|
|
|
|
|
|
- # Start polling so the steps table updates in real time
|
|
|
self._fetch_status_once()
|
|
|
self._poll_timer.start()
|
|
|
|
|
|
def apply_seq_info(self, info_dict: dict) -> None:
|
|
|
"""Receive sequence info from SeqInterpTab after export."""
|
|
|
self._seq_info = info_dict
|
|
|
- summary_lines = []
|
|
|
+ lines = []
|
|
|
if "infostr" in info_dict:
|
|
|
- summary_lines.append(info_dict["infostr"])
|
|
|
+ lines.append(info_dict["infostr"])
|
|
|
if "time" in info_dict:
|
|
|
- summary_lines.append(info_dict["time"])
|
|
|
+ lines.append(info_dict["time"])
|
|
|
adc = info_dict.get("iadc", {})
|
|
|
if "points" in adc:
|
|
|
- summary_lines.append(f"ADC windows: {len(adc['points'])}")
|
|
|
- self._seq_info_label.setText("\n".join(summary_lines) if summary_lines else "-")
|
|
|
+ lines.append(f"ADC windows: {len(adc['points'])}")
|
|
|
+ self._seq_info_label.setText("\n".join(lines) if lines else "-")
|
|
|
self._append_log("Sequence info received from Sequence tab.")
|
|
|
|
|
|
- def apply_theme(self) -> None:
|
|
|
- p = theme.palette()
|
|
|
- self._services_bar.setStyleSheet(
|
|
|
- f"QWidget {{ background: {p['surface']}; border-radius: 4px; }}"
|
|
|
- )
|
|
|
- self._svc_title_lbl.setStyleSheet(
|
|
|
- f"color: {p['text_muted']}; font-size: 11px; background: transparent;"
|
|
|
- )
|
|
|
-
|
|
|
- def retranslate_ui(self) -> None:
|
|
|
- self._url_label.setText(i18n.tr("url_label"))
|
|
|
- self._btn_connect.setText(i18n.tr("btn_connect"))
|
|
|
- self._status_label.setText(i18n.tr(self._conn_state))
|
|
|
- self._scenario_grp.setTitle(i18n.tr("grp_scenario"))
|
|
|
- self._btn_refresh.setText(i18n.tr("btn_refresh"))
|
|
|
- self._seq_grp.setTitle(i18n.tr("grp_seq_info"))
|
|
|
- self._job_grp.setTitle(i18n.tr("grp_job"))
|
|
|
- self._btn_load.setText(i18n.tr("btn_load_scenario"))
|
|
|
- self._btn_run_all.setText(i18n.tr("btn_run_all"))
|
|
|
- self._btn_next.setText(i18n.tr("btn_next_step"))
|
|
|
- self._btn_abort.setText(i18n.tr("btn_abort"))
|
|
|
- self._steps_table.setHorizontalHeaderLabels([
|
|
|
- i18n.tr("col_step"), i18n.tr("col_status"), i18n.tr("col_result")
|
|
|
- ])
|
|
|
- self._bottom_tabs.setTabText(0, i18n.tr("tab_step_result"))
|
|
|
- self._bottom_tabs.setTabText(1, i18n.tr("tab_seq_info_view"))
|
|
|
- self._bottom_tabs.setTabText(2, i18n.tr("tab_log"))
|
|
|
-
|
|
|
- # ================================================================== #
|
|
|
- # Layout builders #
|
|
|
- # ================================================================== #
|
|
|
+ # -- Services strip --------------------------------------------------------
|
|
|
|
|
|
- def _build_layout(self) -> None:
|
|
|
- root = QVBoxLayout(self)
|
|
|
- root.setContentsMargins(6, 6, 6, 6)
|
|
|
- root.setSpacing(6)
|
|
|
-
|
|
|
- root.addWidget(self._build_services_bar())
|
|
|
- root.addWidget(self._build_connection_bar())
|
|
|
-
|
|
|
- split = QSplitter(Qt.Horizontal)
|
|
|
- split.addWidget(self._build_left_panel())
|
|
|
- split.addWidget(self._build_right_panel())
|
|
|
- split.setSizes([280, 720])
|
|
|
- root.addWidget(split, stretch=1)
|
|
|
-
|
|
|
- # ================================================================== #
|
|
|
- # Services status bar #
|
|
|
- # ================================================================== #
|
|
|
-
|
|
|
- def _build_services_bar(self) -> QWidget:
|
|
|
- """Horizontal strip showing live status of every microservice."""
|
|
|
- bar = QWidget()
|
|
|
- p = theme.palette()
|
|
|
- bar.setStyleSheet(
|
|
|
- f"QWidget {{ background: {p['surface']}; border-radius: 4px; }}"
|
|
|
- )
|
|
|
- lay = QHBoxLayout(bar)
|
|
|
- lay.setContentsMargins(8, 4, 8, 4)
|
|
|
+ def _build_services_strip(self) -> QWidget:
|
|
|
+ """Thin status bar showing health of all platform services."""
|
|
|
+ strip = QWidget()
|
|
|
+ strip.setFixedHeight(22)
|
|
|
+ lay = QHBoxLayout(strip)
|
|
|
+ lay.setContentsMargins(6, 0, 6, 0)
|
|
|
lay.setSpacing(0)
|
|
|
|
|
|
- title = QLabel("Сервисы:")
|
|
|
- title.setStyleSheet(
|
|
|
- f"color: {p['text_muted']}; font-size: 11px; background: transparent;"
|
|
|
- )
|
|
|
- self._services_bar = bar
|
|
|
- self._svc_title_lbl = title
|
|
|
- lay.addWidget(title)
|
|
|
- lay.addSpacing(10)
|
|
|
-
|
|
|
- # (key, display_name, tooltip_url)
|
|
|
- self._svc_defs: list[tuple[str, str, str]] = [
|
|
|
- ("orchestrator", "Orchestrator", self._orchestrator_url),
|
|
|
- ("seq_interp", "Seq-Interp", self._seq_interp_url),
|
|
|
- ("spectrometer", "Spectrometer", self._spectrometer_url),
|
|
|
- ("reconstructor", "Reconstructor", self._reconstructor_url),
|
|
|
- ("spectroscopy", "Spectroscopy", self._spectroscopy_url),
|
|
|
+ svc_defs: list[tuple[str, str, str]] = [
|
|
|
+ ("orchestrator", "Orchestrator", self._orchestrator_url),
|
|
|
+ ("seq_interp", "Seq-Interp", self._seq_interp_url),
|
|
|
+ ("spectrometer", "Spectrometer", self._spectrometer_url),
|
|
|
+ ("reconstructor", "Reconstructor",self._reconstructor_url),
|
|
|
+ ("spectroscopy", "Spectroscopy", self._spectroscopy_url),
|
|
|
]
|
|
|
|
|
|
self._svc_labels: dict[str, QLabel] = {}
|
|
|
- for i, (key, name, url) in enumerate(self._svc_defs):
|
|
|
- lbl = QLabel(f"{_DOT_UNKNOWN}{name}")
|
|
|
- lbl.setStyleSheet(_SVC_STYLE["unknown"])
|
|
|
+ for i, (key, name, url) in enumerate(svc_defs):
|
|
|
+ lbl = QLabel(f"● {name}")
|
|
|
+ lbl.setStyleSheet("color: #555577; font-size: 10px;")
|
|
|
lbl.setToolTip(url)
|
|
|
self._svc_labels[key] = lbl
|
|
|
lay.addWidget(lbl)
|
|
|
- if i < len(self._svc_defs) - 1:
|
|
|
+ if i < len(svc_defs) - 1:
|
|
|
sep = QLabel(" | ")
|
|
|
- sep.setStyleSheet("color: #333355; background: transparent;")
|
|
|
+ sep.setStyleSheet("color: #2a2a44; font-size: 10px;")
|
|
|
lay.addWidget(sep)
|
|
|
|
|
|
lay.addStretch()
|
|
|
|
|
|
btn_refresh = QPushButton("↻")
|
|
|
- btn_refresh.setFixedSize(22, 22)
|
|
|
- btn_refresh.setToolTip("Проверить статусы сейчас")
|
|
|
+ btn_refresh.setFixedSize(18, 18)
|
|
|
+ btn_refresh.setToolTip("Refresh service status")
|
|
|
btn_refresh.setStyleSheet(
|
|
|
- "QPushButton { background: #252540; color: #7777aa;"
|
|
|
- " border: 1px solid #333355; border-radius: 3px; font-size: 13px; }"
|
|
|
- "QPushButton:hover { color: #ffffff; background: #303060; }"
|
|
|
+ "QPushButton{background:transparent;color:#555577;"
|
|
|
+ "border:1px solid #2a2a44;border-radius:2px;font-size:10px;}"
|
|
|
+ "QPushButton:hover{color:#aaaacc;border-color:#555577;}"
|
|
|
)
|
|
|
btn_refresh.clicked.connect(self._poll_all_services)
|
|
|
lay.addWidget(btn_refresh)
|
|
|
|
|
|
- return bar
|
|
|
+ self._svc_strip = strip
|
|
|
+ return strip
|
|
|
+
|
|
|
+ # -- Service health polling ------------------------------------------------
|
|
|
|
|
|
def _poll_all_services(self) -> None:
|
|
|
- """Kick off background health checks for every service."""
|
|
|
import httpx
|
|
|
|
|
|
def _check(url: str) -> bool:
|
|
|
@@ -256,19 +476,15 @@ class ScannerTab(QWidget):
|
|
|
checks: dict[str, str] = {
|
|
|
"orchestrator": f"{self._orchestrator_url}/health",
|
|
|
"seq_interp": f"{self._seq_interp_url}/health",
|
|
|
- # Spectrometer accessed via orchestrator proxy to avoid CORS / auth issues
|
|
|
"spectrometer": f"{self._orchestrator_url}/spectrometer/health",
|
|
|
"reconstructor": f"{self._reconstructor_url}/health",
|
|
|
"spectroscopy": f"{self._spectroscopy_url}/health",
|
|
|
}
|
|
|
|
|
|
for key, url in checks.items():
|
|
|
- # Skip if a previous check for this service is still running
|
|
|
existing = self._svc_workers.get(key)
|
|
|
if existing and existing.isRunning():
|
|
|
continue
|
|
|
- # Do NOT reset the indicator here — keep the last known state
|
|
|
- # until the new result arrives to avoid flickering.
|
|
|
worker = OrchestratorWorker(_check, url)
|
|
|
worker.finished.connect(
|
|
|
lambda ok, k=key: self._on_svc_checked(k, bool(ok))
|
|
|
@@ -280,118 +496,196 @@ class ScannerTab(QWidget):
|
|
|
self._svc_workers[key] = worker
|
|
|
|
|
|
def _on_svc_checked(self, key: str, online: bool) -> None:
|
|
|
- self._set_svc_state(key, "online" if online else "offline")
|
|
|
-
|
|
|
- def _set_svc_state(self, key: str, state: str) -> None:
|
|
|
lbl = self._svc_labels.get(key)
|
|
|
if lbl is None:
|
|
|
return
|
|
|
- dot = _DOT_ONLINE if state == "online" else (
|
|
|
- _DOT_OFFLINE if state == "offline" else _DOT_UNKNOWN)
|
|
|
- # keep the display name (text after the dot)
|
|
|
- name = lbl.text()[2:] # strip old "● "
|
|
|
- lbl.setText(f"{dot}{name}")
|
|
|
- lbl.setStyleSheet(_SVC_STYLE.get(state, _SVC_STYLE["unknown"]))
|
|
|
+ # preserve display name (everything after "● ")
|
|
|
+ name = lbl.text()[2:]
|
|
|
+ if online:
|
|
|
+ lbl.setStyleSheet("color: #2e7d32; font-size: 10px;")
|
|
|
+ else:
|
|
|
+ lbl.setStyleSheet("color: #c62828; font-size: 10px;")
|
|
|
+
|
|
|
+ def apply_theme(self) -> None:
|
|
|
+ pass
|
|
|
+
|
|
|
+ def retranslate_ui(self) -> None:
|
|
|
+ self._status_label.setText(i18n.tr(self._conn_state))
|
|
|
+
|
|
|
+ # ================================================================== #
|
|
|
+ # Layout #
|
|
|
+ # ================================================================== #
|
|
|
+
|
|
|
+ def _build_layout(self) -> None:
|
|
|
+ root = QVBoxLayout(self)
|
|
|
+ root.setContentsMargins(6, 6, 6, 6)
|
|
|
+ root.setSpacing(6)
|
|
|
+
|
|
|
+ root.addWidget(self._build_connection_bar())
|
|
|
+
|
|
|
+ split = QSplitter(Qt.Horizontal)
|
|
|
+ split.setChildrenCollapsible(False)
|
|
|
+ split.addWidget(self._build_left_panel())
|
|
|
+ split.addWidget(self._build_right_panel())
|
|
|
+ split.setSizes([260, 740])
|
|
|
+ root.addWidget(split, stretch=1)
|
|
|
+
|
|
|
+ root.addWidget(self._build_services_strip())
|
|
|
+
|
|
|
+ # -- Connection bar ----------------------------------------------------
|
|
|
|
|
|
def _build_connection_bar(self) -> QWidget:
|
|
|
bar = QWidget()
|
|
|
lay = QHBoxLayout(bar)
|
|
|
lay.setContentsMargins(0, 0, 0, 0)
|
|
|
+ lay.setSpacing(6)
|
|
|
+
|
|
|
+ lay.addWidget(TrLabel("url_label"))
|
|
|
|
|
|
- self._url_label = QLabel(i18n.tr("url_label"))
|
|
|
- lay.addWidget(self._url_label)
|
|
|
self._url_edit = QLineEdit(self._client.base_url)
|
|
|
self._url_edit.setMaximumWidth(260)
|
|
|
lay.addWidget(self._url_edit)
|
|
|
|
|
|
- self._btn_connect = QPushButton(i18n.tr("btn_connect"))
|
|
|
- self._btn_connect.setFixedWidth(80)
|
|
|
+ self._btn_connect = TrPushButton("btn_connect")
|
|
|
+ self._btn_connect.setMinimumWidth(90)
|
|
|
self._btn_connect.clicked.connect(self._on_connect)
|
|
|
lay.addWidget(self._btn_connect)
|
|
|
|
|
|
- self._status_label = QLabel(i18n.tr("offline"))
|
|
|
- self._status_label.setStyleSheet("color: #9e9e9e; font-weight: bold;")
|
|
|
- lay.addWidget(self._status_label)
|
|
|
-
|
|
|
self._conn_progress = QProgressBar()
|
|
|
self._conn_progress.setRange(0, 0)
|
|
|
self._conn_progress.setFixedWidth(80)
|
|
|
self._conn_progress.setVisible(False)
|
|
|
lay.addWidget(self._conn_progress)
|
|
|
|
|
|
+ self._status_label = QLabel(i18n.tr("offline"))
|
|
|
+ self._status_label.setStyleSheet("color: #9e9e9e; font-weight: bold;")
|
|
|
+ lay.addWidget(self._status_label)
|
|
|
+
|
|
|
lay.addStretch()
|
|
|
return bar
|
|
|
|
|
|
+ # -- Left panel --------------------------------------------------------
|
|
|
+
|
|
|
def _build_left_panel(self) -> QWidget:
|
|
|
container = QWidget()
|
|
|
container.setMinimumWidth(200)
|
|
|
- container.setMaximumWidth(320)
|
|
|
+ container.setMaximumWidth(300)
|
|
|
lay = QVBoxLayout(container)
|
|
|
lay.setContentsMargins(4, 4, 4, 4)
|
|
|
lay.setSpacing(8)
|
|
|
|
|
|
- # Scenario selector
|
|
|
- self._scenario_grp = QGroupBox(i18n.tr("grp_scenario"))
|
|
|
- sg_lay = QVBoxLayout(self._scenario_grp)
|
|
|
+ lay.addWidget(self._build_scenario_group())
|
|
|
+ lay.addWidget(self._build_job_group())
|
|
|
+ lay.addWidget(self._build_seq_info_group())
|
|
|
+ lay.addStretch()
|
|
|
+
|
|
|
+ scroll = QScrollArea()
|
|
|
+ scroll.setWidget(container)
|
|
|
+ scroll.setWidgetResizable(True)
|
|
|
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
|
+ return scroll
|
|
|
+
|
|
|
+ def _build_scenario_group(self) -> QWidget:
|
|
|
+ grp = TrGroupBox("grp_scenario")
|
|
|
+ lay = QVBoxLayout(grp)
|
|
|
+ lay.setSpacing(4)
|
|
|
+
|
|
|
+ # Combo + refresh button
|
|
|
+ row = QHBoxLayout()
|
|
|
+ row.setSpacing(4)
|
|
|
self._scenario_combo = QComboBox()
|
|
|
self._scenario_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
|
|
- sg_lay.addWidget(self._scenario_combo)
|
|
|
- self._btn_refresh = QPushButton(i18n.tr("btn_refresh"))
|
|
|
+ row.addWidget(self._scenario_combo)
|
|
|
+
|
|
|
+ self._btn_refresh = QPushButton("↻")
|
|
|
+ self._btn_refresh.setFixedSize(26, 26)
|
|
|
+ self._btn_refresh.setToolTip(i18n.tr("btn_refresh"))
|
|
|
self._btn_refresh.clicked.connect(self._on_refresh_scenarios)
|
|
|
- sg_lay.addWidget(self._btn_refresh)
|
|
|
- lay.addWidget(self._scenario_grp)
|
|
|
+ row.addWidget(self._btn_refresh)
|
|
|
+ lay.addLayout(row)
|
|
|
|
|
|
- # Sequence summary
|
|
|
- self._seq_grp = QGroupBox(i18n.tr("grp_seq_info"))
|
|
|
- seq_lay = QVBoxLayout(self._seq_grp)
|
|
|
- self._seq_info_label = QLabel(i18n.tr("no_seq_loaded"))
|
|
|
- self._seq_info_label.setWordWrap(True)
|
|
|
- self._seq_info_label.setStyleSheet("color: #666688; font-style: italic;")
|
|
|
- self._seq_info_label.setFont(QFont("Courier New", 8))
|
|
|
- seq_lay.addWidget(self._seq_info_label)
|
|
|
- lay.addWidget(self._seq_grp)
|
|
|
+ # Load template + build custom — side by side
|
|
|
+ action_row = QHBoxLayout()
|
|
|
+ action_row.setSpacing(4)
|
|
|
+
|
|
|
+ self._btn_load = TrPushButton("btn_load_scenario")
|
|
|
+ self._btn_load.setMinimumHeight(28)
|
|
|
+ self._btn_load.clicked.connect(self._on_load_scenario)
|
|
|
+ action_row.addWidget(self._btn_load)
|
|
|
+
|
|
|
+ self._btn_build = QPushButton("+")
|
|
|
+ self._btn_build.setFixedSize(28, 28)
|
|
|
+ self._btn_build.setToolTip(i18n.tr("btn_build_scenario"))
|
|
|
+ self._btn_build.setFont(QFont("Arial", 13))
|
|
|
+ self._btn_build.clicked.connect(self._on_build_scenario)
|
|
|
+ action_row.addWidget(self._btn_build)
|
|
|
+
|
|
|
+ lay.addLayout(action_row)
|
|
|
+ return grp
|
|
|
+
|
|
|
+ def _build_job_group(self) -> QWidget:
|
|
|
+ grp = TrGroupBox("grp_job")
|
|
|
+ lay = QVBoxLayout(grp)
|
|
|
+ lay.setSpacing(4)
|
|
|
|
|
|
- # Job info
|
|
|
- self._job_grp = QGroupBox(i18n.tr("grp_job"))
|
|
|
- job_lay = QVBoxLayout(self._job_grp)
|
|
|
self._job_label = QLabel(i18n.tr("no_job"))
|
|
|
self._job_label.setFont(QFont("Courier New", 8))
|
|
|
self._job_label.setWordWrap(True)
|
|
|
self._job_label.setStyleSheet("color: #666688;")
|
|
|
- job_lay.addWidget(self._job_label)
|
|
|
- lay.addWidget(self._job_grp)
|
|
|
-
|
|
|
- # Control buttons
|
|
|
- self._btn_load = QPushButton(i18n.tr("btn_load_scenario"))
|
|
|
- self._btn_load.setMinimumHeight(30)
|
|
|
- self._btn_load.clicked.connect(self._on_load_scenario)
|
|
|
- lay.addWidget(self._btn_load)
|
|
|
-
|
|
|
- self._btn_run_all = QPushButton(i18n.tr("btn_run_all"))
|
|
|
+ lay.addWidget(self._job_label)
|
|
|
+
|
|
|
+ # Progress bar: shows X / N steps
|
|
|
+ self._job_progress = QProgressBar()
|
|
|
+ self._job_progress.setRange(0, 1)
|
|
|
+ self._job_progress.setValue(0)
|
|
|
+ self._job_progress.setTextVisible(True)
|
|
|
+ self._job_progress.setFormat("%v / %m")
|
|
|
+ self._job_progress.setFixedHeight(14)
|
|
|
+ self._job_progress.setVisible(False)
|
|
|
+ lay.addWidget(self._job_progress)
|
|
|
+
|
|
|
+ # Job overall status text (running / done / failed)
|
|
|
+ self._job_status_label = QLabel()
|
|
|
+ self._job_status_label.setFont(QFont("Courier New", 8))
|
|
|
+ self._job_status_label.setVisible(False)
|
|
|
+ lay.addWidget(self._job_status_label)
|
|
|
+
|
|
|
+ # Run All — primary execution button
|
|
|
+ self._btn_run_all = TrPushButton("btn_run_all")
|
|
|
self._btn_run_all.setMinimumHeight(30)
|
|
|
self._btn_run_all.setEnabled(False)
|
|
|
self._btn_run_all.clicked.connect(self._on_run_all)
|
|
|
lay.addWidget(self._btn_run_all)
|
|
|
|
|
|
- self._btn_next = QPushButton(i18n.tr("btn_next_step"))
|
|
|
- self._btn_next.setMinimumHeight(30)
|
|
|
+ # Next Step + Abort — secondary controls side by side
|
|
|
+ secondary = QHBoxLayout()
|
|
|
+ secondary.setSpacing(4)
|
|
|
+ self._btn_next = TrPushButton("btn_next_step")
|
|
|
+ self._btn_next.setMinimumHeight(26)
|
|
|
self._btn_next.setEnabled(False)
|
|
|
self._btn_next.clicked.connect(self._on_next_step)
|
|
|
- lay.addWidget(self._btn_next)
|
|
|
+ secondary.addWidget(self._btn_next)
|
|
|
|
|
|
- self._btn_abort = QPushButton(i18n.tr("btn_abort"))
|
|
|
- self._btn_abort.setMinimumHeight(30)
|
|
|
+ self._btn_abort = TrPushButton("btn_abort")
|
|
|
+ self._btn_abort.setMinimumHeight(26)
|
|
|
self._btn_abort.setEnabled(False)
|
|
|
self._btn_abort.clicked.connect(self._on_abort)
|
|
|
- lay.addWidget(self._btn_abort)
|
|
|
+ secondary.addWidget(self._btn_abort)
|
|
|
+ lay.addLayout(secondary)
|
|
|
|
|
|
- lay.addStretch()
|
|
|
+ return grp
|
|
|
|
|
|
- scroll = QScrollArea()
|
|
|
- scroll.setWidget(container)
|
|
|
- scroll.setWidgetResizable(True)
|
|
|
- scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
|
- return scroll
|
|
|
+ def _build_seq_info_group(self) -> QWidget:
|
|
|
+ grp = TrGroupBox("grp_seq_info")
|
|
|
+ lay = QVBoxLayout(grp)
|
|
|
+ self._seq_info_label = QLabel(i18n.tr("no_seq_loaded"))
|
|
|
+ self._seq_info_label.setWordWrap(True)
|
|
|
+ self._seq_info_label.setStyleSheet("color: #666688; font-style: italic;")
|
|
|
+ self._seq_info_label.setFont(QFont("Courier New", 8))
|
|
|
+ lay.addWidget(self._seq_info_label)
|
|
|
+ return grp
|
|
|
+
|
|
|
+ # -- Right panel -------------------------------------------------------
|
|
|
|
|
|
def _build_right_panel(self) -> QWidget:
|
|
|
panel = QWidget()
|
|
|
@@ -404,9 +698,11 @@ class ScannerTab(QWidget):
|
|
|
self._steps_table.setHorizontalHeaderLabels([
|
|
|
i18n.tr("col_step"), i18n.tr("col_status"), i18n.tr("col_result")
|
|
|
])
|
|
|
- self._steps_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
|
|
- self._steps_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
|
|
- self._steps_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
|
|
|
+ bind_table_headers(self._steps_table, ["col_step", "col_status", "col_result"])
|
|
|
+ hdr = self._steps_table.horizontalHeader()
|
|
|
+ hdr.setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
|
|
+ hdr.setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
|
|
+ hdr.setSectionResizeMode(2, QHeaderView.Stretch)
|
|
|
self._steps_table.verticalHeader().setVisible(False)
|
|
|
self._steps_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
|
|
self._steps_table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
|
|
@@ -414,26 +710,35 @@ class ScannerTab(QWidget):
|
|
|
self._steps_table.currentCellChanged.connect(
|
|
|
lambda row, *_: self._on_step_selected(row)
|
|
|
)
|
|
|
- lay.addWidget(self._steps_table, stretch=1)
|
|
|
+ lay.addWidget(self._steps_table, stretch=2)
|
|
|
|
|
|
- # Bottom tabs
|
|
|
+ # Detail tabs
|
|
|
bottom_tabs = QTabWidget()
|
|
|
+ self._bottom_tabs = bottom_tabs
|
|
|
|
|
|
self._step_result_view = QTextEdit()
|
|
|
self._step_result_view.setReadOnly(True)
|
|
|
self._step_result_view.setFont(QFont("Courier New", 9))
|
|
|
- self._bottom_tabs = bottom_tabs
|
|
|
bottom_tabs.addTab(self._step_result_view, i18n.tr("tab_step_result"))
|
|
|
+ bind_tab_text(bottom_tabs, 0, "tab_step_result")
|
|
|
+
|
|
|
+ self._step_params_view = QTextEdit()
|
|
|
+ self._step_params_view.setReadOnly(True)
|
|
|
+ self._step_params_view.setFont(QFont("Courier New", 9))
|
|
|
+ bottom_tabs.addTab(self._step_params_view, i18n.tr("tab_step_params"))
|
|
|
+ bind_tab_text(bottom_tabs, 1, "tab_step_params")
|
|
|
|
|
|
self._seq_info_view = QTextEdit()
|
|
|
self._seq_info_view.setReadOnly(True)
|
|
|
self._seq_info_view.setFont(QFont("Courier New", 9))
|
|
|
bottom_tabs.addTab(self._seq_info_view, i18n.tr("tab_seq_info_view"))
|
|
|
+ bind_tab_text(bottom_tabs, 2, "tab_seq_info_view")
|
|
|
|
|
|
self._log_view = QTextEdit()
|
|
|
self._log_view.setReadOnly(True)
|
|
|
self._log_view.setFont(QFont("Courier New", 9))
|
|
|
bottom_tabs.addTab(self._log_view, i18n.tr("tab_log"))
|
|
|
+ bind_tab_text(bottom_tabs, 3, "tab_log")
|
|
|
|
|
|
lay.addWidget(bottom_tabs, stretch=1)
|
|
|
return panel
|
|
|
@@ -454,7 +759,7 @@ class ScannerTab(QWidget):
|
|
|
worker.finished.connect(self._on_healthcheck_done)
|
|
|
worker.error.connect(self._on_healthcheck_error)
|
|
|
worker.start()
|
|
|
- self._hc_worker = worker # keep alive
|
|
|
+ self._hc_worker = worker
|
|
|
|
|
|
def _on_healthcheck_done(self, ok: object) -> None:
|
|
|
self._btn_connect.setEnabled(True)
|
|
|
@@ -463,7 +768,7 @@ class ScannerTab(QWidget):
|
|
|
self._conn_state = "online"
|
|
|
self._status_label.setText(i18n.tr("online"))
|
|
|
self._status_label.setStyleSheet("color: #2e7d32; font-weight: bold;")
|
|
|
- self._append_log(f"Connected to orchestrator: {self._client.base_url}")
|
|
|
+ self._append_log(f"Connected: {self._client.base_url}")
|
|
|
self._on_refresh_scenarios()
|
|
|
else:
|
|
|
self._conn_state = "offline"
|
|
|
@@ -491,15 +796,37 @@ class ScannerTab(QWidget):
|
|
|
self._list_worker = worker
|
|
|
|
|
|
def _on_scenarios_loaded(self, scenarios: object) -> None:
|
|
|
+ items = list(scenarios or [])
|
|
|
self._scenario_combo.clear()
|
|
|
- for s in (scenarios or []):
|
|
|
- self._scenario_combo.addItem(s)
|
|
|
- self._append_log(f"Scenarios: {list(scenarios or [])}")
|
|
|
+ self._scenario_combo.addItems(items)
|
|
|
+ self._append_log(f"Scenarios: {items}")
|
|
|
|
|
|
# ================================================================== #
|
|
|
# Job control #
|
|
|
# ================================================================== #
|
|
|
|
|
|
+ def _on_build_scenario(self) -> None:
|
|
|
+ """Open the scenario builder dialog; on accept, submit as a custom job."""
|
|
|
+ # Fetch server-side tasks in background so the dialog can show extras
|
|
|
+ extra: list[str] = []
|
|
|
+ try:
|
|
|
+ extra = self._client.get_task_registry()
|
|
|
+ except Exception:
|
|
|
+ pass # offline or old server — builder still works with built-ins
|
|
|
+
|
|
|
+ dlg = ScenarioBuilderDialog(extra_tasks=extra, parent=self)
|
|
|
+ if dlg.exec() != QDialog.Accepted:
|
|
|
+ return
|
|
|
+
|
|
|
+ steps = dlg.get_steps()
|
|
|
+ self._append_log(f"Building custom scenario: {[s['name'] for s in steps]}")
|
|
|
+
|
|
|
+ worker = OrchestratorWorker(self._client.create_scenario, steps)
|
|
|
+ worker.finished.connect(self._on_scenario_loaded)
|
|
|
+ worker.error.connect(lambda msg: self._append_log(f"Build error: {msg}"))
|
|
|
+ worker.start()
|
|
|
+ self._load_worker = worker
|
|
|
+
|
|
|
def _on_load_scenario(self) -> None:
|
|
|
scenario_id = self._scenario_combo.currentText()
|
|
|
if not scenario_id:
|
|
|
@@ -521,13 +848,13 @@ class ScannerTab(QWidget):
|
|
|
|
|
|
def _on_scenario_loaded(self, job_id: object) -> None:
|
|
|
self._job_id = str(job_id)
|
|
|
- self._job_label.setText(self._job_id[:24] + "..." if len(self._job_id) > 24 else self._job_id)
|
|
|
+ self._job_label.setText(self._short_id(self._job_id))
|
|
|
self._append_log(f"Job created: {self._job_id}")
|
|
|
self._btn_run_all.setEnabled(True)
|
|
|
self._btn_next.setEnabled(True)
|
|
|
self._btn_abort.setEnabled(False)
|
|
|
self._steps_table.setRowCount(0)
|
|
|
- # Fetch initial step list
|
|
|
+ self._show_job_progress(True)
|
|
|
self._fetch_status_once()
|
|
|
|
|
|
def _on_run_all(self) -> None:
|
|
|
@@ -542,7 +869,6 @@ class ScannerTab(QWidget):
|
|
|
self._run_worker.finished.connect(self._on_run_all_done)
|
|
|
self._run_worker.error.connect(self._on_worker_error)
|
|
|
self._run_worker.start()
|
|
|
-
|
|
|
self._poll_timer.start()
|
|
|
|
|
|
def _on_run_all_done(self, result: object) -> None:
|
|
|
@@ -596,10 +922,10 @@ class ScannerTab(QWidget):
|
|
|
|
|
|
def _poll_status(self) -> None:
|
|
|
if self._poll_worker and self._poll_worker.isRunning():
|
|
|
- return # previous poll still in flight
|
|
|
+ return
|
|
|
self._poll_worker = OrchestratorWorker(self._client.get_status, self._job_id)
|
|
|
self._poll_worker.finished.connect(self._on_status_received)
|
|
|
- self._poll_worker.error.connect(lambda msg: None) # silently ignore poll errors
|
|
|
+ self._poll_worker.error.connect(lambda msg: None)
|
|
|
self._poll_worker.start()
|
|
|
|
|
|
def _on_status_received(self, status: object) -> None:
|
|
|
@@ -607,8 +933,8 @@ class ScannerTab(QWidget):
|
|
|
return
|
|
|
steps = status.get("steps", [])
|
|
|
self._update_steps_table(steps)
|
|
|
+ self._update_job_status_display(status.get("status", ""), steps)
|
|
|
|
|
|
- # Stop polling when the orchestrator marks the job as done or failed
|
|
|
job_status = status.get("status", "")
|
|
|
if job_status == "done" or job_status.startswith("failed"):
|
|
|
self._poll_timer.stop()
|
|
|
@@ -618,7 +944,6 @@ class ScannerTab(QWidget):
|
|
|
self._emit_result_if_found(steps)
|
|
|
|
|
|
def _emit_result_if_found(self, steps: list) -> None:
|
|
|
- """Scan step results for a JSON output path and emit scan_result_ready."""
|
|
|
for step in steps:
|
|
|
result = step.get("result") or {}
|
|
|
if not isinstance(result, dict):
|
|
|
@@ -637,12 +962,12 @@ class ScannerTab(QWidget):
|
|
|
current_row = self._steps_table.currentRow()
|
|
|
self._steps_table.setRowCount(len(steps))
|
|
|
for row, step in enumerate(steps):
|
|
|
- name = step.get("name", "")
|
|
|
+ name = step.get("name", "")
|
|
|
status = step.get("status", "pending").lower()
|
|
|
result = step.get("result", "")
|
|
|
- result_str = json.dumps(result, default=str)[:80] if result else ""
|
|
|
+ result_str = json.dumps(result, default=str)[:120] if result else ""
|
|
|
|
|
|
- name_item = QTableWidgetItem(name)
|
|
|
+ name_item = QTableWidgetItem(name)
|
|
|
status_item = QTableWidgetItem(status)
|
|
|
result_item = QTableWidgetItem(result_str)
|
|
|
|
|
|
@@ -657,18 +982,44 @@ class ScannerTab(QWidget):
|
|
|
if current_row >= 0:
|
|
|
self._steps_table.setCurrentCell(current_row, 0)
|
|
|
|
|
|
- # Store full step data for detail view
|
|
|
self._steps_data = steps
|
|
|
|
|
|
def _on_step_selected(self, row: int) -> None:
|
|
|
- if not hasattr(self, "_steps_data") or row < 0 or row >= len(self._steps_data):
|
|
|
+ if row < 0 or row >= len(self._steps_data):
|
|
|
return
|
|
|
step = self._steps_data[row]
|
|
|
+
|
|
|
result = step.get("result", None)
|
|
|
self._step_result_view.setPlainText(
|
|
|
json.dumps(result, indent=2, default=str) if result is not None else ""
|
|
|
)
|
|
|
|
|
|
+ params = step.get("params", None)
|
|
|
+ self._step_params_view.setPlainText(
|
|
|
+ json.dumps(params, indent=2, default=str) if params else ""
|
|
|
+ )
|
|
|
+
|
|
|
+ # ================================================================== #
|
|
|
+ # Job progress display #
|
|
|
+ # ================================================================== #
|
|
|
+
|
|
|
+ def _show_job_progress(self, visible: bool) -> None:
|
|
|
+ self._job_progress.setVisible(visible)
|
|
|
+ self._job_status_label.setVisible(visible)
|
|
|
+
|
|
|
+ def _update_job_status_display(self, job_status: str, steps: list) -> None:
|
|
|
+ """Update the progress bar and status label in the job group."""
|
|
|
+ total = len(steps)
|
|
|
+ done = sum(1 for s in steps if s.get("status", "").lower() == "done")
|
|
|
+
|
|
|
+ if total > 0:
|
|
|
+ self._job_progress.setMaximum(total)
|
|
|
+ self._job_progress.setValue(done)
|
|
|
+
|
|
|
+ color = _STATUS_COLORS.get(job_status.split(":")[0].strip(), "#9e9e9e")
|
|
|
+ self._job_status_label.setText(job_status)
|
|
|
+ self._job_status_label.setStyleSheet(f"color: {color};")
|
|
|
+
|
|
|
# ================================================================== #
|
|
|
# Log #
|
|
|
# ================================================================== #
|
|
|
@@ -676,7 +1027,14 @@ class ScannerTab(QWidget):
|
|
|
def _append_log(self, msg: str) -> None:
|
|
|
self._log_view.append(msg)
|
|
|
if self._seq_info is not None:
|
|
|
- # Also refresh seq info view with latest raw dict
|
|
|
self._seq_info_view.setPlainText(
|
|
|
json.dumps(self._seq_info, indent=2, default=str)
|
|
|
)
|
|
|
+
|
|
|
+ # ================================================================== #
|
|
|
+ # Helpers #
|
|
|
+ # ================================================================== #
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _short_id(job_id: str, n: int = 20) -> str:
|
|
|
+ return job_id[:n] + "..." if len(job_id) > n else job_id
|