"""Editor de configuración de alarmas (Sprint 3). Cada tag puede tener múltiples AlarmConfig. Este editor permite: - Listar todas las alarmas configuradas (todos los tags) - Editar threshold, operator, priority, hysteresis, delay, message - Agregar / eliminar alarmas La lógica del alarm engine (evaluación en tiempo real) llega en Sprint 4 con el Runtime servidor. """ from __future__ import annotations from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QColor from PySide6.QtWidgets import ( QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, QFormLayout, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMessageBox, QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) from vmssailor.core.enums import AlarmPriority from vmssailor.core.project import Project from vmssailor.core.tag import AlarmConfig from vmssailor.shared.ids import make_alarm_config_id from vmssailor.studio.theme import ( C_CYAN, C_EMERGENCY, C_FOG, C_HIGH, C_INFO, C_WARN, mono_font, ui_font, ) _PRIORITY_COLORS = { AlarmPriority.EMERGENCY: C_EMERGENCY, AlarmPriority.HIGH: C_HIGH, AlarmPriority.LOW: C_WARN, AlarmPriority.INFO: C_INFO, } _COLS = [ "Tag", "Alarm ID", "Operador", "Threshold", "Prioridad", "Histéresis", "Delay (s)", "Mensaje", ] class AlarmEditor(QWidget): projectMutated = Signal(Project) def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self._project: Project | None = None layout = QVBoxLayout(self) layout.setContentsMargins(12, 12, 12, 12) layout.setSpacing(10) header = QLabel("Editor de alarmas") header.setObjectName("title") header.setFont(ui_font(14)) layout.addWidget(header) intro = QLabel( "Configuración declarativa de alarmas. Cuatro prioridades: " "EMERGENCY (rojo, accion inmediata), HIGH (naranja), LOW (amber, watch), " "INFO (cyan, registro)." ) intro.setStyleSheet(f"color: {C_FOG};") intro.setFont(ui_font(10)) intro.setWordWrap(True) layout.addWidget(intro) # Buttons row btn_row = QHBoxLayout() self._btn_add = QPushButton("+ Agregar alarma") self._btn_add.setObjectName("primary") self._btn_remove = QPushButton("Eliminar seleccionada") self._counts_by_priority = QLabel("") self._counts_by_priority.setFont(mono_font(10)) btn_row.addWidget(self._btn_add) btn_row.addWidget(self._btn_remove) btn_row.addStretch(1) btn_row.addWidget(self._counts_by_priority) layout.addLayout(btn_row) # Table self._table = QTableWidget(0, len(_COLS)) self._table.setHorizontalHeaderLabels(_COLS) self._table.horizontalHeader().setSectionResizeMode(7, QHeaderView.ResizeMode.Stretch) self._table.verticalHeader().setVisible(False) self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self._table.setEditTriggers( QAbstractItemView.EditTrigger.DoubleClicked | QAbstractItemView.EditTrigger.EditKeyPressed ) self._table.itemChanged.connect(self._on_item_changed) layout.addWidget(self._table, 1) self._btn_add.clicked.connect(self._on_add) self._btn_remove.clicked.connect(self._on_remove) # ----- Public API ------------------------------------------------- def set_project(self, project: Project | None) -> None: self._project = project self._refresh_table() # ----- Internals -------------------------------------------------- def _refresh_table(self) -> None: self._table.blockSignals(True) self._table.setRowCount(0) if self._project is None: self._counts_by_priority.setText("") self._table.blockSignals(False) return # Aplanar todas las alarmas flat: list[tuple[int, str, AlarmConfig]] = [] for tag_idx, t in enumerate(self._project.tags): for a in t.alarms: flat.append((tag_idx, t.id, a)) self._flat = flat for _tag_idx, tag_id, a in flat: self._append_row(tag_id, a) self._update_priority_counts() self._table.blockSignals(False) def _append_row(self, tag_id: str, a: AlarmConfig) -> None: row = self._table.rowCount() self._table.insertRow(row) tag_item = QTableWidgetItem(tag_id) tag_item.setFlags(tag_item.flags() & ~Qt.ItemFlag.ItemIsEditable) tag_item.setForeground(QColor(C_CYAN)) self._table.setItem(row, 0, tag_item) id_item = QTableWidgetItem(a.id) id_item.setFlags(id_item.flags() & ~Qt.ItemFlag.ItemIsEditable) self._table.setItem(row, 1, id_item) self._table.setItem(row, 2, QTableWidgetItem(a.operator)) self._table.setItem(row, 3, QTableWidgetItem(f"{a.threshold:.3f}")) prio = QTableWidgetItem(a.priority.value) prio.setForeground(QColor(_PRIORITY_COLORS[a.priority])) self._table.setItem(row, 4, prio) self._table.setItem(row, 5, QTableWidgetItem(f"{a.hysteresis:.3f}")) self._table.setItem(row, 6, QTableWidgetItem(f"{a.delay_seconds:.1f}")) self._table.setItem(row, 7, QTableWidgetItem(a.message)) def _update_priority_counts(self) -> None: if not self._project: return counts: dict[AlarmPriority, int] = dict.fromkeys(AlarmPriority, 0) for t in self._project.tags: for a in t.alarms: counts[a.priority] += 1 parts = [] for p in (AlarmPriority.EMERGENCY, AlarmPriority.HIGH, AlarmPriority.LOW, AlarmPriority.INFO): color = _PRIORITY_COLORS[p] parts.append(f'{p.value}:{counts[p]}') self._counts_by_priority.setText(" ".join(parts)) def _on_item_changed(self, item: QTableWidgetItem) -> None: if self._project is None: return row = item.row() if row >= len(self._flat): return tag_idx, _tag_id, a = self._flat[row] col = item.column() new_alarm: AlarmConfig | None = None try: if col == 2: new_alarm = a.model_copy(update={"operator": item.text()}) elif col == 3: new_alarm = a.model_copy(update={"threshold": float(item.text())}) elif col == 4: new_alarm = a.model_copy(update={"priority": AlarmPriority(item.text())}) elif col == 5: new_alarm = a.model_copy(update={"hysteresis": float(item.text())}) elif col == 6: new_alarm = a.model_copy(update={"delay_seconds": float(item.text())}) elif col == 7: new_alarm = a.model_copy(update={"message": item.text()}) except Exception as exc: QMessageBox.warning(self, "Edición inválida", str(exc)) self._refresh_table() return if new_alarm is None: return tag = self._project.tags[tag_idx] # Reemplazo del alarmConfig dentro de la lista new_alarms = [new_alarm if x.id == a.id else x for x in tag.alarms] new_tag = tag.model_copy(update={"alarms": new_alarms}) new_tags = list(self._project.tags) new_tags[tag_idx] = new_tag try: self._project = self._project.model_copy(update={"tags": new_tags}) self._project = self._project.__class__.model_validate(self._project.model_dump()) except Exception as exc: QMessageBox.critical(self, "Validación falló", str(exc)) self._refresh_table() return self._project.touch() self.projectMutated.emit(self._project) self._update_priority_counts() def _on_add(self) -> None: if self._project is None or not self._project.tags: QMessageBox.information(self, "Agregar alarma", "No hay tags en el proyecto.") return dlg = _AddAlarmDialog(self._project, self) if dlg.exec(): tag_idx, alarm = dlg.build() if alarm is None: return tag = self._project.tags[tag_idx] new_tag = tag.model_copy(update={"alarms": [*tag.alarms, alarm]}) new_tags = list(self._project.tags) new_tags[tag_idx] = new_tag try: self._project = self._project.model_copy(update={"tags": new_tags}) self._project = self._project.__class__.model_validate( self._project.model_dump() ) except Exception as exc: QMessageBox.critical(self, "Validación falló", str(exc)) return self._project.touch() self.projectMutated.emit(self._project) self._refresh_table() def _on_remove(self) -> None: if self._project is None: return row = self._table.currentRow() if row < 0 or row >= len(self._flat): return tag_idx, _tag_id, a = self._flat[row] tag = self._project.tags[tag_idx] new_tag = tag.model_copy(update={"alarms": [x for x in tag.alarms if x.id != a.id]}) new_tags = list(self._project.tags) new_tags[tag_idx] = new_tag try: self._project = self._project.model_copy(update={"tags": new_tags}) self._project = self._project.__class__.model_validate(self._project.model_dump()) except Exception as exc: QMessageBox.critical(self, "Validación falló", str(exc)) return self._project.touch() self.projectMutated.emit(self._project) self._refresh_table() class _AddAlarmDialog(QDialog): def __init__(self, project: Project, parent: QWidget | None = None) -> None: super().__init__(parent) self._project = project self.setWindowTitle("Agregar configuración de alarma") self.setMinimumWidth(420) layout = QVBoxLayout(self) form = QFormLayout() self._tag_combo = QComboBox() for idx, t in enumerate(project.tags): self._tag_combo.addItem(t.id, idx) form.addRow("Tag:", self._tag_combo) self._level = QLineEdit("low") form.addRow("Nivel (low/high/hihi/etc):", self._level) self._op = QComboBox() for op in (">", ">=", "<", "<=", "==", "!="): self._op.addItem(op, op) form.addRow("Operador:", self._op) self._threshold = QDoubleSpinBox() self._threshold.setRange(-1e9, 1e9) self._threshold.setDecimals(3) self._threshold.setValue(0.0) form.addRow("Threshold:", self._threshold) self._priority = QComboBox() for p in AlarmPriority: self._priority.addItem(p.value, p.value) form.addRow("Prioridad:", self._priority) self._hyst = QDoubleSpinBox() self._hyst.setRange(0.0, 1e6) self._hyst.setDecimals(3) form.addRow("Histéresis:", self._hyst) self._delay = QDoubleSpinBox() self._delay.setRange(0.0, 300.0) self._delay.setDecimals(1) form.addRow("Delay (s):", self._delay) self._message = QLineEdit() form.addRow("Mensaje:", self._message) layout.addLayout(form) buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) def build(self) -> tuple[int, AlarmConfig | None]: tag_idx = self._tag_combo.currentData() if tag_idx is None: return -1, None tag = self._project.tags[tag_idx] try: alarm = AlarmConfig( id=make_alarm_config_id(tag.id, self._level.text() or "low"), threshold=self._threshold.value(), operator=self._op.currentData(), priority=AlarmPriority(self._priority.currentData()), hysteresis=self._hyst.value(), delay_seconds=self._delay.value(), message=self._message.text(), ) return tag_idx, alarm except Exception: return -1, None