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>
138 lines
4.7 KiB
Python
138 lines
4.7 KiB
Python
"""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", ""),
|
|
)
|