feature/integracion-cpanel-asrecovery
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
"""Tab de configuración."""
|
||||
|
||||
import sys
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox,
|
||||
QFormLayout, QLineEdit, QPushButton, QSpinBox,
|
||||
@@ -104,6 +106,12 @@ class ConfigTab(QWidget):
|
||||
self.sql_windows_auth_checkbox.setChecked(True)
|
||||
self.sql_windows_auth_checkbox.toggled.connect(self._on_auth_changed)
|
||||
sql_layout.addRow("Usar Windows Auth:", self.sql_windows_auth_checkbox)
|
||||
if sys.platform != "win32":
|
||||
self.sql_windows_auth_checkbox.setChecked(False)
|
||||
self.sql_windows_auth_checkbox.setEnabled(False)
|
||||
self.sql_windows_auth_checkbox.setToolTip(
|
||||
"Windows Auth no está disponible en Linux; use SQL Auth."
|
||||
)
|
||||
|
||||
self.sql_username_input = QLineEdit()
|
||||
self.sql_username_input.setEnabled(False)
|
||||
|
||||
@@ -32,6 +32,14 @@ class DashboardTab(QWidget):
|
||||
title_font.setBold(True)
|
||||
title.setFont(title_font)
|
||||
layout.addWidget(title)
|
||||
|
||||
self.panel_warning_label = QLabel("")
|
||||
self.panel_warning_label.setWordWrap(True)
|
||||
self.panel_warning_label.setStyleSheet(
|
||||
"background-color: #fff3cd; color: #856404; padding: 8px; border-radius: 4px;"
|
||||
)
|
||||
self.panel_warning_label.hide()
|
||||
layout.addWidget(self.panel_warning_label)
|
||||
|
||||
# Grid de estadísticas
|
||||
stats_group = QGroupBox("Estadísticas de Jobs")
|
||||
@@ -94,17 +102,29 @@ class DashboardTab(QWidget):
|
||||
times_group.setLayout(times_layout)
|
||||
layout.addWidget(times_group)
|
||||
|
||||
self.engine_status_badge = QLabel("Detenido")
|
||||
badge_font = QFont()
|
||||
badge_font.setPointSize(12)
|
||||
badge_font.setBold(True)
|
||||
self.engine_status_badge.setFont(badge_font)
|
||||
self.engine_status_badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.engine_status_badge.setStyleSheet(
|
||||
"background-color: #e9ecef; color: #495057; padding: 6px 14px; "
|
||||
"border-radius: 4px;"
|
||||
)
|
||||
layout.addWidget(self.engine_status_badge)
|
||||
|
||||
# Estado del motor
|
||||
motor_group = QGroupBox("Estado del Motor")
|
||||
motor_layout = QHBoxLayout()
|
||||
|
||||
|
||||
self.motor_status_label = QLabel("Detenido")
|
||||
motor_status_font = QFont()
|
||||
motor_status_font.setPointSize(14)
|
||||
motor_status_font.setBold(True)
|
||||
self.motor_status_label.setFont(motor_status_font)
|
||||
self.motor_status_label.setStyleSheet("color: red;")
|
||||
|
||||
|
||||
motor_layout.addWidget(QLabel("Estado:"))
|
||||
motor_layout.addWidget(self.motor_status_label)
|
||||
motor_layout.addStretch()
|
||||
@@ -136,6 +156,7 @@ class DashboardTab(QWidget):
|
||||
|
||||
def _load_initial_stats(self):
|
||||
"""Carga las estadísticas iniciales."""
|
||||
self.update_motor_status()
|
||||
stats = self.engine.get_stats()
|
||||
self.update_stats(stats)
|
||||
self._refresh_recent_jobs()
|
||||
@@ -193,3 +214,40 @@ class DashboardTab(QWidget):
|
||||
self.recent_table.setItem(i, 4, status_item)
|
||||
|
||||
self.recent_table.resizeColumnsToContents()
|
||||
|
||||
def update_motor_status(self) -> None:
|
||||
"""Actualiza badge y etiqueta según el estado actual del motor."""
|
||||
if not self.engine.is_running():
|
||||
text = "Detenido"
|
||||
badge_style = (
|
||||
"background-color: #e9ecef; color: #495057; padding: 6px 14px; "
|
||||
"border-radius: 4px;"
|
||||
)
|
||||
label_style = "color: red;"
|
||||
elif self.engine.is_paused():
|
||||
text = "Pausado"
|
||||
badge_style = (
|
||||
"background-color: #fff3cd; color: #856404; padding: 6px 14px; "
|
||||
"border-radius: 4px;"
|
||||
)
|
||||
label_style = "color: orange;"
|
||||
else:
|
||||
text = "Activo"
|
||||
badge_style = (
|
||||
"background-color: #d4edda; color: #155724; padding: 6px 14px; "
|
||||
"border-radius: 4px;"
|
||||
)
|
||||
label_style = "color: green;"
|
||||
|
||||
self.engine_status_badge.setText(f"Motor: {text}")
|
||||
self.engine_status_badge.setStyleSheet(badge_style)
|
||||
self.motor_status_label.setText(text)
|
||||
self.motor_status_label.setStyleSheet(label_style)
|
||||
|
||||
def set_panel_warning(self, message: str) -> None:
|
||||
"""Muestra aviso cuando falta configuración del servicio de bases en config/.env."""
|
||||
if message:
|
||||
self.panel_warning_label.setText(message)
|
||||
self.panel_warning_label.show()
|
||||
else:
|
||||
self.panel_warning_label.hide()
|
||||
|
||||
@@ -2,181 +2,207 @@
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QMainWindow, QWidget, QVBoxLayout, QTabWidget,
|
||||
QSystemTrayIcon, QMenu
|
||||
QSystemTrayIcon, QMenu, QMessageBox, QApplication,
|
||||
)
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QIcon, QCloseEvent, QAction
|
||||
from PySide6.QtGui import QCloseEvent, QAction
|
||||
|
||||
from .dashboard_tab import DashboardTab
|
||||
from .jobs_tab import JobsTab
|
||||
from .nodes_tab import NodesTab
|
||||
from .config_tab import ConfigTab
|
||||
from .logs_tab import LogsTab
|
||||
from .tray_assets import load_tray_icon
|
||||
from ..engine.engine import RestoreEngine
|
||||
from ..utils.logger import app_logger
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""Ventana principal de la aplicación."""
|
||||
|
||||
# Señal para minimizar a tray
|
||||
|
||||
minimize_to_tray = Signal()
|
||||
|
||||
def __init__(self):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
minimized: bool = False,
|
||||
start_engine: bool = False,
|
||||
panel_configured: bool = True,
|
||||
):
|
||||
"""Inicializa la ventana principal."""
|
||||
super().__init__()
|
||||
|
||||
# Motor de restauración
|
||||
|
||||
self._start_on_load = start_engine
|
||||
self._minimized_on_load = minimized
|
||||
self._panel_configured = panel_configured
|
||||
self._force_quit = False
|
||||
self._tray_hint_shown = False
|
||||
|
||||
self.engine = RestoreEngine()
|
||||
|
||||
# Configurar ventana
|
||||
|
||||
self.setWindowTitle("CloudRestoreAS - Restauración Automática SQL Server")
|
||||
self.setMinimumSize(1200, 800)
|
||||
|
||||
# System tray
|
||||
self.tray_icon: QSystemTrayIcon = None
|
||||
|
||||
self.tray_icon: QSystemTrayIcon | None = None
|
||||
self._setup_tray()
|
||||
|
||||
# UI
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
# Conectar señales del motor
|
||||
self._connect_engine_signals()
|
||||
|
||||
# Timer para actualizar stats
|
||||
|
||||
self.stats_timer = QTimer()
|
||||
self.stats_timer.timeout.connect(self._update_stats)
|
||||
self.stats_timer.start(5000) # Cada 5 segundos
|
||||
|
||||
self.stats_timer.start(5000)
|
||||
|
||||
if not self._panel_configured:
|
||||
self.dashboard_tab.set_panel_warning(
|
||||
"Configure la conexión al servicio de bases en config/.env "
|
||||
"(CLOUDRESTORE_PANEL_API_URL, TOKEN e INSTANCE_KEY)."
|
||||
)
|
||||
|
||||
if self._start_on_load:
|
||||
QTimer.singleShot(500, self._start_engine)
|
||||
|
||||
if self._minimized_on_load:
|
||||
QTimer.singleShot(100, self.hide)
|
||||
|
||||
self._update_tray_status()
|
||||
self.dashboard_tab.update_motor_status()
|
||||
app_logger.info("Ventana principal inicializada")
|
||||
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Configura la interfaz de usuario."""
|
||||
# Widget central
|
||||
central_widget = QWidget()
|
||||
self.setCentralWidget(central_widget)
|
||||
|
||||
# Layout principal
|
||||
|
||||
layout = QVBoxLayout(central_widget)
|
||||
layout.setContentsMargins(10, 10, 10, 10)
|
||||
|
||||
# Tabs
|
||||
|
||||
self.tabs = QTabWidget()
|
||||
|
||||
# Crear tabs
|
||||
|
||||
self.dashboard_tab = DashboardTab(self.engine)
|
||||
self.jobs_tab = JobsTab(self.engine)
|
||||
self.nodes_tab = NodesTab(self.engine)
|
||||
self.config_tab = ConfigTab(self.engine)
|
||||
self.logs_tab = LogsTab(self.engine)
|
||||
|
||||
# Agregar tabs
|
||||
|
||||
self.tabs.addTab(self.dashboard_tab, "📊 Dashboard")
|
||||
self.tabs.addTab(self.jobs_tab, "📋 Jobs")
|
||||
self.tabs.addTab(self.nodes_tab, "🔗 Nodos")
|
||||
self.tabs.addTab(self.config_tab, "⚙️ Configuración")
|
||||
self.tabs.addTab(self.logs_tab, "📝 Logs")
|
||||
|
||||
|
||||
layout.addWidget(self.tabs)
|
||||
|
||||
# Menu bar
|
||||
self._setup_menu()
|
||||
|
||||
|
||||
def _setup_menu(self):
|
||||
"""Configura el menú."""
|
||||
menubar = self.menuBar()
|
||||
|
||||
# Menú Motor
|
||||
|
||||
file_menu = menubar.addMenu("Archivo")
|
||||
|
||||
quit_action = QAction("Salir", self)
|
||||
quit_action.triggered.connect(self._quit_application)
|
||||
file_menu.addAction(quit_action)
|
||||
|
||||
motor_menu = menubar.addMenu("Motor")
|
||||
|
||||
|
||||
self.start_action = QAction("▶️ Iniciar", self)
|
||||
self.start_action.triggered.connect(self._start_engine)
|
||||
motor_menu.addAction(self.start_action)
|
||||
|
||||
|
||||
self.pause_action = QAction("⏸️ Pausar", self)
|
||||
self.pause_action.triggered.connect(self._pause_engine)
|
||||
self.pause_action.setEnabled(False)
|
||||
motor_menu.addAction(self.pause_action)
|
||||
|
||||
|
||||
self.resume_action = QAction("▶️ Continuar", self)
|
||||
self.resume_action.triggered.connect(self._resume_engine)
|
||||
self.resume_action.setEnabled(False)
|
||||
motor_menu.addAction(self.resume_action)
|
||||
|
||||
|
||||
self.stop_action = QAction("⏹️ Detener", self)
|
||||
self.stop_action.triggered.connect(self._stop_engine)
|
||||
self.stop_action.setEnabled(False)
|
||||
motor_menu.addAction(self.stop_action)
|
||||
|
||||
|
||||
motor_menu.addSeparator()
|
||||
|
||||
|
||||
self.scan_action = QAction("🔍 Escanear Ahora", self)
|
||||
self.scan_action.triggered.connect(self._scan_now)
|
||||
motor_menu.addAction(self.scan_action)
|
||||
|
||||
# Menú Ayuda
|
||||
|
||||
help_menu = menubar.addMenu("Ayuda")
|
||||
|
||||
|
||||
about_action = QAction("Acerca de", self)
|
||||
about_action.triggered.connect(self._show_about)
|
||||
help_menu.addAction(about_action)
|
||||
|
||||
|
||||
def _setup_tray(self):
|
||||
"""Configura el icono de bandeja del sistema."""
|
||||
# Crear icono (usar un icono por defecto o crear uno simple)
|
||||
if not QSystemTrayIcon.isSystemTrayAvailable():
|
||||
app_logger.warning("Bandeja del sistema no disponible en este entorno")
|
||||
return
|
||||
|
||||
self.tray_icon = QSystemTrayIcon(self)
|
||||
self.tray_icon.setIcon(load_tray_icon())
|
||||
self.tray_icon.setToolTip("CloudRestoreAS")
|
||||
|
||||
# Menú del tray
|
||||
|
||||
tray_menu = QMenu()
|
||||
|
||||
|
||||
show_action = QAction("Mostrar", self)
|
||||
show_action.triggered.connect(self._show_window)
|
||||
tray_menu.addAction(show_action)
|
||||
|
||||
|
||||
hide_action = QAction("Ocultar", self)
|
||||
hide_action.triggered.connect(self.hide)
|
||||
tray_menu.addAction(hide_action)
|
||||
|
||||
|
||||
tray_menu.addSeparator()
|
||||
|
||||
|
||||
pause_tray_action = QAction("Pausar", self)
|
||||
pause_tray_action.triggered.connect(self._pause_engine)
|
||||
tray_menu.addAction(pause_tray_action)
|
||||
|
||||
|
||||
resume_tray_action = QAction("Continuar", self)
|
||||
resume_tray_action.triggered.connect(self._resume_engine)
|
||||
tray_menu.addAction(resume_tray_action)
|
||||
|
||||
|
||||
tray_menu.addSeparator()
|
||||
|
||||
quit_action = QAction("Salir", self)
|
||||
quit_action.triggered.connect(self._quit_application)
|
||||
tray_menu.addAction(quit_action)
|
||||
|
||||
|
||||
quit_tray_action = QAction("Salir", self)
|
||||
quit_tray_action.triggered.connect(self._quit_application)
|
||||
tray_menu.addAction(quit_tray_action)
|
||||
|
||||
self.tray_icon.setContextMenu(tray_menu)
|
||||
|
||||
# Doble clic en tray para mostrar/ocultar
|
||||
self.tray_icon.activated.connect(self._on_tray_activated)
|
||||
|
||||
# Mostrar tray
|
||||
self.tray_icon.show()
|
||||
|
||||
|
||||
def _connect_engine_signals(self):
|
||||
"""Conecta las señales del motor."""
|
||||
self.engine.signals.job_created.connect(self._on_job_created)
|
||||
self.engine.signals.stats_updated.connect(self._on_stats_updated)
|
||||
self.engine.signals.config_loaded.connect(self._on_config_loaded)
|
||||
|
||||
|
||||
def closeEvent(self, event: QCloseEvent):
|
||||
"""Maneja el evento de cierre de ventana (minimizar a tray)."""
|
||||
if self.tray_icon.isVisible():
|
||||
"""Minimiza a bandeja al cerrar la ventana (no sale de la aplicación)."""
|
||||
if self._force_quit:
|
||||
event.accept()
|
||||
return
|
||||
|
||||
if self.tray_icon and self.tray_icon.isVisible():
|
||||
self.hide()
|
||||
event.ignore()
|
||||
app_logger.info("Ventana minimizada a bandeja del sistema")
|
||||
if not self._tray_hint_shown:
|
||||
self.tray_icon.showMessage(
|
||||
"CloudRestoreAS sigue en ejecución",
|
||||
"La aplicación está en la bandeja. Use clic derecho → Salir para cerrar.",
|
||||
QSystemTrayIcon.MessageIcon.Information,
|
||||
4000,
|
||||
)
|
||||
self._tray_hint_shown = True
|
||||
else:
|
||||
event.accept()
|
||||
|
||||
|
||||
def _on_tray_activated(self, reason):
|
||||
"""Maneja la activación del icono de tray."""
|
||||
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
|
||||
@@ -184,35 +210,56 @@ class MainWindow(QMainWindow):
|
||||
self.hide()
|
||||
else:
|
||||
self._show_window()
|
||||
|
||||
|
||||
def _show_window(self):
|
||||
"""Muestra la ventana."""
|
||||
self.show()
|
||||
self.activateWindow()
|
||||
self.raise_()
|
||||
|
||||
|
||||
def _motor_status_text(self) -> str:
|
||||
if not self.engine.is_running():
|
||||
return "Detenido"
|
||||
if self.engine.is_paused():
|
||||
return "Pausado"
|
||||
return "Activo"
|
||||
|
||||
def _update_tray_status(self):
|
||||
"""Actualiza tooltip del icono de bandeja según estado del motor."""
|
||||
if not self.tray_icon:
|
||||
return
|
||||
status = self._motor_status_text()
|
||||
self.tray_icon.setToolTip(f"CloudRestoreAS — Motor: {status}")
|
||||
|
||||
def _start_engine(self):
|
||||
"""Inicia el motor."""
|
||||
self.engine.start()
|
||||
self.start_action.setEnabled(False)
|
||||
self.pause_action.setEnabled(True)
|
||||
self.stop_action.setEnabled(True)
|
||||
self.resume_action.setEnabled(False)
|
||||
self._update_tray_status()
|
||||
self.dashboard_tab.update_motor_status()
|
||||
app_logger.info("Motor iniciado desde UI")
|
||||
|
||||
|
||||
def _pause_engine(self):
|
||||
"""Pausa el motor."""
|
||||
self.engine.pause()
|
||||
self.pause_action.setEnabled(False)
|
||||
self.resume_action.setEnabled(True)
|
||||
self._update_tray_status()
|
||||
self.dashboard_tab.update_motor_status()
|
||||
app_logger.info("Motor pausado desde UI")
|
||||
|
||||
|
||||
def _resume_engine(self):
|
||||
"""Reanuda el motor."""
|
||||
self.engine.resume()
|
||||
self.resume_action.setEnabled(False)
|
||||
self.pause_action.setEnabled(True)
|
||||
self._update_tray_status()
|
||||
self.dashboard_tab.update_motor_status()
|
||||
app_logger.info("Motor reanudado desde UI")
|
||||
|
||||
|
||||
def _stop_engine(self):
|
||||
"""Detiene el motor."""
|
||||
self.engine.stop()
|
||||
@@ -220,48 +267,53 @@ class MainWindow(QMainWindow):
|
||||
self.pause_action.setEnabled(False)
|
||||
self.resume_action.setEnabled(False)
|
||||
self.start_action.setEnabled(True)
|
||||
self._update_tray_status()
|
||||
self.dashboard_tab.update_motor_status()
|
||||
app_logger.info("Motor detenido desde UI")
|
||||
|
||||
|
||||
def _scan_now(self):
|
||||
"""Fuerza un escaneo manual."""
|
||||
self.engine.scan_now()
|
||||
app_logger.info("Escaneo manual solicitado desde UI")
|
||||
|
||||
|
||||
def _update_stats(self):
|
||||
"""Actualiza las estadísticas."""
|
||||
self._update_tray_status()
|
||||
self.dashboard_tab.update_motor_status()
|
||||
if self.engine.is_running():
|
||||
stats = self.engine.get_stats()
|
||||
self.dashboard_tab.update_stats(stats)
|
||||
|
||||
|
||||
def _on_job_created(self, job_id: str):
|
||||
"""Maneja la creación de un nuevo job."""
|
||||
self.jobs_tab.refresh_jobs()
|
||||
self._update_stats()
|
||||
|
||||
|
||||
def _on_stats_updated(self, stats: dict):
|
||||
"""Maneja la actualización de estadísticas."""
|
||||
self.dashboard_tab.update_stats(stats)
|
||||
|
||||
|
||||
def _on_config_loaded(self, config: dict):
|
||||
"""Maneja la carga de configuración."""
|
||||
self.config_tab.load_config(config)
|
||||
|
||||
|
||||
def _show_about(self):
|
||||
"""Muestra el diálogo Acerca de."""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
QMessageBox.about(
|
||||
self,
|
||||
"Acerca de CloudRestoreAS",
|
||||
"CloudRestoreAS v1.0.0\n\n"
|
||||
"Aplicación de restauración automática de bases de datos SQL Server.\n\n"
|
||||
"© 2026 Aduanasoft"
|
||||
"Al cerrar la ventana, la aplicación permanece en la bandeja del sistema.\n"
|
||||
"Use Archivo → Salir o la bandeja → Salir para cerrar por completo.\n\n"
|
||||
"© 2026 Aduanasoft",
|
||||
)
|
||||
|
||||
|
||||
def _quit_application(self):
|
||||
"""Sale de la aplicación completamente."""
|
||||
app_logger.info("Saliendo de la aplicación")
|
||||
self._force_quit = True
|
||||
self.engine.stop()
|
||||
self.tray_icon.hide()
|
||||
self.close()
|
||||
from PySide6.QtWidgets import QApplication
|
||||
if self.tray_icon:
|
||||
self.tray_icon.hide()
|
||||
QApplication.quit()
|
||||
|
||||
55
app/ui/tray_assets.py
Normal file
55
app/ui/tray_assets.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Icono de bandeja del sistema."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap
|
||||
|
||||
_ASSETS_DIR = Path(__file__).resolve().parent.parent.parent / "packaging" / "assets"
|
||||
|
||||
|
||||
def _generated_tray_icon() -> QIcon:
|
||||
"""Icono simple en memoria si no hay archivo empaquetado."""
|
||||
size = 32
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setBrush(QColor(0, 120, 215))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawRoundedRect(2, 2, size - 4, size - 4, 6, 6)
|
||||
painter.setPen(QColor(255, 255, 255))
|
||||
font = painter.font()
|
||||
font.setBold(True)
|
||||
font.setPointSize(14)
|
||||
painter.setFont(font)
|
||||
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "C")
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
|
||||
|
||||
def load_tray_icon() -> QIcon:
|
||||
"""Carga icono de bandeja desde assets empaquetados o genera uno por defecto."""
|
||||
candidates: list[Path] = []
|
||||
meipass = getattr(sys, "_MEIPASS", None)
|
||||
if meipass:
|
||||
base = Path(meipass)
|
||||
candidates.extend(
|
||||
[
|
||||
base / "packaging" / "assets" / "tray-icon.png",
|
||||
base / "packaging" / "assets" / "tray-icon.ico",
|
||||
]
|
||||
)
|
||||
candidates.extend(
|
||||
[
|
||||
_ASSETS_DIR / "tray-icon.png",
|
||||
_ASSETS_DIR / "tray-icon.ico",
|
||||
]
|
||||
)
|
||||
for path in candidates:
|
||||
if path.is_file():
|
||||
icon = QIcon(str(path))
|
||||
if not icon.isNull():
|
||||
return icon
|
||||
return _generated_tray_icon()
|
||||
Reference in New Issue
Block a user