Просмотр исходного кода

refactoring of GUI-orchestration connections

spacexerq 4 недель назад
Родитель
Сommit
5c61c90655

+ 61 - 9
apps/gui/src/app_window.py

@@ -9,7 +9,7 @@ from __future__ import annotations
 
 
 import os
 import os
 
 
-from PySide6.QtCore import Qt, QSize
+from PySide6.QtCore import Qt, QSize, QTimer
 from PySide6.QtWidgets import (
 from PySide6.QtWidgets import (
     QApplication,
     QApplication,
     QButtonGroup,
     QButtonGroup,
@@ -30,7 +30,8 @@ from src.tabs.scanning_tab import ScanningTab
 from src.tabs.seq_interp_tab import SeqInterpTab
 from src.tabs.seq_interp_tab import SeqInterpTab
 from src.tabs.spectroscopy_tab import SpectroscopyTab
 from src.tabs.spectroscopy_tab import SpectroscopyTab
 
 
-_TAB_NAMES = ["Sequence", "Scanner", "FID", "Scanning", "Spectroscopy"]
+_TAB_NAMES = ["Scanning", "Sequence", "Scanner", "Spectroscopy", "FID"]
+_SPEC_TAB_IDX = 3  # index of the Spectroscopy tab
 
 
 _NAV_BG = "#0f0f1e"
 _NAV_BG = "#0f0f1e"
 _NAV_H = 38
 _NAV_H = 38
@@ -63,6 +64,27 @@ QToolBar {{
 }}
 }}
 """
 """
 
 
+# Stylesheet applied to the Spectroscopy nav button while data-arrival blink is ON
+_SPEC_BTN_BLINK_CSS = """
+QPushButton {
+    background: transparent;
+    color: #e65100;
+    border: none;
+    border-bottom: 2px solid #e65100;
+    padding: 0px 20px;
+    font-size: 12px;
+    min-height: 36px;
+}
+QPushButton:checked {
+    color: #ffffff;
+    border-bottom: 2px solid #f0c040;
+}
+QPushButton:hover:!checked {
+    color: #ff7733;
+    background: #17172e;
+}
+"""
+
 
 
 class LFMRIWindow(QMainWindow):
 class LFMRIWindow(QMainWindow):
     """Unified LF-MRI application window."""
     """Unified LF-MRI application window."""
@@ -91,6 +113,8 @@ class LFMRIWindow(QMainWindow):
         self._scanner_tab = ScannerTab(
         self._scanner_tab = ScannerTab(
             hw_config_path=hw_config_path,
             hw_config_path=hw_config_path,
             orchestrator_url=orchestrator_url,
             orchestrator_url=orchestrator_url,
+            seq_interp_url=seq_interp_url,
+            spectroscopy_url=spectroscopy_url,
         )
         )
         self._fid_tab = FidTab(
         self._fid_tab = FidTab(
             hw_config_path=hw_config_path,
             hw_config_path=hw_config_path,
@@ -103,16 +127,24 @@ class LFMRIWindow(QMainWindow):
         self._tabs = QTabWidget()
         self._tabs = QTabWidget()
         self._tabs.tabBar().hide()
         self._tabs.tabBar().hide()
         self._tabs.setDocumentMode(True)
         self._tabs.setDocumentMode(True)
-        self._tabs.addTab(self._seq_tab, _TAB_NAMES[0])
-        self._tabs.addTab(self._scanner_tab, _TAB_NAMES[1])
-        self._tabs.addTab(self._fid_tab, _TAB_NAMES[2])
-        self._tabs.addTab(self._scanning_tab, _TAB_NAMES[3])
-        self._tabs.addTab(self._spectroscopy_tab, _TAB_NAMES[4])
+        self._tabs.addTab(self._scanning_tab, _TAB_NAMES[0])
+        self._tabs.addTab(self._seq_tab, _TAB_NAMES[1])
+        self._tabs.addTab(self._scanner_tab, _TAB_NAMES[2])
+        self._tabs.addTab(self._spectroscopy_tab, _TAB_NAMES[3])
+        self._tabs.addTab(self._fid_tab, _TAB_NAMES[4])
         self._tabs.currentChanged.connect(self._on_tab_changed)
         self._tabs.currentChanged.connect(self._on_tab_changed)
         self.setCentralWidget(self._tabs)
         self.setCentralWidget(self._tabs)
 
 
         self._fid_tab.fid_seq_generated.connect(self._on_fid_generated)
         self._fid_tab.fid_seq_generated.connect(self._on_fid_generated)
         self._seq_tab.ready_for_scan.connect(self._on_ready_for_scan)
         self._seq_tab.ready_for_scan.connect(self._on_ready_for_scan)
+        self._scanning_tab.raw_data_ready.connect(self._on_scan_raw_data_ready)
+        self._scanning_tab.scan_job_started.connect(self._scanner_tab.attach_job)
+
+        # -- blink timer (off by default) ----------------------------------
+        self._spec_blink_on: bool = False
+        self._spec_blink_timer = QTimer(self)
+        self._spec_blink_timer.setInterval(700)   # slow orange blink: 700 ms
+        self._spec_blink_timer.timeout.connect(self._tick_spec_blink)
 
 
         self.menuBar().hide()
         self.menuBar().hide()
         self._build_nav_bar()
         self._build_nav_bar()
@@ -194,15 +226,35 @@ class LFMRIWindow(QMainWindow):
         self.statusBar().showMessage(f"Active: {name}")
         self.statusBar().showMessage(f"Active: {name}")
         if 0 <= index < len(self._nav_tab_buttons):
         if 0 <= index < len(self._nav_tab_buttons):
             self._nav_tab_buttons[index].setChecked(True)
             self._nav_tab_buttons[index].setChecked(True)
+        if index == _SPEC_TAB_IDX:
+            self._stop_spec_blink()
 
 
     def _on_fid_generated(self, path: str) -> None:
     def _on_fid_generated(self, path: str) -> None:
         self._seq_tab.load_seq_file(path)
         self._seq_tab.load_seq_file(path)
-        self._switch_tab(0)
+        self._switch_tab(1)
 
 
     def _on_ready_for_scan(self, info: dict) -> None:
     def _on_ready_for_scan(self, info: dict) -> None:
         self._scanner_tab.apply_seq_info(info)
         self._scanner_tab.apply_seq_info(info)
         self._scanning_tab.apply_seq_info(info)
         self._scanning_tab.apply_seq_info(info)
-        self._switch_tab(1)
+        self._switch_tab(2)
+
+    def _on_scan_raw_data_ready(self, json_path: str) -> None:
+        """Raw measurement data arrived — push to spectroscopy tab and start blink."""
+        self._spectroscopy_tab.receive_scan_data(json_path)
+        if self._tabs.currentIndex() != _SPEC_TAB_IDX:
+            self._spec_blink_on = False
+            self._spec_blink_timer.start()
+
+    # -- nav button blink (Spectroscopy tab) --------------------------------
+
+    def _tick_spec_blink(self) -> None:
+        self._spec_blink_on = not self._spec_blink_on
+        btn = self._nav_tab_buttons[_SPEC_TAB_IDX]
+        btn.setStyleSheet(_SPEC_BTN_BLINK_CSS if self._spec_blink_on else _TAB_BTN_CSS)
+
+    def _stop_spec_blink(self) -> None:
+        self._spec_blink_timer.stop()
+        self._nav_tab_buttons[_SPEC_TAB_IDX].setStyleSheet(_TAB_BTN_CSS)
 
 
 
 
 class _VSep(QFrame):
 class _VSep(QFrame):

+ 105 - 2
apps/gui/src/clients/orchestrator_client.py

@@ -55,9 +55,17 @@ class OrchestratorClient:
     # -- public API ---------------------------------------------------------
     # -- public API ---------------------------------------------------------
 
 
     def healthcheck(self) -> bool:
     def healthcheck(self) -> bool:
-        """Return True if the orchestrator responds on /scenario/list."""
+        """Return True if the orchestrator responds on /health."""
         try:
         try:
-            self._get("/scenario/list")
+            self._get("/health")
+            return True
+        except OrchestratorError:
+            return False
+
+    def spectrometer_healthcheck(self) -> bool:
+        """Return True if the spectrometer service is reachable (via orchestrator proxy)."""
+        try:
+            self._get("/spectrometer/health")
             return True
             return True
         except OrchestratorError:
         except OrchestratorError:
             return False
             return False
@@ -93,3 +101,98 @@ class OrchestratorClient:
     def get_status(self, job_id: str) -> dict:
     def get_status(self, job_id: str) -> dict:
         """Return the current scenario state (steps + their statuses)."""
         """Return the current scenario state (steps + their statuses)."""
         return self._get(f"/scenario/{job_id}")
         return self._get(f"/scenario/{job_id}")
+
+    def scan(
+        self,
+        seq_file_path: str | None = None,
+        seq_info: dict | None = None,
+        scenario_id: str = "full_pipeline",
+        protocol: str = "",
+    ) -> str:
+        """
+        Submit a scan job to the orchestrator and return the job_id immediately.
+
+        Provide either:
+          - seq_file_path: path to a .seq file → orchestrator interprets it via seq-interp
+          - seq_info: already-interpreted parameters dict
+
+        Use poll_scan(job_id) to wait for completion.
+        """
+        import json as _json
+        import httpx
+
+        data: dict[str, str] = {
+            "scenario_id": scenario_id,
+            "protocol": protocol,
+        }
+        files = None
+
+        if seq_file_path:
+            import os as _os
+            with open(seq_file_path, "rb") as fh:
+                file_bytes = fh.read()
+            files = {
+                "file": (_os.path.basename(seq_file_path), file_bytes, "application/octet-stream")
+            }
+        elif seq_info:
+            data["seq_info_json"] = _json.dumps(seq_info)
+
+        try:
+            r = httpx.post(
+                f"{self.base_url}/scan/",
+                data=data,
+                files=files,
+                timeout=httpx.Timeout(connect=5.0, read=60.0, write=60.0, pool=5.0),
+            )
+        except httpx.ConnectError as exc:
+            raise OrchestratorError(f"Cannot connect to orchestrator at {self.base_url}") from exc
+        except httpx.TimeoutException as exc:
+            raise OrchestratorError("Orchestrator request timed out") from exc
+
+        if not r.is_success:
+            raise OrchestratorError(r.text, status_code=r.status_code)
+        return r.json()["job_id"]
+
+    def poll_scan(
+        self,
+        job_id: str,
+        timeout: float = 300.0,
+        poll_interval: float = 2.0,
+        progress_cb=None,
+        interrupted_fn=None,
+    ) -> dict:
+        """
+        Block until the scan job finishes (status == 'done' or starts with 'failed').
+        Returns the final scenario dict from GET /scenario/{job_id}.
+        Raises OrchestratorError on timeout or failure.
+
+        interrupted_fn: optional callable() -> bool; if it returns True the loop
+        exits immediately with OrchestratorError("cancelled").
+        """
+        import time
+        deadline = time.monotonic() + timeout
+        last_status = ""
+        step = 0.25  # sleep granularity for responsive cancellation
+
+        while time.monotonic() < deadline:
+            # Sleep in small steps so a cancellation request is noticed quickly
+            elapsed = 0.0
+            while elapsed < poll_interval:
+                if interrupted_fn and interrupted_fn():
+                    raise OrchestratorError("cancelled")
+                time.sleep(step)
+                elapsed += step
+            doc = self.get_status(job_id)
+            status = doc.get("status", "")
+
+            if status != last_status:
+                last_status = status
+                if progress_cb:
+                    progress_cb(status)
+
+            if status == "done":
+                return doc
+            if status.startswith("failed"):
+                raise OrchestratorError(f"Scan job failed: {status}")
+
+        raise OrchestratorError(f"Scan job timed out after {timeout}s (job_id={job_id})")

+ 167 - 0
apps/gui/src/tabs/scanner_tab.py

@@ -30,6 +30,20 @@ _STATUS_COLORS = {
 
 
 _POLL_INTERVAL_MS = 1500
 _POLL_INTERVAL_MS = 1500
 
 
+# Service status bar
+_SVC_POLL_INTERVAL_MS = 5_000
+
+_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;",
+}
+
 
 
 class ScannerTab(QWidget):
 class ScannerTab(QWidget):
     """Orchestrator-based scanner control panel."""
     """Orchestrator-based scanner control panel."""
@@ -38,11 +52,21 @@ class ScannerTab(QWidget):
         self,
         self,
         hw_config_path: str | None = None,
         hw_config_path: str | None = None,
         orchestrator_url: str = "http://localhost:1717",
         orchestrator_url: str = "http://localhost:1717",
+        seq_interp_url: str = "http://localhost:7475",
+        reconstructor_url: str = "http://localhost:8081",
+        spectroscopy_url: str = "http://localhost:8002",
+        spectrometer_url: str = "http://localhost:8000",
         parent: QWidget | None = None,
         parent: QWidget | None = None,
     ) -> None:
     ) -> None:
         super().__init__(parent)
         super().__init__(parent)
 
 
         self._hw_config_path = hw_config_path
         self._hw_config_path = hw_config_path
+        self._orchestrator_url = orchestrator_url.rstrip("/")
+        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._client = OrchestratorClient(orchestrator_url)
         self._job_id: str | None = None
         self._job_id: str | None = None
         self._seq_info: dict | None = None
         self._seq_info: dict | None = None
@@ -55,8 +79,18 @@ class ScannerTab(QWidget):
         self._poll_timer.setInterval(_POLL_INTERVAL_MS)
         self._poll_timer.setInterval(_POLL_INTERVAL_MS)
         self._poll_timer.timeout.connect(self._poll_status)
         self._poll_timer.timeout.connect(self._poll_status)
 
 
+        # Service status polling
+        self._svc_workers: dict[str, OrchestratorWorker] = {}
+        self._svc_timer = QTimer(self)
+        self._svc_timer.setInterval(_SVC_POLL_INTERVAL_MS)
+        self._svc_timer.timeout.connect(self._poll_all_services)
+
         self._build_layout()
         self._build_layout()
 
 
+        # Kick off initial check after the event loop starts
+        QTimer.singleShot(300, self._poll_all_services)
+        self._svc_timer.start()
+
     # ================================================================== #
     # ================================================================== #
     #  Public API                                                          #
     #  Public API                                                          #
     # ================================================================== #
     # ================================================================== #
@@ -65,6 +99,25 @@ class ScannerTab(QWidget):
         self._hw_config_path = path
         self._hw_config_path = path
         self._append_log(f"HW config: {path}")
         self._append_log(f"HW config: {path}")
 
 
+    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.
+        """
+        self._job_id = job_id
+        short = job_id[:24] + "..." if len(job_id) > 24 else job_id
+        self._job_label.setText(short)
+        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)
+
+        # 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:
     def apply_seq_info(self, info_dict: dict) -> None:
         """Receive sequence info from SeqInterpTab after export."""
         """Receive sequence info from SeqInterpTab after export."""
         self._seq_info = info_dict
         self._seq_info = info_dict
@@ -88,6 +141,7 @@ class ScannerTab(QWidget):
         root.setContentsMargins(6, 6, 6, 6)
         root.setContentsMargins(6, 6, 6, 6)
         root.setSpacing(6)
         root.setSpacing(6)
 
 
+        root.addWidget(self._build_services_bar())
         root.addWidget(self._build_connection_bar())
         root.addWidget(self._build_connection_bar())
 
 
         split = QSplitter(Qt.Horizontal)
         split = QSplitter(Qt.Horizontal)
@@ -96,6 +150,112 @@ class ScannerTab(QWidget):
         split.setSizes([280, 720])
         split.setSizes([280, 720])
         root.addWidget(split, stretch=1)
         root.addWidget(split, stretch=1)
 
 
+    # ================================================================== #
+    #  Services status bar                                                #
+    # ================================================================== #
+
+    def _build_services_bar(self) -> QWidget:
+        """Horizontal strip showing live status of every microservice."""
+        bar = QWidget()
+        bar.setStyleSheet(
+            "QWidget { background: #1a1a2e; border-radius: 4px; }"
+        )
+        lay = QHBoxLayout(bar)
+        lay.setContentsMargins(8, 4, 8, 4)
+        lay.setSpacing(0)
+
+        title = QLabel("Сервисы:")
+        title.setStyleSheet("color: #555577; font-size: 11px; background: transparent;")
+        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),
+        ]
+
+        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"])
+            lbl.setToolTip(url)
+            self._svc_labels[key] = lbl
+            lay.addWidget(lbl)
+            if i < len(self._svc_defs) - 1:
+                sep = QLabel("  |  ")
+                sep.setStyleSheet("color: #333355; background: transparent;")
+                lay.addWidget(sep)
+
+        lay.addStretch()
+
+        btn_refresh = QPushButton("↻")
+        btn_refresh.setFixedSize(22, 22)
+        btn_refresh.setToolTip("Проверить статусы сейчас")
+        btn_refresh.setStyleSheet(
+            "QPushButton { background: #252540; color: #7777aa;"
+            "  border: 1px solid #333355; border-radius: 3px; font-size: 13px; }"
+            "QPushButton:hover { color: #ffffff; background: #303060; }"
+        )
+        btn_refresh.clicked.connect(self._poll_all_services)
+        lay.addWidget(btn_refresh)
+
+        return bar
+
+    def _poll_all_services(self) -> None:
+        """Kick off background health checks for every service."""
+        import httpx
+
+        def _check(url: str) -> bool:
+            try:
+                r = httpx.get(url, timeout=3.0)
+                return r.status_code < 500
+            except Exception:
+                return False
+
+        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))
+            )
+            worker.error.connect(
+                lambda _msg, k=key: self._on_svc_checked(k, False)
+            )
+            worker.start()
+            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"]))
+
     def _build_connection_bar(self) -> QWidget:
     def _build_connection_bar(self) -> QWidget:
         bar = QWidget()
         bar = QWidget()
         lay = QHBoxLayout(bar)
         lay = QHBoxLayout(bar)
@@ -404,6 +564,13 @@ class ScannerTab(QWidget):
         steps = status.get("steps", [])
         steps = status.get("steps", [])
         self._update_steps_table(steps)
         self._update_steps_table(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()
+            self._btn_abort.setEnabled(False)
+            self._append_log(f"Job finished: {job_status}")
+
     # ================================================================== #
     # ================================================================== #
     #  Steps table                                                         #
     #  Steps table                                                         #
     # ================================================================== #
     # ================================================================== #

+ 384 - 76
apps/gui/src/tabs/scanning_tab.py

@@ -13,6 +13,7 @@ from __future__ import annotations
 
 
 import math
 import math
 import json
 import json
+import os
 import numpy as np
 import numpy as np
 
 
 from PySide6.QtCore import Qt, QThread, QTimer, Signal
 from PySide6.QtCore import Qt, QThread, QTimer, Signal
@@ -21,17 +22,11 @@ from PySide6.QtGui import (
 )
 )
 from PySide6.QtCore import QPointF
 from PySide6.QtCore import QPointF
 from PySide6.QtWidgets import (
 from PySide6.QtWidgets import (
-    QButtonGroup, QDoubleSpinBox, QFormLayout, QGridLayout,
-    QGroupBox, QHBoxLayout, QLabel, QListWidget, QMessageBox,
-    QPushButton, QSplitter, QTabWidget, QVBoxLayout, QWidget,
+    QButtonGroup, QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QFrame,
+    QGridLayout, QGroupBox, QHBoxLayout, QLabel, QListWidget, QMessageBox,
+    QPushButton, QSplitter, QTabWidget, QTextEdit, QVBoxLayout, QWidget,
 )
 )
 
 
-try:
-    import httpx as _httpx
-    _HAS_HTTPX = True
-except ImportError:
-    _HAS_HTTPX = False
-
 # -- colour palette -------------------------------------------------------------
 # -- colour palette -------------------------------------------------------------
 _BG_DARK      = "#1a1a2e"
 _BG_DARK      = "#1a1a2e"
 _PANEL_BG     = "#2a2a2a"
 _PANEL_BG     = "#2a2a2a"
@@ -358,61 +353,119 @@ class ProtocolListWidget(QWidget):
 
 
 
 
 # ==============================================================================
 # ==============================================================================
-class _ScanWorker(QThread):
-    """Fire-and-forget: load scenario and run_all via orchestrator REST."""
+class _ScanPipelineWorker(QThread):
+    """
+    Thin worker: sends everything to the orchestrator via POST /scan/ and
+    polls GET /scenario/{job_id} until done.
 
 
-    finished = Signal(str)   # job_id or success message
-    error    = Signal(str)
+    The orchestrator is responsible for:
+      - Forwarding the .seq file to seq-interp for interpretation
+      - Running the full measurement scenario (spectrometer, reconstructor, …)
 
 
-    def __init__(self, url: str, info: dict, parent=None) -> None:
+    The GUI never calls seq-interp or any other microservice directly.
+    """
+
+    progress       = Signal(str)
+    job_started    = Signal(str)   # job_id received from orchestrator
+    finished       = Signal(str)
+    error          = Signal(str)
+    raw_data_ready = Signal(str)   # absolute path to temp JSON file
+
+    def __init__(
+        self,
+        seq_file_path: str | None,
+        seq_info: dict | None,
+        orchestrator_url: str,
+        scenario_id: str = "full_pipeline",
+        protocol: str = "",
+        parent=None,
+    ) -> None:
         super().__init__(parent)
         super().__init__(parent)
-        self._url  = url.rstrip("/")
-        self._info = info
+        self._seq_file    = seq_file_path
+        self._seq_info    = dict(seq_info) if seq_info else {}
+        self._scenario_id = scenario_id
+        self._protocol    = protocol
+        from src.clients.orchestrator_client import OrchestratorClient
+        self._orch = OrchestratorClient(orchestrator_url)
+
+    # -- main run -----------------------------------------------------------
 
 
     def run(self) -> None:
     def run(self) -> None:
         try:
         try:
-            if not _HAS_HTTPX:
-                import urllib.request, urllib.error
-                self._run_urllib()
-            else:
-                self._run_httpx()
+            self._run_pipeline()
         except Exception as exc:
         except Exception as exc:
-            self.error.emit(str(exc))
-
-    def _run_httpx(self) -> None:
-        payload = {"param_overrides": {"start_measurement": {"info": self._info}}}
-        with _httpx.Client(timeout=15) as client:
-            r = client.post(
-                f"{self._url}/scenario/load/full_pipeline",
-                json=payload,
-            )
-            r.raise_for_status()
-            job_id = r.json().get("job_id", "?")
-            r2 = client.post(f"{self._url}/scenario/{job_id}/run_all")
-            r2.raise_for_status()
-            self.finished.emit(f"job_id={job_id}")
-
-    def _run_urllib(self) -> None:
-        import urllib.request
-        payload = {"param_overrides": {"start_measurement": {"info": self._info}}}
-        data    = json.dumps(payload).encode()
-        headers = {"Content-Type": "application/json"}
-
-        req  = urllib.request.Request(
-            f"{self._url}/scenario/load/full_pipeline",
-            data=data, headers=headers, method="POST",
+            if not self.isInterruptionRequested():
+                self.error.emit(str(exc))
+
+    def _run_pipeline(self) -> None:
+        # 1. Quick orchestrator reachability check
+        self.progress.emit("Проверка оркестратора...")
+        if not self._orch.healthcheck():
+            raise RuntimeError("Оркестратор недоступен — проверьте, что сервис запущен")
+
+        # 2. Submit the scan job — orchestrator handles interpretation + pipeline
+        self.progress.emit(
+            f"Отправка задания в оркестратор "
+            f"[сценарий: {self._scenario_id}, ИП: {self._protocol or '—'}]…"
         )
         )
-        with urllib.request.urlopen(req, timeout=15) as resp:
-            body   = json.loads(resp.read())
-            job_id = body.get("job_id", "?")
-
-        req2 = urllib.request.Request(
-            f"{self._url}/scenario/{job_id}/run_all",
-            data=b"{}", headers=headers, method="POST",
+        job_id = self._orch.scan(
+            seq_file_path=self._seq_file or None,
+            seq_info=self._seq_info or None,
+            scenario_id=self._scenario_id,
+            protocol=self._protocol,
+        )
+        self.job_started.emit(job_id)
+        self.progress.emit(f"  Job {job_id[:8]}… запущен, ожидание результата…")
+
+        # 3. Poll until done (orchestrator runs everything in background)
+        def _on_status(status: str) -> None:
+            self.progress.emit(f"  [{status}]")
+
+        final = self._orch.poll_scan(
+            job_id,
+            timeout=300.0,
+            poll_interval=2.0,
+            progress_cb=_on_status,
+            interrupted_fn=self.isInterruptionRequested,
         )
         )
-        with urllib.request.urlopen(req2, timeout=15):
-            pass
-        self.finished.emit(f"job_id={job_id}")
+
+        # 4. Extract raw measurement data from step results
+        steps    = final.get("steps", [])
+        meas_id  = None
+        raw_data = None
+        for step in steps:
+            name = step.get("name")
+            res  = step.get("result") or {}
+            if name == "start_measurement":
+                meas_id = res.get("measurement_id")
+            if name == "fetch_data":
+                raw_data = res.get("data")
+
+        self.progress.emit(f"  Сканирование завершено (meas_id={meas_id})")
+
+        # 5. Persist raw data for Spectroscopy tab
+        is_stub = str(meas_id) in ("", "None", "meas_stub") or meas_id is None
+        if raw_data and not is_stub:
+            raw_path = self._save_raw(raw_data, meas_id)
+            if raw_path:
+                self.raw_data_ready.emit(raw_path)
+
+        self.finished.emit(f"job завершён (meas_id={meas_id})")
+
+    # -- raw data persistence -----------------------------------------------
+
+    def _save_raw(self, data, meas_id) -> str | None:
+        import tempfile, time as _t
+        fname = f"scan_raw_{meas_id}_{int(_t.time())}.json"
+        fpath = os.path.join(tempfile.gettempdir(), fname)
+        try:
+            with open(fpath, "w", encoding="utf-8") as fh:
+                json.dump(data, fh)
+            self.progress.emit(f"  Данные сохранены: {fname}")
+            return fpath
+        except Exception as exc:
+            self.progress.emit(f"  Не удалось сохранить данные: {exc}")
+            return None
 
 
 
 
 # ==============================================================================
 # ==============================================================================
@@ -426,17 +479,22 @@ class ScanningTab(QWidget):
     "Геометрия" holds orientation presets + Rx/Ry/Rz spinboxes + live 3x3 matrix.
     "Геометрия" holds orientation presets + Rx/Ry/Rz spinboxes + live 3x3 matrix.
     """
     """
 
 
+    scan_job_started = Signal(str)  # job_id — forwarded to ScannerTab for queue monitoring
+    raw_data_ready   = Signal(str)  # absolute path to temp JSON file
+
     def __init__(self, parent: QWidget | None = None) -> None:
     def __init__(self, parent: QWidget | None = None) -> None:
         super().__init__(parent)
         super().__init__(parent)
         self.setStyleSheet(f"background: {_BG_DARK};")
         self.setStyleSheet(f"background: {_BG_DARK};")
 
 
-        self._viewers:          list[MriViewerWidget] = []
-        self._scan_tick:        int                   = 0
-        self._seq_info:         dict | None           = None
-        self._orchestrator_url: str                   = "http://localhost:1717"
-        self._scan_worker:      _ScanWorker | None    = None
-        self._active_protocol:  str                   = _PROTOCOLS[0]
-        self._slice_offset:     list[float]           = [0.0, 0.0, 0.0]
+        self._viewers:          list[MriViewerWidget]      = []
+        self._scan_tick:        int                        = 0
+        self._seq_info:         dict | None                = None
+        self._seq_file_path:    str | None                 = None
+        self._orchestrator_url: str                        = "http://localhost:1717"
+        self._scan_worker:      _ScanPipelineWorker | None = None
+        self._active_protocol:  str                        = _PROTOCOLS[0]
+        self._scenario_id:      str                        = "full_pipeline"
+        self._slice_offset:     list[float]                = [0.0, 0.0, 0.0]
 
 
         root = QVBoxLayout(self)
         root = QVBoxLayout(self)
         root.setContentsMargins(0, 0, 0, 0)
         root.setContentsMargins(0, 0, 0, 0)
@@ -458,9 +516,14 @@ class ScanningTab(QWidget):
     # -- public API ---------------------------------------------------------
     # -- public API ---------------------------------------------------------
 
 
     def apply_seq_info(self, info_dict: dict) -> None:
     def apply_seq_info(self, info_dict: dict) -> None:
-        """Receive exported sequence info from SeqInterpTab."""
+        """Receive exported sequence info from SeqInterpTab and auto-start scan."""
         self._seq_info = dict(info_dict)
         self._seq_info = dict(info_dict)
+        self._seq_file_path = None
         self._update_scan_ready_state()
         self._update_scan_ready_state()
+        label = info_dict.get("infostr") or "sequence"
+        self._log(f"Получены параметры последовательности: {label}", "INFO")
+        if not self._btn_scan.isChecked():
+            self._btn_scan.setChecked(True)
 
 
     def set_orchestrator_url(self, url: str) -> None:
     def set_orchestrator_url(self, url: str) -> None:
         self._orchestrator_url = url
         self._orchestrator_url = url
@@ -484,9 +547,100 @@ class ScanningTab(QWidget):
         return container
         return container
 
 
     def _build_protocol_panel(self) -> QWidget:
     def _build_protocol_panel(self) -> QWidget:
+        container = QWidget()
+        container.setStyleSheet(f"background: {_PANEL_BG};")
+        lay = QVBoxLayout(container)
+        lay.setContentsMargins(0, 0, 0, 0)
+        lay.setSpacing(0)
+
         self._protocol_list = ProtocolListWidget()
         self._protocol_list = ProtocolListWidget()
         self._protocol_list.protocol_selected.connect(self._on_protocol_selected)
         self._protocol_list.protocol_selected.connect(self._on_protocol_selected)
-        return self._protocol_list
+        lay.addWidget(self._protocol_list, stretch=1)
+
+        sep = QFrame()
+        sep.setFrameShape(QFrame.HLine)
+        sep.setFixedHeight(1)
+        sep.setStyleSheet("background: #3a3a4a; border: none;")
+        lay.addWidget(sep)
+
+        # -- Scenario selector ------------------------------------------------
+        scenario_container = QWidget()
+        scenario_container.setStyleSheet(f"background: {_PANEL_BG};")
+        sc_lay = QVBoxLayout(scenario_container)
+        sc_lay.setContentsMargins(6, 6, 6, 4)
+        sc_lay.setSpacing(4)
+
+        sc_header = QLabel("Сценарий оркестратора")
+        sc_header.setStyleSheet(
+            "color: #7777aa; font-size: 10px; font-weight: bold; background: transparent;"
+        )
+        sc_lay.addWidget(sc_header)
+
+        sc_row = QHBoxLayout()
+        sc_row.setSpacing(4)
+
+        _combo_style = (
+            "QComboBox {"
+            f"  background: {_BTN_BG}; color: #ccccee;"
+            "  border: 1px solid #444466; border-radius: 3px;"
+            "  font-size: 11px; padding: 3px 6px;"
+            "}"
+            "QComboBox::drop-down { border: none; width: 16px; }"
+            "QComboBox QAbstractItemView {"
+            f"  background: {_BTN_BG}; color: #ccccee; border: 1px solid #444466;"
+            "  selection-background-color: #e65100;"
+            "}"
+        )
+        self._scenario_combo = QComboBox()
+        self._scenario_combo.setStyleSheet(_combo_style)
+        self._scenario_combo.setToolTip("Тип сценария, который будет запущен в оркестраторе")
+        self._scenario_combo.addItem("full_pipeline")   # default
+        self._scenario_combo.currentTextChanged.connect(self._on_scenario_selected)
+        sc_row.addWidget(self._scenario_combo, stretch=1)
+
+        btn_refresh_sc = QPushButton("↻")
+        btn_refresh_sc.setFixedSize(24, 24)
+        btn_refresh_sc.setToolTip("Получить список сценариев из оркестратора")
+        btn_refresh_sc.setStyleSheet(
+            f"QPushButton {{ background: {_BTN_BG}; color: #7777aa;"
+            "  border: 1px solid #444466; border-radius: 3px; font-size: 13px; }}"
+            "QPushButton:hover { color: #ffffff; background: #303050; }"
+        )
+        btn_refresh_sc.clicked.connect(self._on_refresh_scenarios)
+        sc_row.addWidget(btn_refresh_sc)
+
+        sc_lay.addLayout(sc_row)
+        lay.addWidget(scenario_container)
+
+        sep2 = QFrame()
+        sep2.setFrameShape(QFrame.HLine)
+        sep2.setFixedHeight(1)
+        sep2.setStyleSheet("background: #3a3a4a; border: none;")
+        lay.addWidget(sep2)
+
+        # -- .seq file loader -------------------------------------------------
+        btn_load = QPushButton("Загрузить .seq…")
+        btn_load.setToolTip("Выбрать готовый .seq файл для запуска полного пайплайна")
+        btn_load.setStyleSheet(
+            "QPushButton {"
+            f"  background: {_BTN_BG}; color: #aaaacc;"
+            "  border: 1px solid #444466; border-radius: 3px;"
+            "  font-size: 11px; padding: 5px 8px; margin: 6px 6px 2px 6px;"
+            "}"
+            "QPushButton:hover { background: #303050; color: #ffffff; }"
+        )
+        btn_load.clicked.connect(self._on_load_seq_clicked)
+        lay.addWidget(btn_load)
+
+        self._lbl_seq_file = QLabel("Файл не выбран")
+        self._lbl_seq_file.setWordWrap(True)
+        self._lbl_seq_file.setStyleSheet(
+            "color: #555577; font-size: 10px; background: transparent;"
+            "padding: 0 8px 6px 8px;"
+        )
+        lay.addWidget(self._lbl_seq_file)
+
+        return container
 
 
     def _build_image_grid(self) -> QWidget:
     def _build_image_grid(self) -> QWidget:
         container = QWidget()
         container = QWidget()
@@ -534,6 +688,10 @@ class ScanningTab(QWidget):
 
 
         geo_tab = self._build_geometry_tab()
         geo_tab = self._build_geometry_tab()
         self._param_tabs.addTab(geo_tab, "Геометрия")
         self._param_tabs.addTab(geo_tab, "Геометрия")
+
+        log_tab = self._build_log_tab()
+        self._param_tabs.addTab(log_tab, "Лог")
+
         self._param_tabs.setCurrentWidget(geo_tab)
         self._param_tabs.setCurrentWidget(geo_tab)
 
 
         outer.addWidget(self._param_tabs, stretch=1)
         outer.addWidget(self._param_tabs, stretch=1)
@@ -550,7 +708,7 @@ class ScanningTab(QWidget):
         action_lay.addWidget(self._status_label)
         action_lay.addWidget(self._status_label)
         action_lay.addStretch()
         action_lay.addStretch()
 
 
-        self._btn_scan = QPushButton("Run  Сканировать")
+        self._btn_scan = QPushButton("Сканировать")
         self._btn_scan.setCheckable(True)
         self._btn_scan.setCheckable(True)
         self._btn_scan.setMinimumWidth(140)
         self._btn_scan.setMinimumWidth(140)
         self._btn_scan.setStyleSheet(
         self._btn_scan.setStyleSheet(
@@ -647,6 +805,58 @@ class ScanningTab(QWidget):
         lay.addStretch()
         lay.addStretch()
         return w
         return w
 
 
+    def _build_log_tab(self) -> QWidget:
+        w = QWidget()
+        w.setStyleSheet("background: #16162a;")
+        lay = QVBoxLayout(w)
+        lay.setContentsMargins(6, 6, 6, 4)
+        lay.setSpacing(4)
+
+        self._log_view = QTextEdit()
+        self._log_view.setReadOnly(True)
+        self._log_view.setFont(QFont("Courier New", 9))
+        self._log_view.setStyleSheet(
+            "QTextEdit {"
+            "  background: #0e0e1c; color: #aaaacc;"
+            "  border: 1px solid #2a2a4a; border-radius: 3px;"
+            "}"
+        )
+        lay.addWidget(self._log_view, stretch=1)
+
+        btn_clear = QPushButton("Очистить")
+        btn_clear.setFixedWidth(90)
+        btn_clear.setStyleSheet(
+            "QPushButton {"
+            f"  background: {_BTN_BG}; color: #777799;"
+            "  border: 1px solid #333355; border-radius: 3px;"
+            "  font-size: 10px; padding: 3px 8px;"
+            "}"
+            "QPushButton:hover { color: #aaaacc; }"
+        )
+        btn_clear.clicked.connect(self._log_view.clear)
+        lay.addWidget(btn_clear, alignment=Qt.AlignRight)
+
+        return w
+
+    # level: "INFO" | "WARN" | "ERR"
+    _LOG_COLORS = {"INFO": "#aaaacc", "WARN": "#e6a817", "ERR": "#ee4444"}
+
+    def _log(self, msg: str, level: str = "INFO") -> None:
+        """Append [LEVEL] HH:MM:SS  message to the log and auto-scroll."""
+        if not hasattr(self, "_log_view"):
+            return
+        from datetime import datetime
+        ts    = datetime.now().strftime("%H:%M:%S")
+        color = self._LOG_COLORS.get(level, "#aaaacc")
+        line  = (
+            f"<span style='color:#555577'>{ts}</span>"
+            f"  <span style='color:{color}'>[{level}]</span>"
+            f"  {msg}"
+        )
+        self._log_view.append(line)
+        sb = self._log_view.verticalScrollBar()
+        sb.setValue(sb.maximum())
+
     @staticmethod
     @staticmethod
     def _group_style() -> str:
     def _group_style() -> str:
         return (
         return (
@@ -760,56 +970,154 @@ class ScanningTab(QWidget):
             btn.setChecked(name == matched)
             btn.setChecked(name == matched)
             btn.blockSignals(False)
             btn.blockSignals(False)
 
 
+    # -- .seq file loading --------------------------------------------------
+
+    def _on_load_seq_clicked(self) -> None:
+        path, _ = QFileDialog.getOpenFileName(
+            self,
+            "Выбрать .seq файл",
+            os.path.join(os.path.dirname(__file__), os.pardir, os.pardir),
+            "Pulseq файлы (*.seq);;Все файлы (*)",
+        )
+        if not path:
+            return
+        self._seq_file_path = path
+        self._seq_info = None
+        fname = os.path.basename(path)
+        self._lbl_seq_file.setText(fname)
+        self._lbl_seq_file.setStyleSheet(
+            "color: #e65100; font-size: 10px; background: transparent;"
+            "padding: 0 8px 6px 8px;"
+        )
+        self._log(f"Загружен файл: {fname}", "INFO")
+        self._update_scan_ready_state()
+
     # -- scan initiation ----------------------------------------------------
     # -- scan initiation ----------------------------------------------------
 
 
     def _on_scan_toggled(self, checked: bool) -> None:
     def _on_scan_toggled(self, checked: bool) -> None:
         if checked:
         if checked:
-            if self._seq_info is None:
+            if self._scan_worker is not None and self._scan_worker.isRunning():
+                return
+            if self._seq_info is None and self._seq_file_path is None:
                 QMessageBox.warning(
                 QMessageBox.warning(
                     self, "Нет данных",
                     self, "Нет данных",
-                    "Сначала загрузите и экспортируйте последовательность\n"
-                    'во вкладке "Sequence".'
+                    "Загрузите .seq файл кнопкой «Загрузить .seq…»\n"
+                    "или экспортируйте последовательность во вкладке «Sequence»."
                 )
                 )
                 self._btn_scan.setChecked(False)
                 self._btn_scan.setChecked(False)
                 return
                 return
-            info = dict(self._seq_info)
-            info["rotation_matrix"] = self._compute_rotation_matrix()
-            info["slice_position"]  = list(self._slice_offset)
-            self._scan_worker = _ScanWorker(self._orchestrator_url, info, parent=self)
+            info = dict(self._seq_info) if self._seq_info else {}
+            if info:
+                info["rotation_matrix"] = self._compute_rotation_matrix()
+                info["slice_position"]  = list(self._slice_offset)
+            self._scan_worker = _ScanPipelineWorker(
+                seq_file_path=self._seq_file_path,
+                seq_info=info if info else None,
+                orchestrator_url=self._orchestrator_url,
+                scenario_id=self._scenario_id,
+                protocol=self._active_protocol,
+                parent=self,
+            )
+            self._scan_worker.job_started.connect(self.scan_job_started)
             self._scan_worker.finished.connect(self._on_scan_done)
             self._scan_worker.finished.connect(self._on_scan_done)
             self._scan_worker.error.connect(self._on_scan_error)
             self._scan_worker.error.connect(self._on_scan_error)
+            self._scan_worker.progress.connect(self._on_scan_progress)
+            self._scan_worker.raw_data_ready.connect(self._on_raw_data_ready_log)
+            self._scan_worker.raw_data_ready.connect(self.raw_data_ready)
             self._scan_worker.start()
             self._scan_worker.start()
             self._scan_timer.start()
             self._scan_timer.start()
             self._btn_scan.setText("Stop  Стоп")
             self._btn_scan.setText("Stop  Стоп")
-            self._status_label.setText("Сканирование...")
+            self._status_label.setText("Инициализация пайплайна…")
             self._status_label.setStyleSheet("color: #88ee88; font-size: 11px;")
             self._status_label.setStyleSheet("color: #88ee88; font-size: 11px;")
+            self._log("--- Запуск пайплайна сканирования ---", "INFO")
             for v in self._viewers:
             for v in self._viewers:
                 v.set_scanning(True)
                 v.set_scanning(True)
+            # Switch to Log tab so the user can follow progress
+            self._param_tabs.setCurrentIndex(
+                self._param_tabs.indexOf(self._log_view.parent())
+            )
         else:
         else:
             self._scan_timer.stop()
             self._scan_timer.stop()
+            if self._scan_worker and self._scan_worker.isRunning():
+                self._scan_worker.requestInterruption()
             self._btn_scan.setText("Run  Сканировать")
             self._btn_scan.setText("Run  Сканировать")
             for v in self._viewers:
             for v in self._viewers:
                 v.set_scanning(False)
                 v.set_scanning(False)
             self._update_scan_ready_state()
             self._update_scan_ready_state()
 
 
+    def _on_raw_data_ready_log(self, path: str) -> None:
+        self._log(f"Данные отправлены во вкладку Spectroscopy: {os.path.basename(path)}", "INFO")
+
+    def _on_scan_progress(self, msg: str) -> None:
+        self._status_label.setText(msg[:80])
+        self._status_label.setStyleSheet("color: #88ee88; font-size: 11px;")
+        self._log(msg, "INFO")
+
     def _on_scan_done(self, msg: str) -> None:
     def _on_scan_done(self, msg: str) -> None:
-        self._status_label.setText(f"Готово ({msg})")
+        self._scan_timer.stop()
+        self._status_label.setText("Готово")
         self._status_label.setStyleSheet("color: #66ccff; font-size: 11px;")
         self._status_label.setStyleSheet("color: #66ccff; font-size: 11px;")
+        self._log(msg, "INFO")
+        for v in self._viewers:
+            v.set_scanning(False)
         self._btn_scan.setChecked(False)
         self._btn_scan.setChecked(False)
 
 
     def _on_scan_error(self, err: str) -> None:
     def _on_scan_error(self, err: str) -> None:
-        self._status_label.setText(f"Ошибка: {err[:60]}")
+        self._scan_timer.stop()
+        self._status_label.setText(f"Ошибка: {err[:70]}")
         self._status_label.setStyleSheet("color: #ee4444; font-size: 11px;")
         self._status_label.setStyleSheet("color: #ee4444; font-size: 11px;")
+        self._log(err, "ERR")
+        for v in self._viewers:
+            v.set_scanning(False)
         self._btn_scan.setChecked(False)
         self._btn_scan.setChecked(False)
 
 
     def _update_scan_ready_state(self) -> None:
     def _update_scan_ready_state(self) -> None:
-        if self._seq_info is not None:
+        if self._seq_file_path:
+            fname = os.path.basename(self._seq_file_path)
+            self._status_label.setText(f"Файл: {fname}")
+            self._status_label.setStyleSheet("color: #e65100; font-size: 11px;")
+        elif self._seq_info is not None:
             self._status_label.setText("Готово к сканированию")
             self._status_label.setText("Готово к сканированию")
             self._status_label.setStyleSheet("color: #e65100; font-size: 11px;")
             self._status_label.setStyleSheet("color: #e65100; font-size: 11px;")
         else:
         else:
             self._status_label.setText("Нет данных")
             self._status_label.setText("Нет данных")
             self._status_label.setStyleSheet("color: #666688; font-size: 11px;")
             self._status_label.setStyleSheet("color: #666688; font-size: 11px;")
 
 
+    # -- scenario selection -------------------------------------------------
+
+    def _on_scenario_selected(self, name: str) -> None:
+        if name:
+            self._scenario_id = name
+
+    def _on_refresh_scenarios(self) -> None:
+        """Fetch available scenario IDs from the orchestrator."""
+        import httpx
+        try:
+            r = httpx.get(
+                f"{self._orchestrator_url}/scenario/list",
+                timeout=5.0,
+            )
+            if r.is_success:
+                scenarios: list[str] = r.json().get("scenarios", [])
+                if scenarios:
+                    current = self._scenario_combo.currentText()
+                    self._scenario_combo.blockSignals(True)
+                    self._scenario_combo.clear()
+                    for s in scenarios:
+                        self._scenario_combo.addItem(s)
+                    # restore previous selection if still available
+                    idx = self._scenario_combo.findText(current)
+                    self._scenario_combo.setCurrentIndex(max(idx, 0))
+                    self._scenario_combo.blockSignals(False)
+                    self._scenario_id = self._scenario_combo.currentText()
+                    self._log(f"Сценарии загружены: {scenarios}", "INFO")
+                else:
+                    self._log("Оркестратор вернул пустой список сценариев", "WARN")
+            else:
+                self._log(f"Ошибка загрузки сценариев: HTTP {r.status_code}", "WARN")
+        except Exception as exc:
+            self._log(f"Не удалось подключиться к оркестратору: {exc}", "WARN")
+
     # -- protocol selection -------------------------------------------------
     # -- protocol selection -------------------------------------------------
 
 
     def _on_protocol_selected(self, name: str) -> None:
     def _on_protocol_selected(self, name: str) -> None:

+ 12 - 0
apps/gui/src/tabs/spectroscopy_tab.py

@@ -130,6 +130,18 @@ class SpectroscopyTab(QWidget):
     def set_spectroscopy_url(self, url: str) -> None:
     def set_spectroscopy_url(self, url: str) -> None:
         self._client = SpectroscopyClient(url)
         self._client = SpectroscopyClient(url)
 
 
+    def receive_scan_data(self, json_path: str) -> None:
+        """
+        Called automatically after a scan completes: load the raw JSON file
+        and start NMR analysis without user interaction.
+        """
+        if not os.path.isfile(json_path):
+            return
+        self._last_json_path = json_path
+        self._update_data_ranges_from_file(json_path)
+        self._btn_analyze.setEnabled(True)
+        self._run_analysis(json_path)
+
     def current_params(self) -> dict:
     def current_params(self) -> dict:
         """Return current NMR parameters as a plain dict."""
         """Return current NMR parameters as a plain dict."""
         return {
         return {

+ 0 - 3
docker-compose.yml

@@ -14,9 +14,6 @@ services:
       SPECTROMETER_URL: http://spectrometer:8000
       SPECTROMETER_URL: http://spectrometer:8000
       RECONSTRUCTOR_URL: http://reconstructor:8000
       RECONSTRUCTOR_URL: http://reconstructor:8000
       SEQ_INTERP_URL: http://seq-interp:7475
       SEQ_INTERP_URL: http://seq-interp:7475
-    depends_on:
-      seq-interp:
-        condition: service_healthy
     restart: unless-stopped
     restart: unless-stopped
     healthcheck:
     healthcheck:
       test: ["CMD", "curl", "-f", "http://localhost:1717/health"]
       test: ["CMD", "curl", "-f", "http://localhost:1717/health"]

+ 138 - 1
services/orchestrator/orchestrator/main.py

@@ -1,7 +1,9 @@
 import uuid
 import uuid
+import json
 import os
 import os
+import threading
 from typing import Any, Dict, Optional
 from typing import Any, Dict, Optional
-from fastapi import FastAPI, HTTPException, Query
+from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
 from pydantic import BaseModel
 from pydantic import BaseModel
 from .scenario import Scenario, Step, StepStatus
 from .scenario import Scenario, Step, StepStatus
 from .docstore import JobDoc
 from .docstore import JobDoc
@@ -31,6 +33,31 @@ def health():
     return {"status": "ok"}
     return {"status": "ok"}
 
 
 
 
+@app.get("/spectrometer/health")
+def spectrometer_health():
+    """Check connectivity to the spectrometer service."""
+    spec_url  = os.getenv("SPECTROMETER_URL",      "http://localhost:8000")
+    spec_user = os.getenv("SPECTROMETER_USER",     "admin")
+    spec_pass = os.getenv("SPECTROMETER_PASSWORD", "admin")
+    try:
+        import requests
+        r = requests.get(
+            f"{spec_url}/api/",
+            auth=(spec_user, spec_pass),
+            timeout=5.0,
+        )
+        if r.status_code < 500:
+            return {"status": "ok", "spectrometer_url": spec_url}
+        raise HTTPException(
+            status_code=503,
+            detail=f"Spectrometer returned HTTP {r.status_code}",
+        )
+    except requests.ConnectionError as exc:
+        raise HTTPException(status_code=503, detail=f"Spectrometer unreachable: {exc}")
+    except requests.Timeout:
+        raise HTTPException(status_code=503, detail="Spectrometer health check timed out")
+
+
 # Память для живых Scenario (по job_id)
 # Память для живых Scenario (по job_id)
 SCENARIOS: Dict[str, Scenario] = {}
 SCENARIOS: Dict[str, Scenario] = {}
 # Кэш шаблонов сценариев
 # Кэш шаблонов сценариев
@@ -135,6 +162,116 @@ def get_status(job_id: str):
     return doc.scenario
     return doc.scenario
 
 
 
 
+def _build_scenario_for_job(
+    tpl: dict,
+    seq_info: dict,
+    protocol: str,
+) -> Scenario:
+    """
+    Build a Scenario from template, injecting seq_info + protocol into
+    the start_measurement step params.  Raises ValueError for unknown tasks.
+    """
+    steps = []
+    for item in tpl.get("steps", []):
+        name = item["name"]
+        params = dict(item.get("params", {}) or {})
+        if name not in TASK_REGISTRY:
+            raise ValueError(f"Unknown task name in template: {name!r}")
+        if name == "start_measurement":
+            if seq_info:
+                params["info"] = {**params.get("info", {}), **seq_info}
+            if protocol:
+                params["protocol"] = protocol
+        steps.append(Step(name=name, func=TASK_REGISTRY[name], params=params))
+    return Scenario(steps=steps)
+
+
+def _run_scan_background(
+    job_id: str,
+    file_bytes: bytes | None,
+    filename: str | None,
+    seq_info_json: str | None,
+    scenario_id: str,
+    protocol: str,
+) -> None:
+    """
+    Background thread: interpret .seq (if provided) → build scenario → run_all.
+    Saves job state to DocStore at each major step so the GUI can poll it.
+    """
+    def _save(status: str, steps: list | None = None) -> None:
+        payload: dict = {"status": status}
+        if steps is not None:
+            payload["steps"] = steps
+        JobDoc(id=job_id, scenario=payload).save()
+
+    try:
+        # 1. Interpret .seq file via seq-interp (if provided)
+        seq_info: dict = {}
+        if file_bytes and filename:
+            _save("interpreting")
+            seq_interp_url = os.getenv("SEQ_INTERP_URL", "http://seq-interp:7475")
+            from .clients.seq_interp_cl import SeqInterpClient, SeqInterpError
+            client = SeqInterpClient(seq_interp_url)
+            result = client.interpret_and_wait(file_bytes, filename)
+            post_json = result.get("post_json", {})
+            seq_info = post_json.get("info", post_json) or {}
+        elif seq_info_json:
+            seq_info = json.loads(seq_info_json)
+
+        # 2. Build scenario
+        tpl = SCENARIO_TEMPLATES.get(scenario_id)
+        if not tpl:
+            _save(f"failed: unknown scenario '{scenario_id}'")
+            return
+
+        scenario = _build_scenario_for_job(tpl, seq_info, protocol)
+        SCENARIOS[job_id] = scenario
+
+        _save("running", [step_to_dict(s) for s in scenario.steps])
+
+        # 3. Run all steps (blocking); scenario catches per-step exceptions internally
+        scenario.run_all()
+
+        _save("done", [step_to_dict(s) for s in scenario.steps])
+
+    except Exception as exc:
+        JobDoc(id=job_id, scenario={"status": f"failed: {exc}", "steps": []}).save()
+
+
+@app.post("/scan/")
+async def scan_endpoint(
+    file: UploadFile = File(None),
+    seq_info_json: str = Form(None),
+    scenario_id: str = Form("full_pipeline"),
+    protocol: str = Form(""),
+):
+    """
+    Single entry point for the GUI scan button.
+
+    Accepts either:
+      - a .seq file (multipart) → orchestrator forwards to seq-interp for interpretation
+      - seq_info_json (form field, JSON string) → already-interpreted parameters
+
+    Returns {"job_id": "..."} immediately; the pipeline runs in a background thread.
+    Poll GET /scenario/{job_id} for status / step results.
+    """
+    job_id = str(uuid.uuid4())
+    file_bytes = await file.read() if file else None
+    filename = file.filename if (file and file.filename) else None
+
+    # Register job immediately so polling can start right away
+    JobDoc(id=job_id, scenario={"status": "queued", "steps": []}).save()
+
+    threading.Thread(
+        target=_run_scan_background,
+        args=(job_id, file_bytes, filename, seq_info_json, scenario_id, protocol),
+        daemon=True,
+        name=f"scan-{job_id[:8]}",
+    ).start()
+
+    return {"job_id": job_id}
+
+
 @app.get("/measurement/{meas_id}/decode")
 @app.get("/measurement/{meas_id}/decode")
 def decode_measurement_endpoint(
 def decode_measurement_endpoint(
     meas_id: int,
     meas_id: int,

+ 2 - 0
services/seq-interp/Dockerfile

@@ -23,6 +23,8 @@ COPY services/seq-interp/requirements.docker.txt /app/requirements.txt
 RUN pip install --no-cache-dir -r /app/requirements.txt
 RUN pip install --no-cache-dir -r /app/requirements.txt
 
 
 COPY libs/lf-scanner /app/LF_scanner
 COPY libs/lf-scanner /app/LF_scanner
+RUN pip install --no-cache-dir /app/LF_scanner
+
 COPY services/seq-interp /app/seq_interp
 COPY services/seq-interp /app/seq_interp
 
 
 WORKDIR /app/seq_interp
 WORKDIR /app/seq_interp