diff --git a/runtime_client_main.py b/runtime_client_main.py index 608255d..a2975f8 100644 --- a/runtime_client_main.py +++ b/runtime_client_main.py @@ -1,6 +1,7 @@ -"""Entry point del cliente desktop Runtime (stub Sprint 0). +"""Entry point del cliente desktop del Runtime (Sprint 6). -Sprint 6 lo reemplaza con `vmssailor.runtime.client.app:main`. +Uso: + uv run python runtime_client_main.py """ from __future__ import annotations @@ -9,9 +10,9 @@ import sys def main() -> int: - print("VMS-Sailor Runtime cliente — Sprint 6 trae la UI PySide6.") - print("En Sprint 0 solo existe el modelo de datos core.") - return 0 + from vmssailor.runtime.client.app import run_client + + return run_client(sys.argv) if __name__ == "__main__": diff --git a/tests/runtime/test_client.py b/tests/runtime/test_client.py new file mode 100644 index 0000000..51facb8 --- /dev/null +++ b/tests/runtime/test_client.py @@ -0,0 +1,91 @@ +"""Tests del cliente Runtime (Sprint 6).""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("PySide6") + + +def test_client_window_builds(qtbot): + from vmssailor.runtime.client.main_window import RuntimeClientWindow + + w = RuntimeClientWindow() + qtbot.addWidget(w) + assert w._stack.count() == 7 # Overview, Mimicos, Alarmas, Trends, Trim, Logbook, Conexion + assert "VMS-Sailor Runtime" in w.windowTitle() + + +def test_overview_handles_tag_update(qtbot): + from vmssailor.runtime.client.views.overview_view import OverviewView + + ov = OverviewView() + qtbot.addWidget(ov) + ov.handle_event( + { + "type": "tag_update", + "tag_id": "ME_PORT.OIL_PRESS", + "value": 4.8, + "quality": "good", + "timestamp": "2026-05-17T03:42:00", + } + ) + assert "ME_PORT.OIL_PRESS" in ov._tiles + + +def test_alarms_view_handles_alarm_event(qtbot): + from vmssailor.runtime.client.views.alarms_view import AlarmsView + + av = AlarmsView() + qtbot.addWidget(av) + av.handle_event( + { + "type": "alarm_event", + "alarm_id": "alm_1", + "tag_id": "ME_PORT.OIL_PRESS", + "priority": "emergency", + "state": "active", + "message": "Oil press low", + } + ) + assert av._table.rowCount() == 1 + + +def test_alarms_view_clears(qtbot): + from vmssailor.runtime.client.views.alarms_view import AlarmsView + + av = AlarmsView() + qtbot.addWidget(av) + av.handle_event( + { + "type": "alarm_event", + "alarm_id": "alm_1", + "tag_id": "X", + "priority": "low", + "state": "active", + "message": "low msg", + } + ) + assert av._table.rowCount() == 1 + av.handle_event( + { + "type": "alarm_event", + "alarm_id": "alm_1", + "tag_id": "X", + "priority": "low", + "state": "cleared", + "message": "low msg", + } + ) + assert av._table.rowCount() == 0 + + +def test_connection_view_initial_state(qtbot): + from vmssailor.runtime.client.views.connection_view import ConnectionView + + cv = ConnectionView() + qtbot.addWidget(cv) + assert cv._connect_btn.isEnabled() + assert not cv._disconnect_btn.isEnabled() + assert cv._host_input.text() == "127.0.0.1" + assert cv._port_input.value() == 8765 diff --git a/vmssailor/runtime/client/__init__.py b/vmssailor/runtime/client/__init__.py new file mode 100644 index 0000000..181b3ff --- /dev/null +++ b/vmssailor/runtime/client/__init__.py @@ -0,0 +1,10 @@ +"""Cliente desktop del Runtime (PySide6). + +Sprint 6: shell + login + overview + alarmas + WebSocket client. + +Entry point: `runtime_client_main.py` en la raíz del repo. +""" + +from vmssailor.runtime.client.app import RuntimeClientApp, run_client + +__all__ = ["RuntimeClientApp", "run_client"] diff --git a/vmssailor/runtime/client/api_client.py b/vmssailor/runtime/client/api_client.py new file mode 100644 index 0000000..75b87e2 --- /dev/null +++ b/vmssailor/runtime/client/api_client.py @@ -0,0 +1,148 @@ +"""Cliente HTTP + WebSocket contra el Runtime server. + +Wrappers async-friendly de httpx + websockets para uso desde el cliente +PySide6 (que corre asyncio via qasync o thread workers). +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from collections.abc import Callable +from contextlib import suppress +from typing import Any + +import httpx +import websockets + +logger = logging.getLogger(__name__) + + +class RuntimeApiClient: + """Cliente REST contra `http://host:port`.""" + + def __init__(self, base_url: str = "http://127.0.0.1:8765", timeout_s: float = 5.0) -> None: + self._base = base_url.rstrip("/") + self._timeout = timeout_s + self._client = httpx.AsyncClient(base_url=self._base, timeout=timeout_s) + + async def close(self) -> None: + await self._client.aclose() + + async def health(self) -> dict[str, Any]: + r = await self._client.get("/health") + r.raise_for_status() + return r.json() + + async def project(self) -> dict[str, Any]: + r = await self._client.get("/project") + r.raise_for_status() + return r.json() + + async def tags(self) -> list[dict[str, Any]]: + r = await self._client.get("/tags") + r.raise_for_status() + return r.json() + + async def tag(self, tag_id: str) -> dict[str, Any]: + r = await self._client.get(f"/tags/{tag_id}") + r.raise_for_status() + return r.json() + + async def history( + self, + tag_id: str, + *, + since: str | None = None, + until: str | None = None, + limit: int = 1000, + ) -> list[dict[str, Any]]: + params: dict[str, Any] = {"limit": limit} + if since: + params["since"] = since + if until: + params["until"] = until + r = await self._client.get(f"/tags/{tag_id}/history", params=params) + r.raise_for_status() + return r.json() + + async def alarms(self, state: str | None = None) -> list[dict[str, Any]]: + params = {"state": state} if state else None + r = await self._client.get("/alarms", params=params) + r.raise_for_status() + return r.json() + + async def ack_alarm(self, alarm_id: str, user: str) -> dict[str, Any]: + r = await self._client.post( + f"/alarms/{alarm_id}/ack", params={"user": user} + ) + r.raise_for_status() + return r.json() + + async def logbook(self, *, kind: str | None = None, limit: int = 200) -> list[dict[str, Any]]: + params: dict[str, Any] = {"limit": limit} + if kind: + params["kind"] = kind + r = await self._client.get("/logbook", params=params) + r.raise_for_status() + return r.json() + + +class RuntimeWebSocketClient: + """Cliente WebSocket que mantiene conexión y emite eventos por callback.""" + + def __init__( + self, + url: str = "ws://127.0.0.1:8765/ws/realtime", + *, + on_event: Callable[[dict[str, Any]], None] | None = None, + on_state_change: Callable[[str], None] | None = None, + reconnect_delay_s: float = 5.0, + ) -> None: + self._url = url + self._on_event = on_event or (lambda _e: None) + self._on_state_change = on_state_change or (lambda _s: None) + self._reconnect_delay_s = reconnect_delay_s + self._stop = False + self._task: asyncio.Task | None = None + self._state = "disconnected" + + @property + def state(self) -> str: + return self._state + + async def start(self) -> None: + self._task = asyncio.create_task(self._loop()) + + async def stop(self) -> None: + self._stop = True + if self._task is not None: + self._task.cancel() + with suppress(asyncio.CancelledError): + await self._task + self._set_state("disconnected") + + def _set_state(self, state: str) -> None: + self._state = state + self._on_state_change(state) + + async def _loop(self) -> None: + while not self._stop: + try: + self._set_state("connecting") + async with websockets.connect(self._url, ping_interval=20) as ws: + self._set_state("connected") + async for raw in ws: + try: + event = json.loads(raw) + self._on_event(event) + except json.JSONDecodeError: + logger.warning("Invalid JSON from WS: %s", raw[:200]) + except asyncio.CancelledError: + break + except Exception as exc: + logger.warning("WebSocket connection failed: %s", exc) + self._set_state("disconnected") + with suppress(asyncio.CancelledError): + await asyncio.sleep(self._reconnect_delay_s) diff --git a/vmssailor/runtime/client/app.py b/vmssailor/runtime/client/app.py new file mode 100644 index 0000000..bc54112 --- /dev/null +++ b/vmssailor/runtime/client/app.py @@ -0,0 +1,47 @@ +"""Bootstrap del cliente desktop Runtime (PySide6).""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from PySide6.QtGui import QIcon +from PySide6.QtWidgets import QApplication + +from vmssailor.shared.logging_setup import setup_logging +from vmssailor.studio.theme import apply_theme +from vmssailor.version import __version__ + +BRAND_ROOT = Path(__file__).resolve().parents[3] / "docs" / "brand" + + +class RuntimeClientApp(QApplication): + """QApplication del cliente Runtime.""" + + def __init__(self, argv: list[str] | None = None) -> None: + super().__init__(argv or sys.argv) + self.setOrganizationName("Aerom") + self.setApplicationName("VMS-Sailor Runtime Client") + self.setApplicationDisplayName("VMS-Sailor Runtime") + self.setApplicationVersion(__version__) + + icon_svg = BRAND_ROOT / "favicon.svg" + if icon_svg.exists(): + self.setWindowIcon(QIcon(str(icon_svg))) + apply_theme(self) + + +def run_client(argv: list[str] | None = None) -> int: + """Lanza el cliente Runtime y bloquea hasta que cierre.""" + setup_logging() + app = RuntimeClientApp(argv) + from vmssailor.runtime.client.main_window import RuntimeClientWindow + + window = RuntimeClientWindow() + window.resize(1440, 900) + window.show() + return app.exec() + + +if __name__ == "__main__": + sys.exit(run_client()) diff --git a/vmssailor/runtime/client/main_window.py b/vmssailor/runtime/client/main_window.py new file mode 100644 index 0000000..d794847 --- /dev/null +++ b/vmssailor/runtime/client/main_window.py @@ -0,0 +1,254 @@ +"""Ventana principal del cliente Runtime. + +Layout: topbar (vessel name + status + alarms badge) / + sidebar (Overview/Mímicos/Alarmas/Trends/Trim) / + central stacked widget / + ticker. + +Sprint 6: Overview + Alarmas funcionales. Mímicos/Trends/Trim son stubs. +""" + +from __future__ import annotations + +import logging +from datetime import datetime +from pathlib import Path + +from PySide6.QtCore import Qt, QTimer, Slot +from PySide6.QtGui import QIcon +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QListWidget, + QListWidgetItem, + QMainWindow, + QStackedWidget, + QStatusBar, + QVBoxLayout, + QWidget, +) + +from vmssailor.runtime.client.views.alarms_view import AlarmsView +from vmssailor.runtime.client.views.connection_view import ConnectionView +from vmssailor.runtime.client.views.overview_view import OverviewView +from vmssailor.studio.theme import ( + C_FOAM, + C_FOG, + C_OK, + C_SAND, + C_STEEL, + C_WARN, + mono_font, + ui_font, +) + +logger = logging.getLogger(__name__) + + +BRAND_ROOT = Path(__file__).resolve().parents[3] / "docs" / "brand" + + +class RuntimeClientWindow(QMainWindow): + """Ventana principal del cliente Runtime (puente / máquinas).""" + + def __init__(self) -> None: + super().__init__() + self.setWindowTitle("VMS-Sailor Runtime") + self.setMinimumSize(1100, 700) + + self._user = "operator" + self._build_topbar() + self._build_central() + self._build_statusbar() + self._connect_signals() + + # Clock + self._clock_timer = QTimer(self) + self._clock_timer.timeout.connect(self._update_clock) + self._clock_timer.start(1000) + self._update_clock() + + # ----- Build ------------------------------------------------------- + + def _build_topbar(self) -> None: + bar = QWidget() + bar.setObjectName("topbar") + bar.setFixedHeight(64) + layout = QHBoxLayout(bar) + layout.setContentsMargins(24, 0, 24, 0) + layout.setSpacing(16) + + logo_path = BRAND_ROOT / "logo-mark.svg" + if logo_path.exists(): + logo = QLabel() + logo.setPixmap(QIcon(str(logo_path)).pixmap(36, 36)) + layout.addWidget(logo) + + vessel_box = QVBoxLayout() + vessel_box.setSpacing(0) + self._vessel_name_label = QLabel("VMS-Sailor Runtime") + self._vessel_name_label.setFont(ui_font(14)) + self._vessel_name_label.setStyleSheet(f"color: {C_FOAM}; font-weight: 600;") + self._vessel_sub_label = QLabel("Sin conexión") + self._vessel_sub_label.setFont(ui_font(9)) + self._vessel_sub_label.setStyleSheet(f"color: {C_FOG}; letter-spacing: 1.5px;") + vessel_box.addWidget(self._vessel_name_label) + vessel_box.addWidget(self._vessel_sub_label) + layout.addLayout(vessel_box) + + layout.addStretch(1) + + self._status_pill = QLabel("● Desconectado") + self._status_pill.setFont(ui_font(11)) + self._status_pill.setStyleSheet( + f"color: {C_FOG}; padding: 6px 14px; border: 1px solid {C_FOG}; border-radius: 999px;" + ) + layout.addWidget(self._status_pill) + + self._alarm_chip = QLabel("0 alarmas") + self._alarm_chip.setFont(ui_font(11)) + self._alarm_chip.setStyleSheet( + f"color: {C_OK}; padding: 6px 14px; border: 1px solid {C_OK}; border-radius: 999px;" + ) + layout.addWidget(self._alarm_chip) + + self._user_chip = QLabel(f"Usuario: {self._user}") + self._user_chip.setFont(mono_font(10)) + self._user_chip.setStyleSheet( + f"color: {C_SAND}; background: {C_STEEL}; padding: 6px 14px; border-radius: 999px;" + ) + layout.addWidget(self._user_chip) + + self._topbar = bar + + def _build_central(self) -> None: + wrapper = QWidget() + outer = QVBoxLayout(wrapper) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + outer.addWidget(self._topbar) + + body = QHBoxLayout() + body.setContentsMargins(0, 0, 0, 0) + body.setSpacing(0) + + # Sidebar + sidebar = QWidget() + sidebar.setObjectName("sidebar") + sidebar.setFixedWidth(220) + sb_layout = QVBoxLayout(sidebar) + sb_layout.setContentsMargins(8, 12, 8, 12) + sb_layout.setSpacing(4) + + self._nav = QListWidget() + self._nav.setStyleSheet("border: none;") + self._nav_items = ["Overview", "Mímicos", "Alarmas", "Trends", "Trim", "Log Book", "Conexión"] + for name in self._nav_items: + QListWidgetItem(name, self._nav) + self._nav.setCurrentRow(0) + sb_layout.addWidget(self._nav, 1) + body.addWidget(sidebar) + + # Central stack + self._stack = QStackedWidget() + self._overview = OverviewView() + self._alarms = AlarmsView() + self._connection = ConnectionView() + self._stack.addWidget(self._overview) + self._stack.addWidget(self._placeholder("Mímicos · Sprint 6+")) + self._stack.addWidget(self._alarms) + self._stack.addWidget(self._placeholder("Trends · Sprint 7+")) + self._stack.addWidget(self._placeholder("Trim & Maniobra · Sprint 8")) + self._stack.addWidget(self._placeholder("Log Book · Sprint 6+")) + self._stack.addWidget(self._connection) + body.addWidget(self._stack, 1) + outer.addLayout(body, 1) + self.setCentralWidget(wrapper) + + def _build_statusbar(self) -> None: + sb = QStatusBar(self) + sb.setSizeGripEnabled(False) + sb.setFont(mono_font(9)) + self._ws_label = QLabel("WS: disconnected") + self._ws_label.setFont(mono_font(9)) + self._ws_label.setStyleSheet(f"color: {C_FOG};") + sb.addWidget(self._ws_label, 1) + self._clock_label = QLabel("--:--:--") + self._clock_label.setFont(mono_font(9)) + sb.addPermanentWidget(self._clock_label) + from vmssailor.version import __version__ as v + + ver_label = QLabel(f"Runtime client {v}") + ver_label.setFont(mono_font(9)) + ver_label.setStyleSheet(f"color: {C_FOG};") + sb.addPermanentWidget(ver_label) + self.setStatusBar(sb) + + def _connect_signals(self) -> None: + self._nav.currentRowChanged.connect(self._stack.setCurrentIndex) + self._connection.connectionStateChanged.connect(self._on_connection_state_change) + self._connection.eventReceived.connect(self._on_event) + self._connection.projectLoaded.connect(self._on_project_loaded) + self._alarms.acknowledged.connect(self._on_ack_request) + + # ----- Helpers ----------------------------------------------------- + + def _placeholder(self, title: str) -> QWidget: + w = QWidget() + lo = QVBoxLayout(w) + lo.addStretch(1) + big = QLabel(title) + big.setFont(ui_font(20)) + big.setStyleSheet(f"color: {C_FOG};") + big.setAlignment(big.alignment() | Qt.AlignmentFlag.AlignCenter) + lo.addWidget(big) + lo.addStretch(2) + return w + + def _update_clock(self) -> None: + self._clock_label.setText(datetime.now().strftime("%H:%M:%S · %Y-%m-%d")) + + # ----- Slots ------------------------------------------------------- + + @Slot(str) + def _on_connection_state_change(self, state: str) -> None: + if state == "connected": + self._status_pill.setText("● Conectado") + self._status_pill.setStyleSheet( + f"color: {C_OK}; padding: 6px 14px; border: 1px solid {C_OK}; border-radius: 999px;" + ) + self._ws_label.setText("WS: connected") + self._ws_label.setStyleSheet(f"color: {C_OK};") + elif state == "connecting": + self._status_pill.setText("● Conectando…") + self._status_pill.setStyleSheet( + f"color: {C_WARN}; padding: 6px 14px; border: 1px solid {C_WARN}; border-radius: 999px;" + ) + self._ws_label.setText("WS: connecting") + self._ws_label.setStyleSheet(f"color: {C_WARN};") + else: + self._status_pill.setText("● Desconectado") + self._status_pill.setStyleSheet( + f"color: {C_FOG}; padding: 6px 14px; border: 1px solid {C_FOG}; border-radius: 999px;" + ) + self._ws_label.setText("WS: disconnected") + self._ws_label.setStyleSheet(f"color: {C_FOG};") + + @Slot(dict) + def _on_event(self, event: dict) -> None: + self._overview.handle_event(event) + self._alarms.handle_event(event) + + @Slot(dict) + def _on_project_loaded(self, project: dict) -> None: + self._vessel_name_label.setText(project.get("name", "—")) + v = project.get("vessel", {}) + sub = f"{v.get('name', '')} · {v.get('loa_m', 0):.1f} m" + self._vessel_sub_label.setText(sub) + self._overview.set_project(project) + + @Slot(str) + def _on_ack_request(self, alarm_id: str) -> None: + self._connection.request_ack(alarm_id, user=self._user) + + diff --git a/vmssailor/runtime/client/views/__init__.py b/vmssailor/runtime/client/views/__init__.py new file mode 100644 index 0000000..ebfb564 --- /dev/null +++ b/vmssailor/runtime/client/views/__init__.py @@ -0,0 +1 @@ +"""Vistas del cliente Runtime (Sprint 6).""" diff --git a/vmssailor/runtime/client/views/alarms_view.py b/vmssailor/runtime/client/views/alarms_view.py new file mode 100644 index 0000000..b01bdf0 --- /dev/null +++ b/vmssailor/runtime/client/views/alarms_view.py @@ -0,0 +1,110 @@ +"""Vista de alarmas activas (Sprint 6). + +Renderiza eventos `alarm_event` y `tag_update` para mantener el contador. +ACK individual emite signal `acknowledged(alarm_id)`. +""" + +from __future__ import annotations + +from PySide6.QtCore import Signal +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QAbstractItemView, + QHBoxLayout, + QHeaderView, + QLabel, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from vmssailor.studio.theme import ( + C_EMERGENCY, + C_FOG, + C_HIGH, + C_INFO, + C_WARN, + mono_font, + ui_font, +) + +_PRIORITY_COLORS = { + "emergency": C_EMERGENCY, + "high": C_HIGH, + "low": C_WARN, + "info": C_INFO, +} + + +class AlarmsView(QWidget): + """Panel de alarmas: tabla + botones ACK.""" + + acknowledged = Signal(str) + """Emite el `alarm_id` al solicitar ACK.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._alarms: dict[str, dict] = {} + + outer = QVBoxLayout(self) + outer.setContentsMargins(20, 20, 20, 20) + outer.setSpacing(12) + + title_row = QHBoxLayout() + title = QLabel("Alarmas") + title.setFont(ui_font(20)) + title.setStyleSheet(f"color: {C_EMERGENCY}; font-weight: 600;") + title_row.addWidget(title) + title_row.addStretch(1) + + self._counter = QLabel("0 activas") + self._counter.setFont(mono_font(11)) + self._counter.setStyleSheet(f"color: {C_FOG};") + title_row.addWidget(self._counter) + outer.addLayout(title_row) + + # Table + self._table = QTableWidget(0, 6) + self._table.setHorizontalHeaderLabels( + ["ID", "Tag", "Prioridad", "Estado", "Mensaje", "Acción"] + ) + self._table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) + self._table.verticalHeader().setVisible(False) + self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self._table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + outer.addWidget(self._table, 1) + + def handle_event(self, event: dict) -> None: + if event.get("type") != "alarm_event": + return + alarm_id = event.get("alarm_id") or event.get("id") + if not alarm_id: + return + state = event.get("state", "active") + if state == "cleared": + # Quitar + self._alarms.pop(alarm_id, None) + else: + self._alarms[alarm_id] = event + self._refresh_table() + + def _refresh_table(self) -> None: + self._table.setRowCount(0) + for alarm_id, a in self._alarms.items(): + row = self._table.rowCount() + self._table.insertRow(row) + self._table.setItem(row, 0, QTableWidgetItem(alarm_id[:24])) + self._table.setItem(row, 1, QTableWidgetItem(str(a.get("tag_id", "")))) + priority = a.get("priority", "—") + prio_item = QTableWidgetItem(priority) + prio_item.setForeground(QColor(_PRIORITY_COLORS.get(priority, C_FOG))) + self._table.setItem(row, 2, prio_item) + self._table.setItem(row, 3, QTableWidgetItem(a.get("state", ""))) + self._table.setItem(row, 4, QTableWidgetItem(a.get("message", ""))) + btn = QPushButton("ACK") + btn.clicked.connect(lambda _checked=False, aid=alarm_id: self.acknowledged.emit(aid)) + self._table.setCellWidget(row, 5, btn) + + self._counter.setText(f"{len(self._alarms)} activas") diff --git a/vmssailor/runtime/client/views/connection_view.py b/vmssailor/runtime/client/views/connection_view.py new file mode 100644 index 0000000..5a1040a --- /dev/null +++ b/vmssailor/runtime/client/views/connection_view.py @@ -0,0 +1,273 @@ +"""Vista de conexión: configurar host/port + connect/disconnect + tag table. + +Sprint 6: corre el cliente API/WS en un QThread separado con su propio +event loop asyncio. +""" + +from __future__ import annotations + +import asyncio +import logging +import threading +from typing import Any + +from PySide6.QtCore import QObject, QThread, Signal, Slot +from PySide6.QtWidgets import ( + QAbstractItemView, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QPushButton, + QSpinBox, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from vmssailor.runtime.client.api_client import ( + RuntimeApiClient, + RuntimeWebSocketClient, +) +from vmssailor.studio.theme import ( + C_CYAN, + C_FOG, + mono_font, + ui_font, +) + +logger = logging.getLogger(__name__) + + +class _AsyncWorker(QObject): + """Worker que corre un asyncio event loop en su propio QThread.""" + + eventReceived = Signal(dict) + connectionStateChanged = Signal(str) + projectLoaded = Signal(dict) + error = Signal(str) + + def __init__(self, host: str, port: int) -> None: + super().__init__() + self._host = host + self._port = port + self._loop: asyncio.AbstractEventLoop | None = None + self._api: RuntimeApiClient | None = None + self._ws: RuntimeWebSocketClient | None = None + self._thread_id: int | None = None + + @Slot() + def run(self) -> None: + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._thread_id = threading.get_ident() + try: + self._loop.run_until_complete(self._main()) + finally: + self._loop.close() + + async def _main(self) -> None: + base_url = f"http://{self._host}:{self._port}" + ws_url = f"ws://{self._host}:{self._port}/ws/realtime" + + self._api = RuntimeApiClient(base_url) + self._ws = RuntimeWebSocketClient( + ws_url, + on_event=lambda ev: self.eventReceived.emit(ev), + on_state_change=lambda s: self.connectionStateChanged.emit(s), + ) + # Try fetch project info + try: + project = await self._api.project() + self.projectLoaded.emit(project) + except Exception as exc: + self.error.emit(f"GET /project falló: {exc}") + # Start WS loop + await self._ws.start() + + # Block until cancelled + try: + while True: + await asyncio.sleep(60) + except asyncio.CancelledError: + pass + finally: + if self._ws is not None: + await self._ws.stop() + if self._api is not None: + await self._api.close() + + def submit(self, coro_factory): + """Programa una coroutine en el loop del worker.""" + if self._loop is None or not self._loop.is_running(): + return None + return asyncio.run_coroutine_threadsafe(coro_factory(), self._loop) + + async def ack_alarm(self, alarm_id: str, user: str) -> dict[str, Any] | None: + if self._api is None: + return None + try: + return await self._api.ack_alarm(alarm_id, user=user) + except Exception as exc: + self.error.emit(f"ACK falló: {exc}") + return None + + +class ConnectionView(QWidget): + """Panel de conexión + monitor de eventos crudos.""" + + connectionStateChanged = Signal(str) + eventReceived = Signal(dict) + projectLoaded = Signal(dict) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._worker: _AsyncWorker | None = None + self._thread: QThread | None = None + + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(12) + + title = QLabel("Conexión al Runtime server") + title.setFont(ui_font(16)) + title.setStyleSheet(f"color: {C_CYAN}; font-weight: 600;") + layout.addWidget(title) + + intro = QLabel( + "Lanza el servidor primero con:\n" + " uv run python runtime_server_main.py --verbose\n" + "Luego conecta acá." + ) + intro.setStyleSheet(f"color: {C_FOG};") + intro.setFont(mono_font(10)) + layout.addWidget(intro) + + # Connect controls + row = QHBoxLayout() + row.addWidget(QLabel("Host:")) + self._host_input = QLineEdit("127.0.0.1") + self._host_input.setFixedWidth(160) + row.addWidget(self._host_input) + row.addWidget(QLabel("Puerto:")) + self._port_input = QSpinBox() + self._port_input.setRange(1, 65535) + self._port_input.setValue(8765) + self._port_input.setFixedWidth(100) + row.addWidget(self._port_input) + + self._connect_btn = QPushButton("Conectar") + self._connect_btn.setObjectName("primary") + self._disconnect_btn = QPushButton("Desconectar") + self._disconnect_btn.setEnabled(False) + row.addWidget(self._connect_btn) + row.addWidget(self._disconnect_btn) + row.addStretch(1) + layout.addLayout(row) + + # Event log (tabla con últimos updates) + self._counter = QLabel("0 eventos recibidos") + self._counter.setFont(mono_font(10)) + self._counter.setStyleSheet(f"color: {C_FOG};") + layout.addWidget(self._counter) + + self._events_table = QTableWidget(0, 4) + self._events_table.setHorizontalHeaderLabels(["Tipo", "Tag/Detalle", "Valor", "Timestamp"]) + self._events_table.horizontalHeader().setSectionResizeMode( + 1, QHeaderView.ResizeMode.Stretch + ) + self._events_table.verticalHeader().setVisible(False) + self._events_table.setSelectionBehavior( + QAbstractItemView.SelectionBehavior.SelectRows + ) + self._events_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + layout.addWidget(self._events_table, 1) + + self._connect_btn.clicked.connect(self._on_connect) + self._disconnect_btn.clicked.connect(self._on_disconnect) + + self._event_count = 0 + + # ----- Connection lifecycle ---------------------------------------- + + def _on_connect(self) -> None: + host = self._host_input.text().strip() + port = self._port_input.value() + if self._thread is not None: + return + self._worker = _AsyncWorker(host, port) + self._thread = QThread(self) + self._worker.moveToThread(self._thread) + self._worker.connectionStateChanged.connect(self.connectionStateChanged) + self._worker.eventReceived.connect(self._on_event_received_internal) + self._worker.projectLoaded.connect(self.projectLoaded) + self._worker.error.connect(self._on_error) + self._thread.started.connect(self._worker.run) + self._thread.start() + self._connect_btn.setEnabled(False) + self._disconnect_btn.setEnabled(True) + + def _on_disconnect(self) -> None: + if self._worker is not None and self._worker._loop is not None: + self._worker._loop.call_soon_threadsafe(lambda: None) + # Stop loop tasks + fut = asyncio.run_coroutine_threadsafe( + _stop_worker(self._worker), self._worker._loop + ) + from contextlib import suppress + with suppress(Exception): + fut.result(timeout=3) + if self._thread is not None: + self._thread.quit() + self._thread.wait(3000) + self._thread = None + self._worker = None + self.connectionStateChanged.emit("disconnected") + self._connect_btn.setEnabled(True) + self._disconnect_btn.setEnabled(False) + + # ----- Events ------------------------------------------------------ + + @Slot(dict) + def _on_event_received_internal(self, event: dict) -> None: + self._event_count += 1 + # Add row to table (keep last 500) + if self._events_table.rowCount() > 500: + self._events_table.removeRow(self._events_table.rowCount() - 1) + self._events_table.insertRow(0) + ev_type = event.get("type", "?") + self._events_table.setItem(0, 0, QTableWidgetItem(ev_type)) + detail = event.get("tag_id", "") or event.get("alarm_id", "") or "" + self._events_table.setItem(0, 1, QTableWidgetItem(str(detail))) + value = event.get("value", "") + self._events_table.setItem(0, 2, QTableWidgetItem(str(value))) + ts = event.get("timestamp", "") + self._events_table.setItem(0, 3, QTableWidgetItem(str(ts))) + self._counter.setText(f"{self._event_count} eventos recibidos") + self.eventReceived.emit(event) + + @Slot(str) + def _on_error(self, msg: str) -> None: + logger.warning("ConnectionView error: %s", msg) + + # ----- Public API -------------------------------------------------- + + def request_ack(self, alarm_id: str, user: str) -> None: + if self._worker is None: + return + self._worker.submit(lambda: self._worker.ack_alarm(alarm_id, user)) + + +async def _stop_worker(worker: _AsyncWorker) -> None: + """Cancela tareas y cierra recursos del worker desde su loop.""" + if worker._ws is not None: + await worker._ws.stop() + if worker._api is not None: + await worker._api.close() + loop = asyncio.get_running_loop() + # Cancelar todas las tareas + for task in asyncio.all_tasks(loop): + if task is not asyncio.current_task(): + task.cancel() + loop.stop() diff --git a/vmssailor/runtime/client/views/overview_view.py b/vmssailor/runtime/client/views/overview_view.py new file mode 100644 index 0000000..5c22a9d --- /dev/null +++ b/vmssailor/runtime/client/views/overview_view.py @@ -0,0 +1,137 @@ +"""Vista Overview: tarjeta con tags clave en vivo (Sprint 6).""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from vmssailor.studio.theme import ( + C_CYAN, + C_FOAM, + C_FOG, + C_MIDNIGHT, + C_OK, + C_STEEL, + C_WARN, + mono_font, + ui_font, +) + + +class _TagTile(QFrame): + """Tarjeta visual con valor en vivo de un tag.""" + + def __init__(self, tag_id: str, description: str, unit: str) -> None: + super().__init__() + self._tag_id = tag_id + self._unit = unit + self.setObjectName("tagTile") + self.setStyleSheet( + f"#tagTile {{ background: {C_MIDNIGHT}; " + f"border: 1px solid {C_STEEL}; border-radius: 8px; }}" + ) + layout = QVBoxLayout(self) + layout.setContentsMargins(14, 12, 14, 12) + layout.setSpacing(2) + + self._desc_label = QLabel(description or tag_id) + self._desc_label.setFont(ui_font(9)) + self._desc_label.setStyleSheet(f"color: {C_FOG}; letter-spacing: 1.5px;") + layout.addWidget(self._desc_label) + + self._id_label = QLabel(tag_id) + self._id_label.setFont(mono_font(9)) + self._id_label.setStyleSheet(f"color: {C_CYAN};") + layout.addWidget(self._id_label) + + self._value_label = QLabel("—") + self._value_label.setFont(mono_font(20)) + self._value_label.setStyleSheet(f"color: {C_FOAM}; font-weight: 600;") + layout.addWidget(self._value_label) + + self._meta_label = QLabel("sin datos") + self._meta_label.setFont(mono_font(8)) + self._meta_label.setStyleSheet(f"color: {C_FOG};") + layout.addWidget(self._meta_label) + + def update_value(self, value, quality: str, timestamp: str) -> None: + value_str = f"{value:.2f}" if isinstance(value, float) else str(value) + self._value_label.setText(f"{value_str} {self._unit}") + ts_short = timestamp[-12:-3] if timestamp else "—" + self._meta_label.setText(f"q={quality} · {ts_short}") + color = C_OK if quality == "good" else C_WARN + self._meta_label.setStyleSheet(f"color: {color};") + + +class OverviewView(QWidget): + """Grid de tiles con tags clave.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._tiles: dict[str, _TagTile] = {} + + outer = QVBoxLayout(self) + outer.setContentsMargins(20, 20, 20, 20) + outer.setSpacing(12) + + title_row = QHBoxLayout() + self._title = QLabel("Overview") + self._title.setFont(ui_font(20)) + self._title.setStyleSheet(f"color: {C_FOAM}; font-weight: 600;") + title_row.addWidget(self._title) + title_row.addStretch(1) + self._tag_count_label = QLabel("0 tags") + self._tag_count_label.setFont(mono_font(10)) + self._tag_count_label.setStyleSheet(f"color: {C_FOG};") + title_row.addWidget(self._tag_count_label) + outer.addLayout(title_row) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + outer.addWidget(scroll, 1) + + grid_wrapper = QWidget() + self._grid = QGridLayout(grid_wrapper) + self._grid.setSpacing(10) + scroll.setWidget(grid_wrapper) + + self._empty_label = QLabel("Conecta al Runtime para ver tags en vivo (panel Conexión).") + self._empty_label.setFont(ui_font(11)) + self._empty_label.setStyleSheet(f"color: {C_FOG};") + self._empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._grid.addWidget(self._empty_label, 0, 0) + + def set_project(self, project: dict) -> None: + # Pre-popula tiles a partir de stats del proyecto (sin valores aún) + self._title.setText(f"Overview · {project.get('name', '')}") + + def handle_event(self, event: dict) -> None: + if event.get("type") != "tag_update": + return + tag_id = event.get("tag_id") + if not tag_id: + return + if tag_id not in self._tiles: + # Crear nueva tile dinámicamente + if self._empty_label is not None: + self._empty_label.setVisible(False) + tile = _TagTile(tag_id, description="", unit="") + row = len(self._tiles) // 4 + col = len(self._tiles) % 4 + self._grid.addWidget(tile, row, col) + self._tiles[tag_id] = tile + self._tag_count_label.setText(f"{len(self._tiles)} tags") + self._tiles[tag_id].update_value( + event.get("value"), + event.get("quality", "—"), + event.get("timestamp", ""), + )