scanner_tab.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. """
  2. Scanner Control tab - communicates exclusively with the lf_orchestration server.
  3. All microservice switching (Spectrometer, Reconstructor, etc.) is handled by the
  4. orchestrator; this tab never connects to those services directly.
  5. """
  6. from __future__ import annotations
  7. import json
  8. from PySide6.QtCore import Qt, QTimer
  9. from PySide6.QtGui import QFont, QColor
  10. from PySide6.QtWidgets import (
  11. QWidget, QSplitter, QVBoxLayout, QHBoxLayout,
  12. QGroupBox, QLabel, QPushButton, QProgressBar,
  13. QTextEdit, QScrollArea, QSizePolicy, QTabWidget,
  14. QComboBox, QLineEdit, QTableWidget, QTableWidgetItem,
  15. QHeaderView, QAbstractItemView,
  16. )
  17. from src.clients.orchestrator_client import OrchestratorClient, OrchestratorError
  18. from src.gui.workers import OrchestratorWorker
  19. from src import i18n
  20. _STATUS_COLORS = {
  21. "pending": "#9e9e9e",
  22. "running": "#e65100",
  23. "done": "#2e7d32",
  24. "failed": "#c62828",
  25. }
  26. _POLL_INTERVAL_MS = 1500
  27. class ScannerTab(QWidget):
  28. """Orchestrator-based scanner control panel."""
  29. def __init__(
  30. self,
  31. hw_config_path: str | None = None,
  32. orchestrator_url: str = "http://localhost:1717",
  33. parent: QWidget | None = None,
  34. ) -> None:
  35. super().__init__(parent)
  36. self._hw_config_path = hw_config_path
  37. self._client = OrchestratorClient(orchestrator_url)
  38. self._job_id: str | None = None
  39. self._seq_info: dict | None = None
  40. self._conn_state: str = "offline"
  41. # Active workers - kept alive while running
  42. self._run_worker: OrchestratorWorker | None = None
  43. self._poll_worker: OrchestratorWorker | None = None
  44. self._poll_timer = QTimer(self)
  45. self._poll_timer.setInterval(_POLL_INTERVAL_MS)
  46. self._poll_timer.timeout.connect(self._poll_status)
  47. self._build_layout()
  48. # ================================================================== #
  49. # Public API #
  50. # ================================================================== #
  51. def set_hw_config(self, path: str) -> None:
  52. self._hw_config_path = path
  53. self._append_log(f"HW config: {path}")
  54. def apply_seq_info(self, info_dict: dict) -> None:
  55. """Receive sequence info from SeqInterpTab after export."""
  56. self._seq_info = info_dict
  57. summary_lines = []
  58. if "infostr" in info_dict:
  59. summary_lines.append(info_dict["infostr"])
  60. if "time" in info_dict:
  61. summary_lines.append(info_dict["time"])
  62. adc = info_dict.get("iadc", {})
  63. if "points" in adc:
  64. summary_lines.append(f"ADC windows: {len(adc['points'])}")
  65. self._seq_info_label.setText("\n".join(summary_lines) if summary_lines else "-")
  66. self._append_log("Sequence info received from Sequence tab.")
  67. def retranslate_ui(self) -> None:
  68. self._url_label.setText(i18n.tr("url_label"))
  69. self._btn_connect.setText(i18n.tr("btn_connect"))
  70. self._status_label.setText(i18n.tr(self._conn_state))
  71. self._scenario_grp.setTitle(i18n.tr("grp_scenario"))
  72. self._btn_refresh.setText(i18n.tr("btn_refresh"))
  73. self._seq_grp.setTitle(i18n.tr("grp_seq_info"))
  74. self._job_grp.setTitle(i18n.tr("grp_job"))
  75. self._btn_load.setText(i18n.tr("btn_load_scenario"))
  76. self._btn_run_all.setText(i18n.tr("btn_run_all"))
  77. self._btn_next.setText(i18n.tr("btn_next_step"))
  78. self._btn_abort.setText(i18n.tr("btn_abort"))
  79. self._steps_table.setHorizontalHeaderLabels([
  80. i18n.tr("col_step"), i18n.tr("col_status"), i18n.tr("col_result")
  81. ])
  82. self._bottom_tabs.setTabText(0, i18n.tr("tab_step_result"))
  83. self._bottom_tabs.setTabText(1, i18n.tr("tab_seq_info_view"))
  84. self._bottom_tabs.setTabText(2, i18n.tr("tab_log"))
  85. # ================================================================== #
  86. # Layout builders #
  87. # ================================================================== #
  88. def _build_layout(self) -> None:
  89. root = QVBoxLayout(self)
  90. root.setContentsMargins(6, 6, 6, 6)
  91. root.setSpacing(6)
  92. root.addWidget(self._build_connection_bar())
  93. split = QSplitter(Qt.Horizontal)
  94. split.addWidget(self._build_left_panel())
  95. split.addWidget(self._build_right_panel())
  96. split.setSizes([280, 720])
  97. root.addWidget(split, stretch=1)
  98. def _build_connection_bar(self) -> QWidget:
  99. bar = QWidget()
  100. lay = QHBoxLayout(bar)
  101. lay.setContentsMargins(0, 0, 0, 0)
  102. self._url_label = QLabel(i18n.tr("url_label"))
  103. lay.addWidget(self._url_label)
  104. self._url_edit = QLineEdit(self._client.base_url)
  105. self._url_edit.setMaximumWidth(260)
  106. lay.addWidget(self._url_edit)
  107. self._btn_connect = QPushButton(i18n.tr("btn_connect"))
  108. self._btn_connect.setFixedWidth(80)
  109. self._btn_connect.clicked.connect(self._on_connect)
  110. lay.addWidget(self._btn_connect)
  111. self._status_label = QLabel(i18n.tr("offline"))
  112. self._status_label.setStyleSheet("color: #9e9e9e; font-weight: bold;")
  113. lay.addWidget(self._status_label)
  114. self._conn_progress = QProgressBar()
  115. self._conn_progress.setRange(0, 0)
  116. self._conn_progress.setFixedWidth(80)
  117. self._conn_progress.setVisible(False)
  118. lay.addWidget(self._conn_progress)
  119. lay.addStretch()
  120. return bar
  121. def _build_left_panel(self) -> QWidget:
  122. container = QWidget()
  123. container.setMinimumWidth(200)
  124. container.setMaximumWidth(320)
  125. lay = QVBoxLayout(container)
  126. lay.setContentsMargins(4, 4, 4, 4)
  127. lay.setSpacing(8)
  128. # Scenario selector
  129. self._scenario_grp = QGroupBox(i18n.tr("grp_scenario"))
  130. sg_lay = QVBoxLayout(self._scenario_grp)
  131. self._scenario_combo = QComboBox()
  132. self._scenario_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
  133. sg_lay.addWidget(self._scenario_combo)
  134. self._btn_refresh = QPushButton(i18n.tr("btn_refresh"))
  135. self._btn_refresh.clicked.connect(self._on_refresh_scenarios)
  136. sg_lay.addWidget(self._btn_refresh)
  137. lay.addWidget(self._scenario_grp)
  138. # Sequence summary
  139. self._seq_grp = QGroupBox(i18n.tr("grp_seq_info"))
  140. seq_lay = QVBoxLayout(self._seq_grp)
  141. self._seq_info_label = QLabel(i18n.tr("no_seq_loaded"))
  142. self._seq_info_label.setWordWrap(True)
  143. self._seq_info_label.setStyleSheet("color: palette(mid); font-style: italic;")
  144. self._seq_info_label.setFont(QFont("Courier New", 8))
  145. seq_lay.addWidget(self._seq_info_label)
  146. lay.addWidget(self._seq_grp)
  147. # Job info
  148. self._job_grp = QGroupBox(i18n.tr("grp_job"))
  149. job_lay = QVBoxLayout(self._job_grp)
  150. self._job_label = QLabel(i18n.tr("no_job"))
  151. self._job_label.setFont(QFont("Courier New", 8))
  152. self._job_label.setWordWrap(True)
  153. self._job_label.setStyleSheet("color: palette(mid);")
  154. job_lay.addWidget(self._job_label)
  155. lay.addWidget(self._job_grp)
  156. # Control buttons
  157. self._btn_load = QPushButton(i18n.tr("btn_load_scenario"))
  158. self._btn_load.setMinimumHeight(30)
  159. self._btn_load.clicked.connect(self._on_load_scenario)
  160. lay.addWidget(self._btn_load)
  161. self._btn_run_all = QPushButton(i18n.tr("btn_run_all"))
  162. self._btn_run_all.setMinimumHeight(30)
  163. self._btn_run_all.setEnabled(False)
  164. self._btn_run_all.clicked.connect(self._on_run_all)
  165. lay.addWidget(self._btn_run_all)
  166. self._btn_next = QPushButton(i18n.tr("btn_next_step"))
  167. self._btn_next.setMinimumHeight(30)
  168. self._btn_next.setEnabled(False)
  169. self._btn_next.clicked.connect(self._on_next_step)
  170. lay.addWidget(self._btn_next)
  171. self._btn_abort = QPushButton(i18n.tr("btn_abort"))
  172. self._btn_abort.setMinimumHeight(30)
  173. self._btn_abort.setEnabled(False)
  174. self._btn_abort.clicked.connect(self._on_abort)
  175. lay.addWidget(self._btn_abort)
  176. lay.addStretch()
  177. scroll = QScrollArea()
  178. scroll.setWidget(container)
  179. scroll.setWidgetResizable(True)
  180. scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
  181. return scroll
  182. def _build_right_panel(self) -> QWidget:
  183. panel = QWidget()
  184. lay = QVBoxLayout(panel)
  185. lay.setContentsMargins(4, 4, 4, 4)
  186. lay.setSpacing(6)
  187. # Steps table
  188. self._steps_table = QTableWidget(0, 3)
  189. self._steps_table.setHorizontalHeaderLabels([
  190. i18n.tr("col_step"), i18n.tr("col_status"), i18n.tr("col_result")
  191. ])
  192. self._steps_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
  193. self._steps_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
  194. self._steps_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
  195. self._steps_table.verticalHeader().setVisible(False)
  196. self._steps_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
  197. self._steps_table.setSelectionBehavior(QAbstractItemView.SelectRows)
  198. self._steps_table.setFont(QFont("Courier New", 9))
  199. self._steps_table.currentCellChanged.connect(
  200. lambda row, *_: self._on_step_selected(row)
  201. )
  202. lay.addWidget(self._steps_table, stretch=1)
  203. # Bottom tabs
  204. bottom_tabs = QTabWidget()
  205. self._step_result_view = QTextEdit()
  206. self._step_result_view.setReadOnly(True)
  207. self._step_result_view.setFont(QFont("Courier New", 9))
  208. self._bottom_tabs = bottom_tabs
  209. bottom_tabs.addTab(self._step_result_view, i18n.tr("tab_step_result"))
  210. self._seq_info_view = QTextEdit()
  211. self._seq_info_view.setReadOnly(True)
  212. self._seq_info_view.setFont(QFont("Courier New", 9))
  213. bottom_tabs.addTab(self._seq_info_view, i18n.tr("tab_seq_info_view"))
  214. self._log_view = QTextEdit()
  215. self._log_view.setReadOnly(True)
  216. self._log_view.setFont(QFont("Courier New", 9))
  217. bottom_tabs.addTab(self._log_view, i18n.tr("tab_log"))
  218. lay.addWidget(bottom_tabs, stretch=1)
  219. return panel
  220. # ================================================================== #
  221. # Connection #
  222. # ================================================================== #
  223. def _on_connect(self) -> None:
  224. url = self._url_edit.text().strip()
  225. if url:
  226. self._client = OrchestratorClient(url)
  227. self._btn_connect.setEnabled(False)
  228. self._conn_progress.setVisible(True)
  229. worker = OrchestratorWorker(self._client.healthcheck)
  230. worker.finished.connect(self._on_healthcheck_done)
  231. worker.error.connect(self._on_healthcheck_error)
  232. worker.start()
  233. self._hc_worker = worker # keep alive
  234. def _on_healthcheck_done(self, ok: object) -> None:
  235. self._btn_connect.setEnabled(True)
  236. self._conn_progress.setVisible(False)
  237. if ok:
  238. self._conn_state = "online"
  239. self._status_label.setText(i18n.tr("online"))
  240. self._status_label.setStyleSheet("color: #2e7d32; font-weight: bold;")
  241. self._append_log(f"Connected to orchestrator: {self._client.base_url}")
  242. self._on_refresh_scenarios()
  243. else:
  244. self._conn_state = "offline"
  245. self._status_label.setText(i18n.tr("offline"))
  246. self._status_label.setStyleSheet("color: #9e9e9e; font-weight: bold;")
  247. self._append_log("Orchestrator not reachable.")
  248. def _on_healthcheck_error(self, msg: str) -> None:
  249. self._btn_connect.setEnabled(True)
  250. self._conn_progress.setVisible(False)
  251. self._conn_state = "conn_error"
  252. self._status_label.setText(i18n.tr("conn_error"))
  253. self._status_label.setStyleSheet("color: #c62828; font-weight: bold;")
  254. self._append_log(f"Connect error: {msg}")
  255. # ================================================================== #
  256. # Scenario listing #
  257. # ================================================================== #
  258. def _on_refresh_scenarios(self) -> None:
  259. worker = OrchestratorWorker(self._client.list_scenarios)
  260. worker.finished.connect(self._on_scenarios_loaded)
  261. worker.error.connect(lambda msg: self._append_log(f"List error: {msg}"))
  262. worker.start()
  263. self._list_worker = worker
  264. def _on_scenarios_loaded(self, scenarios: object) -> None:
  265. self._scenario_combo.clear()
  266. for s in (scenarios or []):
  267. self._scenario_combo.addItem(s)
  268. self._append_log(f"Scenarios: {list(scenarios or [])}")
  269. # ================================================================== #
  270. # Job control #
  271. # ================================================================== #
  272. def _on_load_scenario(self) -> None:
  273. scenario_id = self._scenario_combo.currentText()
  274. if not scenario_id:
  275. self._append_log("No scenario selected.")
  276. return
  277. param_overrides = None
  278. if self._seq_info:
  279. param_overrides = {"start_measurement": {"info": self._seq_info}}
  280. self._append_log(f"Loading scenario '{scenario_id}'...")
  281. worker = OrchestratorWorker(
  282. self._client.load_scenario, scenario_id, param_overrides
  283. )
  284. worker.finished.connect(self._on_scenario_loaded)
  285. worker.error.connect(lambda msg: self._append_log(f"Load error: {msg}"))
  286. worker.start()
  287. self._load_worker = worker
  288. def _on_scenario_loaded(self, job_id: object) -> None:
  289. self._job_id = str(job_id)
  290. self._job_label.setText(self._job_id[:24] + "..." if len(self._job_id) > 24 else self._job_id)
  291. self._append_log(f"Job created: {self._job_id}")
  292. self._btn_run_all.setEnabled(True)
  293. self._btn_next.setEnabled(True)
  294. self._btn_abort.setEnabled(False)
  295. self._steps_table.setRowCount(0)
  296. # Fetch initial step list
  297. self._fetch_status_once()
  298. def _on_run_all(self) -> None:
  299. if not self._job_id:
  300. return
  301. self._append_log("Running all steps...")
  302. self._btn_run_all.setEnabled(False)
  303. self._btn_next.setEnabled(False)
  304. self._btn_abort.setEnabled(True)
  305. self._run_worker = OrchestratorWorker(self._client.run_all, self._job_id)
  306. self._run_worker.finished.connect(self._on_run_all_done)
  307. self._run_worker.error.connect(self._on_worker_error)
  308. self._run_worker.start()
  309. self._poll_timer.start()
  310. def _on_run_all_done(self, result: object) -> None:
  311. self._poll_timer.stop()
  312. self._btn_abort.setEnabled(False)
  313. self._append_log("Run all complete.")
  314. if isinstance(result, dict) and "steps" in result:
  315. self._update_steps_table(result["steps"])
  316. def _on_next_step(self) -> None:
  317. if not self._job_id:
  318. return
  319. worker = OrchestratorWorker(self._client.next_step, self._job_id)
  320. worker.finished.connect(self._on_next_done)
  321. worker.error.connect(self._on_worker_error)
  322. worker.start()
  323. self._next_worker = worker
  324. def _on_next_done(self, result: object) -> None:
  325. self._append_log("Step executed.")
  326. self._fetch_status_once()
  327. def _on_abort(self) -> None:
  328. self._poll_timer.stop()
  329. if self._run_worker and self._run_worker.isRunning():
  330. self._run_worker.terminate()
  331. self._btn_run_all.setEnabled(True)
  332. self._btn_next.setEnabled(True)
  333. self._btn_abort.setEnabled(False)
  334. self._append_log("Aborted.")
  335. def _on_worker_error(self, msg: str) -> None:
  336. self._poll_timer.stop()
  337. self._btn_run_all.setEnabled(True)
  338. self._btn_next.setEnabled(True)
  339. self._btn_abort.setEnabled(False)
  340. self._append_log(f"Error: {msg}")
  341. # ================================================================== #
  342. # Polling #
  343. # ================================================================== #
  344. def _fetch_status_once(self) -> None:
  345. if not self._job_id:
  346. return
  347. worker = OrchestratorWorker(self._client.get_status, self._job_id)
  348. worker.finished.connect(self._on_status_received)
  349. worker.error.connect(lambda msg: self._append_log(f"Poll error: {msg}"))
  350. worker.start()
  351. self._status_worker = worker
  352. def _poll_status(self) -> None:
  353. if self._poll_worker and self._poll_worker.isRunning():
  354. return # previous poll still in flight
  355. self._poll_worker = OrchestratorWorker(self._client.get_status, self._job_id)
  356. self._poll_worker.finished.connect(self._on_status_received)
  357. self._poll_worker.error.connect(lambda msg: None) # silently ignore poll errors
  358. self._poll_worker.start()
  359. def _on_status_received(self, status: object) -> None:
  360. if not isinstance(status, dict):
  361. return
  362. steps = status.get("steps", [])
  363. self._update_steps_table(steps)
  364. # ================================================================== #
  365. # Steps table #
  366. # ================================================================== #
  367. def _update_steps_table(self, steps: list) -> None:
  368. current_row = self._steps_table.currentRow()
  369. self._steps_table.setRowCount(len(steps))
  370. for row, step in enumerate(steps):
  371. name = step.get("name", "")
  372. status = step.get("status", "pending").lower()
  373. result = step.get("result", "")
  374. result_str = json.dumps(result, default=str)[:80] if result else ""
  375. name_item = QTableWidgetItem(name)
  376. status_item = QTableWidgetItem(status)
  377. result_item = QTableWidgetItem(result_str)
  378. color = QColor(_STATUS_COLORS.get(status, "#9e9e9e"))
  379. for item in (name_item, status_item, result_item):
  380. item.setForeground(color)
  381. self._steps_table.setItem(row, 0, name_item)
  382. self._steps_table.setItem(row, 1, status_item)
  383. self._steps_table.setItem(row, 2, result_item)
  384. if current_row >= 0:
  385. self._steps_table.setCurrentCell(current_row, 0)
  386. # Store full step data for detail view
  387. self._steps_data = steps
  388. def _on_step_selected(self, row: int) -> None:
  389. if not hasattr(self, "_steps_data") or row < 0 or row >= len(self._steps_data):
  390. return
  391. step = self._steps_data[row]
  392. result = step.get("result", None)
  393. self._step_result_view.setPlainText(
  394. json.dumps(result, indent=2, default=str) if result is not None else ""
  395. )
  396. # ================================================================== #
  397. # Log #
  398. # ================================================================== #
  399. def _append_log(self, msg: str) -> None:
  400. self._log_view.append(msg)
  401. if self._seq_info is not None:
  402. # Also refresh seq info view with latest raw dict
  403. self._seq_info_view.setPlainText(
  404. json.dumps(self._seq_info, indent=2, default=str)
  405. )