sprint-6: Runtime desktop client (PySide6 + WebSocket + REST)

Cliente desktop completo conectado al Runtime server por HTTP+WebSocket.

vmssailor/runtime/client/api_client.py
- RuntimeApiClient: wrapper async httpx contra /health, /project, /tags,
  /tags/{id}, /tags/{id}/history, /alarms, /logbook, /alarms/{id}/ack
- RuntimeWebSocketClient: conexion ws://host:port/ws/realtime con
  reconexion automatica + heartbeats + on_event/on_state_change callbacks

vmssailor/runtime/client/app.py
- RuntimeClientApp QApplication con tema Deep Ocean (compartido con Studio)
- run_client() entry point

vmssailor/runtime/client/main_window.py
- Topbar: logo + vessel name + status pill (connecting/connected/disconnected)
  + alarm chip + user chip
- Sidebar lista: Overview, Mimicos, Alarmas, Trends, Trim, Log Book, Conexion
- QStackedWidget central conmuta vista segun sidebar
- Reloj live + statusbar con estado WS + version
- Sprint 6 trae 3 vistas funcionales (Overview, Alarmas, Conexion); resto stubs

vmssailor/runtime/client/views/connection_view.py
- _AsyncWorker QObject corre asyncio event loop en QThread separado
- Conecta API + WS, emite signals connectionStateChanged / eventReceived / projectLoaded
- Tabla con ultimos 500 eventos crudos del WebSocket
- Conecta/desconecta sin congelar la UI

vmssailor/runtime/client/views/overview_view.py
- Grid de tiles dinamicas que se crean al recibir tag_update events
- Cada tile muestra: descripcion, tag_id, valor formateado, quality, timestamp
- Layout 4 columnas con QScrollArea

vmssailor/runtime/client/views/alarms_view.py
- Tabla de alarmas activas con priority coloreada (emergency/high/low/info)
- Boton ACK por fila emite signal acknowledged(alarm_id)
- handle_event mantiene set vivo segun state=active/cleared

runtime_client_main.py
- Entry point: uv run python runtime_client_main.py

Tests (tests/runtime/test_client.py, 5 nuevos, total 157/157):
- main window builds with 7 stack pages
- overview handles tag_update
- alarms view shows + clears on state change
- connection view initial state

Para correr el sistema completo en vivo:
    # Terminal 1 — servidor:
    uv run python runtime_server_main.py --verbose
    # Terminal 2 — cliente:
    uv run python runtime_client_main.py

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 20:37:06 -04:00
parent df56f52091
commit 7390d5cd51
10 changed files with 1077 additions and 5 deletions
@@ -0,0 +1 @@
"""Vistas del cliente Runtime (Sprint 6)."""
@@ -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")
@@ -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()
@@ -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", ""),
)