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:
@@ -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")
|
||||
Reference in New Issue
Block a user