7390d5cd51
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>
255 lines
8.8 KiB
Python
255 lines
8.8 KiB
Python
"""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)
|
|
|
|
|